Skip to main content

codlet_core/
hashing.rs

1//! Secret hashing, key providers, domain separation, and key versioning
2//! (RFC-004).
3//!
4//! Every persisted secret (code, session, form token) is stored only as a
5//! keyed HMAC [`LookupKey`], never in plaintext (INV-1). Lookup keys are
6//! domain-separated so the same plaintext used in two roles derives two
7//! different keys, and every derivation is tagged with the [`KeyVersion`] of
8//! the key that produced it so keys can be rotated without an all-or-nothing
9//! migration (RFC-004 §12.2).
10//!
11//! ## Derivation scheme (prefixing — RFC-004 §9.1 recommendation)
12//!
13//! ```text
14//! message = "codlet/v1/lookup" || 0x00 || domain_label || 0x00 || secret_bytes
15//! LookupKey = lowercase_hex( HMAC-SHA256(key_bytes, message) )
16//! ```
17//!
18//! The fixed context string and `0x00` separators make the label and secret
19//! unambiguous, so distinct domains cannot collide. This is intentionally
20//! **not** the `zinnias-ciao` derivation (`HMAC(pepper, value)` with no domain
21//! or prefix); the migration adapter (RFC-014) supplies a legacy mode for
22//! existing rows.
23
24use hmac::{Hmac, Mac};
25use sha2::Sha256;
26use subtle::ConstantTimeEq;
27
28use crate::FORMAT_VERSION;
29use crate::error::KeyError;
30
31type HmacSha256 = Hmac<Sha256>;
32
33/// The lookup context label, combined with [`FORMAT_VERSION`] into the HMAC
34/// message prefix. Bumping the format version changes every derived key.
35const LOOKUP_CONTEXT: &str = "lookup";
36
37/// Identifier of the key version that produced a [`LookupKey`]. Stored beside
38/// every lookup key (RFC-004 §12.2). Not secret.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct KeyVersion(String);
42
43impl KeyVersion {
44    /// Wrap a version label.
45    #[must_use]
46    pub fn new(value: impl Into<String>) -> Self {
47        Self(value.into())
48    }
49
50    /// Borrow the version label.
51    #[must_use]
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55}
56
57impl core::fmt::Display for KeyVersion {
58    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
59        f.write_str(&self.0)
60    }
61}
62
63/// A keyed lookup value: the lowercase-hex HMAC of a domain-separated message.
64///
65/// Contains no plaintext and is safe to persist, but is still sensitive (it is
66/// the database lookup index). Compare lookup keys with
67/// [`LookupKey::ct_eq`], not `==`, when the comparison could be timing-attacked.
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub struct LookupKey(String);
71
72impl LookupKey {
73    /// Borrow the hex digest.
74    #[must_use]
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78
79    /// Constant-time equality over the hex bytes (RFC-004; service parity with
80    /// `hmac_hex_eq`). Length is allowed to leak: lookup keys are fixed-width.
81    #[must_use]
82    pub fn ct_eq(&self, other: &LookupKey) -> bool {
83        let a = self.0.as_bytes();
84        let b = other.0.as_bytes();
85        if a.len() != b.len() {
86            return false;
87        }
88        a.ct_eq(b).into()
89    }
90}
91
92/// The role a secret plays. Part of the HMAC message, so it cross-namespaces
93/// lookup keys (RFC-004 §12.1).
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum SecretDomain {
96    /// One-time code lookup.
97    Code,
98    /// Session secret lookup.
99    Session,
100    /// Form-token lookup.
101    FormToken,
102    /// Pre-auth flow / join-ticket lookup.
103    FlowTicket,
104}
105
106impl SecretDomain {
107    /// The stable wire label embedded in the HMAC message. Changing these
108    /// strings is a breaking change to stored lookup keys.
109    #[must_use]
110    pub const fn label(self) -> &'static str {
111        match self {
112            SecretDomain::Code => "code",
113            SecretDomain::Session => "session",
114            SecretDomain::FormToken => "form_token",
115            SecretDomain::FlowTicket => "flow_ticket",
116        }
117    }
118}
119
120/// A borrowed HMAC key plus the version that identifies it.
121pub struct HmacKeyRef<'a> {
122    /// The version label of this key.
123    pub version: KeyVersion,
124    /// The raw key bytes. Never logged or formatted.
125    pub bytes: &'a [u8],
126}
127
128impl core::fmt::Debug for HmacKeyRef<'_> {
129    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130        f.debug_struct("HmacKeyRef")
131            .field("version", &self.version)
132            .field("bytes", &"<redacted>")
133            .finish()
134    }
135}
136
137/// Supplies HMAC key material. Synchronous, so key lookup does not couple to a
138/// web/runtime async model (RFC-004 §3.3). **No fallback key exists**: missing
139/// material is an error (INV-2, SR-29).
140pub trait KeyProvider {
141    /// The active key used for new derivations.
142    ///
143    /// # Errors
144    /// [`KeyError::MissingActiveKey`] if none is configured.
145    fn active_hmac_key(&self) -> Result<HmacKeyRef<'_>, KeyError>;
146
147    /// A specific historical key, for validating records written under an older
148    /// version during rotation.
149    ///
150    /// # Errors
151    /// [`KeyError::MissingKeyVersion`] if that version is unknown. Callers must
152    /// fail closed for that candidate rather than falling back.
153    fn hmac_key_by_version(&self, version: &KeyVersion) -> Result<HmacKeyRef<'_>, KeyError>;
154}
155
156/// A key provider holding an active key and zero or more previous keys, in
157/// memory. Suitable for production when constructed from real secret material
158/// loaded at startup, and for tests/examples.
159///
160/// There is deliberately no `Default` or empty constructor that would yield a
161/// usable-but-keyless provider: you must supply real bytes (INV-2).
162#[derive(Clone)]
163pub struct StaticKeyProvider {
164    active_version: KeyVersion,
165    keys: Vec<(KeyVersion, Vec<u8>)>,
166}
167
168impl StaticKeyProvider {
169    /// Construct from an active version+key and optional previous versions.
170    ///
171    /// # Errors
172    /// [`KeyError::InvalidKeyMaterial`] if the active key is empty.
173    pub fn new(
174        active_version: impl Into<String>,
175        active_key: Vec<u8>,
176        previous: Vec<(KeyVersion, Vec<u8>)>,
177    ) -> Result<Self, KeyError> {
178        if active_key.is_empty() {
179            return Err(KeyError::InvalidKeyMaterial);
180        }
181        let active_version = KeyVersion::new(active_version);
182        let mut keys = Vec::with_capacity(previous.len() + 1);
183        keys.push((active_version.clone(), active_key));
184        keys.extend(previous);
185        Ok(Self {
186            active_version,
187            keys,
188        })
189    }
190
191    /// Convenience constructor with a single key and no previous versions.
192    ///
193    /// # Errors
194    /// [`KeyError::InvalidKeyMaterial`] if `key` is empty.
195    pub fn single(version: impl Into<String>, key: Vec<u8>) -> Result<Self, KeyError> {
196        Self::new(version, key, Vec::new())
197    }
198}
199
200impl core::fmt::Debug for StaticKeyProvider {
201    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
202        f.debug_struct("StaticKeyProvider")
203            .field("active_version", &self.active_version)
204            .field("key_versions", &self.keys.len())
205            .field("keys", &"<redacted>")
206            .finish()
207    }
208}
209
210impl KeyProvider for StaticKeyProvider {
211    fn active_hmac_key(&self) -> Result<HmacKeyRef<'_>, KeyError> {
212        self.keys
213            .iter()
214            .find(|(v, _)| *v == self.active_version)
215            .map(|(v, k)| HmacKeyRef {
216                version: v.clone(),
217                bytes: k,
218            })
219            .ok_or(KeyError::MissingActiveKey)
220    }
221
222    fn hmac_key_by_version(&self, version: &KeyVersion) -> Result<HmacKeyRef<'_>, KeyError> {
223        self.keys
224            .iter()
225            .find(|(v, _)| v == version)
226            .map(|(v, k)| HmacKeyRef {
227                version: v.clone(),
228                bytes: k,
229            })
230            .ok_or(KeyError::MissingKeyVersion)
231    }
232}
233
234/// Derives [`LookupKey`]s from secrets using a [`KeyProvider`].
235#[derive(Debug, Clone)]
236pub struct SecretHasher<K> {
237    key_provider: K,
238}
239
240impl<K: KeyProvider> SecretHasher<K> {
241    /// Wrap a key provider.
242    #[must_use]
243    pub fn new(key_provider: K) -> Self {
244        Self { key_provider }
245    }
246
247    /// Borrow the underlying key provider.
248    #[must_use]
249    pub fn key_provider(&self) -> &K {
250        &self.key_provider
251    }
252
253    /// Derive a lookup key for `value` in `domain` using the **active** key.
254    /// Returns the key plus the active [`KeyVersion`] to store alongside it.
255    ///
256    /// # Errors
257    /// Propagates [`KeyError`] from the provider (e.g. missing active key).
258    pub fn lookup_key(
259        &self,
260        domain: SecretDomain,
261        value: &str,
262    ) -> Result<(LookupKey, KeyVersion), KeyError> {
263        let key = self.key_provider.active_hmac_key()?;
264        let lk = derive(key.bytes, domain, value);
265        Ok((lk, key.version))
266    }
267
268    /// Derive a lookup key for `value` in `domain` using a specific key
269    /// `version`. Used during validation to re-derive candidates for records
270    /// written under older keys.
271    ///
272    /// # Errors
273    /// Propagates [`KeyError::MissingKeyVersion`] if the version is unknown.
274    pub fn lookup_key_with_version(
275        &self,
276        domain: SecretDomain,
277        value: &str,
278        version: &KeyVersion,
279    ) -> Result<LookupKey, KeyError> {
280        let key = self.key_provider.hmac_key_by_version(version)?;
281        Ok(derive(key.bytes, domain, value))
282    }
283}
284
285/// Pure derivation: `HMAC-SHA256(key, ctx || 0x00 || domain || 0x00 || value)`,
286/// returned as lowercase hex. Kept private; the public surface goes through
287/// [`SecretHasher`].
288fn derive(key_bytes: &[u8], domain: SecretDomain, value: &str) -> LookupKey {
289    // HMAC accepts any key length; new_from_slice only errors for impossible
290    // key sizes which Hmac<Sha256> does not have, so this cannot fail.
291    let mut mac =
292        HmacSha256::new_from_slice(key_bytes).expect("HMAC-SHA256 accepts any key length");
293    mac.update(FORMAT_VERSION.as_bytes());
294    mac.update(b"/");
295    mac.update(LOOKUP_CONTEXT.as_bytes());
296    mac.update(&[0u8]);
297    mac.update(domain.label().as_bytes());
298    mac.update(&[0u8]);
299    mac.update(value.as_bytes());
300    let digest = mac.finalize().into_bytes();
301    LookupKey(hex_lower(&digest))
302}
303
304/// Lowercase hex encoding without pulling in the `hex` crate, keeping the core
305/// dependency set minimal (NFR-3).
306fn hex_lower(bytes: &[u8]) -> String {
307    const HEX: &[u8; 16] = b"0123456789abcdef";
308    let mut s = String::with_capacity(bytes.len() * 2);
309    for &b in bytes {
310        s.push(HEX[(b >> 4) as usize] as char);
311        s.push(HEX[(b & 0x0f) as usize] as char);
312    }
313    s
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn hasher() -> SecretHasher<StaticKeyProvider> {
321        let kp = StaticKeyProvider::single("v1", b"super-secret-key-material".to_vec()).unwrap();
322        SecretHasher::new(kp)
323    }
324
325    #[test]
326    fn deterministic_same_inputs_same_key() {
327        let h = hasher();
328        let (a, va) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
329        let (b, vb) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
330        assert_eq!(a, b);
331        assert_eq!(va, vb);
332        assert_eq!(va.as_str(), "v1");
333        // 32-byte digest → 64 hex chars.
334        assert_eq!(a.as_str().len(), 64);
335        assert!(a.as_str().bytes().all(|c| c.is_ascii_hexdigit()));
336    }
337
338    #[test]
339    fn different_value_different_key() {
340        let h = hasher();
341        let (a, _) = h.lookup_key(SecretDomain::Code, "AAAAAAAA").unwrap();
342        let (b, _) = h.lookup_key(SecretDomain::Code, "BBBBBBBB").unwrap();
343        assert_ne!(a, b);
344    }
345
346    #[test]
347    fn domain_separation_distinguishes_same_value() {
348        let h = hasher();
349        let (code, _) = h.lookup_key(SecretDomain::Code, "SAME").unwrap();
350        let (sess, _) = h.lookup_key(SecretDomain::Session, "SAME").unwrap();
351        let (form, _) = h.lookup_key(SecretDomain::FormToken, "SAME").unwrap();
352        let (flow, _) = h.lookup_key(SecretDomain::FlowTicket, "SAME").unwrap();
353        // All four must differ pairwise.
354        let all = [&code, &sess, &form, &flow];
355        for i in 0..all.len() {
356            for j in (i + 1)..all.len() {
357                assert_ne!(all[i], all[j], "domains {i},{j} collided");
358            }
359        }
360    }
361
362    #[test]
363    fn different_key_different_output() {
364        let h1 = SecretHasher::new(StaticKeyProvider::single("v1", b"key-one".to_vec()).unwrap());
365        let h2 = SecretHasher::new(StaticKeyProvider::single("v1", b"key-two".to_vec()).unwrap());
366        let (a, _) = h1.lookup_key(SecretDomain::Code, "X").unwrap();
367        let (b, _) = h2.lookup_key(SecretDomain::Code, "X").unwrap();
368        assert_ne!(a, b);
369    }
370
371    #[test]
372    fn missing_active_key_fails_closed() {
373        // A provider whose active version points at no stored key.
374        let kp = StaticKeyProvider {
375            active_version: KeyVersion::new("missing"),
376            keys: vec![(KeyVersion::new("v1"), b"k".to_vec())],
377        };
378        let h = SecretHasher::new(kp);
379        assert_eq!(
380            h.lookup_key(SecretDomain::Code, "X").unwrap_err(),
381            KeyError::MissingActiveKey
382        );
383    }
384
385    #[test]
386    fn empty_key_rejected_at_construction() {
387        assert_eq!(
388            StaticKeyProvider::single("v1", Vec::new()).unwrap_err(),
389            KeyError::InvalidKeyMaterial
390        );
391    }
392
393    #[test]
394    fn key_version_round_trip_validation() {
395        // Derive under v1, rotate active to v2, re-derive the v1 candidate.
396        let kp = StaticKeyProvider::new(
397            "v2",
398            b"key-two".to_vec(),
399            vec![(KeyVersion::new("v1"), b"key-one".to_vec())],
400        )
401        .unwrap();
402        let h = SecretHasher::new(kp);
403        let (active, av) = h.lookup_key(SecretDomain::Session, "tok").unwrap();
404        assert_eq!(av.as_str(), "v2");
405        let v1 = KeyVersion::new("v1");
406        let prev = h
407            .lookup_key_with_version(SecretDomain::Session, "tok", &v1)
408            .unwrap();
409        // v1 and v2 derivations differ; the active is v2.
410        assert_ne!(active, prev);
411        // Unknown version fails closed, not fallback.
412        let missing = KeyVersion::new("v9");
413        assert_eq!(
414            h.lookup_key_with_version(SecretDomain::Session, "tok", &missing)
415                .unwrap_err(),
416            KeyError::MissingKeyVersion
417        );
418    }
419
420    #[test]
421    fn lookup_key_ct_eq_matches_value_eq() {
422        let h = hasher();
423        let (a, _) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
424        let (b, _) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
425        let (c, _) = h.lookup_key(SecretDomain::Code, "DIFFEREN").unwrap();
426        assert!(a.ct_eq(&b));
427        assert!(!a.ct_eq(&c));
428    }
429
430    #[test]
431    fn key_material_redacted_in_debug() {
432        let kp = StaticKeyProvider::single("v1", b"secret-bytes".to_vec()).unwrap();
433        let dbg = format!("{kp:?}");
434        assert!(!dbg.contains("secret-bytes"), "key bytes leaked: {dbg}");
435        assert!(dbg.contains("<redacted>"));
436        let key = kp.active_hmac_key().unwrap();
437        let kdbg = format!("{key:?}");
438        assert!(!kdbg.contains("secret-bytes"));
439    }
440}