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** a simple `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, KeyInit, 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    /// All held keys (active first, then previous) for generating verification
156    /// candidates during validation (RFC-A).
157    ///
158    /// The returned vec always contains at least the active key.
159    fn all_hmac_keys(&self) -> Result<Vec<HmacKeyRef<'_>>, KeyError>;
160}
161
162/// A key provider holding an active key and zero or more previous keys, in
163/// memory. Suitable for production when constructed from real secret material
164/// loaded at startup, and for tests/examples.
165///
166/// There is deliberately no `Default` or empty constructor that would yield a
167/// usable-but-keyless provider: you must supply real bytes (INV-2).
168#[derive(Clone)]
169pub struct StaticKeyProvider {
170    active_version: KeyVersion,
171    keys: Vec<(KeyVersion, Vec<u8>)>,
172}
173
174impl StaticKeyProvider {
175    /// Construct from an active version+key and optional previous versions.
176    ///
177    /// # Errors
178    /// [`KeyError::InvalidKeyMaterial`] if the active key is empty.
179    pub fn new(
180        active_version: impl Into<String>,
181        active_key: Vec<u8>,
182        previous: Vec<(KeyVersion, Vec<u8>)>,
183    ) -> Result<Self, KeyError> {
184        if active_key.is_empty() {
185            return Err(KeyError::InvalidKeyMaterial);
186        }
187        let active_version = KeyVersion::new(active_version);
188        let mut keys = Vec::with_capacity(previous.len() + 1);
189        keys.push((active_version.clone(), active_key));
190        keys.extend(previous);
191        Ok(Self {
192            active_version,
193            keys,
194        })
195    }
196
197    /// Convenience constructor with a single key and no previous versions.
198    ///
199    /// # Errors
200    /// [`KeyError::InvalidKeyMaterial`] if `key` is empty.
201    pub fn single(version: impl Into<String>, key: Vec<u8>) -> Result<Self, KeyError> {
202        Self::new(version, key, Vec::new())
203    }
204}
205
206impl core::fmt::Debug for StaticKeyProvider {
207    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
208        f.debug_struct("StaticKeyProvider")
209            .field("active_version", &self.active_version)
210            .field("key_versions", &self.keys.len())
211            .field("keys", &"<redacted>")
212            .finish()
213    }
214}
215
216impl KeyProvider for StaticKeyProvider {
217    fn active_hmac_key(&self) -> Result<HmacKeyRef<'_>, KeyError> {
218        self.keys
219            .iter()
220            .find(|(v, _)| *v == self.active_version)
221            .map(|(v, k)| HmacKeyRef {
222                version: v.clone(),
223                bytes: k,
224            })
225            .ok_or(KeyError::MissingActiveKey)
226    }
227
228    fn hmac_key_by_version(&self, version: &KeyVersion) -> Result<HmacKeyRef<'_>, KeyError> {
229        self.keys
230            .iter()
231            .find(|(v, _)| v == version)
232            .map(|(v, k)| HmacKeyRef {
233                version: v.clone(),
234                bytes: k,
235            })
236            .ok_or(KeyError::MissingKeyVersion)
237    }
238
239    fn all_hmac_keys(&self) -> Result<Vec<HmacKeyRef<'_>>, KeyError> {
240        if self.keys.is_empty() {
241            return Err(KeyError::MissingActiveKey);
242        }
243        Ok(self
244            .keys
245            .iter()
246            .map(|(v, k)| HmacKeyRef {
247                version: v.clone(),
248                bytes: k,
249            })
250            .collect())
251    }
252}
253
254/// Derives [`LookupKey`]s from secrets using a [`KeyProvider`].
255#[derive(Debug, Clone)]
256pub struct SecretHasher<K> {
257    key_provider: K,
258}
259
260impl<K: KeyProvider> SecretHasher<K> {
261    /// Wrap a key provider.
262    #[must_use]
263    pub fn new(key_provider: K) -> Self {
264        Self { key_provider }
265    }
266
267    /// Borrow the underlying key provider.
268    #[must_use]
269    pub fn key_provider(&self) -> &K {
270        &self.key_provider
271    }
272
273    /// Derive a lookup key for `value` in `domain` using the **active** key.
274    /// Returns the key plus the active [`KeyVersion`] to store alongside it.
275    ///
276    /// # Errors
277    /// Propagates [`KeyError`] from the provider (e.g. missing active key).
278    pub fn lookup_key(
279        &self,
280        domain: SecretDomain,
281        value: &str,
282    ) -> Result<(LookupKey, KeyVersion), KeyError> {
283        let key = self.key_provider.active_hmac_key()?;
284        let lk = derive(key.bytes, domain, value);
285        Ok((lk, key.version))
286    }
287
288    /// Derive one lookup-key candidate per held key (active first, then
289    /// previous). Managers pass the full slice to store finders so that
290    /// records written under any held key are reachable during the rotation
291    /// grace period (RFC-A).
292    ///
293    /// # Errors
294    /// Propagates [`KeyError::MissingActiveKey`] if no keys are configured.
295    pub fn lookup_key_candidates(
296        &self,
297        domain: SecretDomain,
298        value: &str,
299    ) -> Result<Vec<(LookupKey, KeyVersion)>, KeyError> {
300        let keys = self.key_provider.all_hmac_keys()?;
301        Ok(keys
302            .into_iter()
303            .map(|k| {
304                let lk = derive(k.bytes, domain, value);
305                (lk, k.version)
306            })
307            .collect())
308    }
309
310    /// Derive a lookup key for `value` in `domain` using a specific key
311    /// `version`. Used during validation to re-derive candidates for records
312    /// written under older keys.
313    ///
314    /// # Errors
315    /// Propagates [`KeyError::MissingKeyVersion`] if the version is unknown.
316    pub fn lookup_key_with_version(
317        &self,
318        domain: SecretDomain,
319        value: &str,
320        version: &KeyVersion,
321    ) -> Result<LookupKey, KeyError> {
322        let key = self.key_provider.hmac_key_by_version(version)?;
323        Ok(derive(key.bytes, domain, value))
324    }
325}
326
327/// Pure derivation: `HMAC-SHA256(key, ctx || 0x00 || domain || 0x00 || value)`,
328/// returned as lowercase hex. Kept private; the public surface goes through
329/// [`SecretHasher`].
330fn derive(key_bytes: &[u8], domain: SecretDomain, value: &str) -> LookupKey {
331    // HMAC accepts any key length; new_from_slice only errors for impossible
332    // key sizes which Hmac<Sha256> does not have, so this cannot fail.
333    let mut mac =
334        HmacSha256::new_from_slice(key_bytes).expect("HMAC-SHA256 accepts any key length");
335    mac.update(FORMAT_VERSION.as_bytes());
336    mac.update(b"/");
337    mac.update(LOOKUP_CONTEXT.as_bytes());
338    mac.update(&[0u8]);
339    mac.update(domain.label().as_bytes());
340    mac.update(&[0u8]);
341    mac.update(value.as_bytes());
342    let digest = mac.finalize().into_bytes();
343    LookupKey(hex_lower(&digest))
344}
345
346/// Lowercase hex encoding without pulling in the `hex` crate, keeping the core
347/// dependency set minimal (NFR-3).
348fn hex_lower(bytes: &[u8]) -> String {
349    const HEX: &[u8; 16] = b"0123456789abcdef";
350    let mut s = String::with_capacity(bytes.len() * 2);
351    for &b in bytes {
352        s.push(HEX[(b >> 4) as usize] as char);
353        s.push(HEX[(b & 0x0f) as usize] as char);
354    }
355    s
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn hasher() -> SecretHasher<StaticKeyProvider> {
363        let kp = StaticKeyProvider::single("v1", b"super-secret-key-material".to_vec()).unwrap();
364        SecretHasher::new(kp)
365    }
366
367    #[test]
368    fn deterministic_same_inputs_same_key() {
369        let h = hasher();
370        let (a, va) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
371        let (b, vb) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
372        assert_eq!(a, b);
373        assert_eq!(va, vb);
374        assert_eq!(va.as_str(), "v1");
375        // 32-byte digest → 64 hex chars.
376        assert_eq!(a.as_str().len(), 64);
377        assert!(a.as_str().bytes().all(|c| c.is_ascii_hexdigit()));
378    }
379
380    #[test]
381    fn different_value_different_key() {
382        let h = hasher();
383        let (a, _) = h.lookup_key(SecretDomain::Code, "AAAAAAAA").unwrap();
384        let (b, _) = h.lookup_key(SecretDomain::Code, "BBBBBBBB").unwrap();
385        assert_ne!(a, b);
386    }
387
388    #[test]
389    fn domain_separation_distinguishes_same_value() {
390        let h = hasher();
391        let (code, _) = h.lookup_key(SecretDomain::Code, "SAME").unwrap();
392        let (sess, _) = h.lookup_key(SecretDomain::Session, "SAME").unwrap();
393        let (form, _) = h.lookup_key(SecretDomain::FormToken, "SAME").unwrap();
394        let (flow, _) = h.lookup_key(SecretDomain::FlowTicket, "SAME").unwrap();
395        // All four must differ pairwise.
396        let all = [&code, &sess, &form, &flow];
397        for i in 0..all.len() {
398            for j in (i + 1)..all.len() {
399                assert_ne!(all[i], all[j], "domains {i},{j} collided");
400            }
401        }
402    }
403
404    #[test]
405    fn different_key_different_output() {
406        let h1 = SecretHasher::new(StaticKeyProvider::single("v1", b"key-one".to_vec()).unwrap());
407        let h2 = SecretHasher::new(StaticKeyProvider::single("v1", b"key-two".to_vec()).unwrap());
408        let (a, _) = h1.lookup_key(SecretDomain::Code, "X").unwrap();
409        let (b, _) = h2.lookup_key(SecretDomain::Code, "X").unwrap();
410        assert_ne!(a, b);
411    }
412
413    #[test]
414    fn missing_active_key_fails_closed() {
415        // A provider whose active version points at no stored key.
416        let kp = StaticKeyProvider {
417            active_version: KeyVersion::new("missing"),
418            keys: vec![(KeyVersion::new("v1"), b"k".to_vec())],
419        };
420        let h = SecretHasher::new(kp);
421        assert_eq!(
422            h.lookup_key(SecretDomain::Code, "X").unwrap_err(),
423            KeyError::MissingActiveKey
424        );
425    }
426
427    #[test]
428    fn empty_key_rejected_at_construction() {
429        assert_eq!(
430            StaticKeyProvider::single("v1", Vec::new()).unwrap_err(),
431            KeyError::InvalidKeyMaterial
432        );
433    }
434
435    #[test]
436    fn key_version_round_trip_validation() {
437        // Derive under v1, rotate active to v2, re-derive the v1 candidate.
438        let kp = StaticKeyProvider::new(
439            "v2",
440            b"key-two".to_vec(),
441            vec![(KeyVersion::new("v1"), b"key-one".to_vec())],
442        )
443        .unwrap();
444        let h = SecretHasher::new(kp);
445        let (active, av) = h.lookup_key(SecretDomain::Session, "tok").unwrap();
446        assert_eq!(av.as_str(), "v2");
447        let v1 = KeyVersion::new("v1");
448        let prev = h
449            .lookup_key_with_version(SecretDomain::Session, "tok", &v1)
450            .unwrap();
451        // v1 and v2 derivations differ; the active is v2.
452        assert_ne!(active, prev);
453        // Unknown version fails closed, not fallback.
454        let missing = KeyVersion::new("v9");
455        assert_eq!(
456            h.lookup_key_with_version(SecretDomain::Session, "tok", &missing)
457                .unwrap_err(),
458            KeyError::MissingKeyVersion
459        );
460    }
461
462    #[test]
463    fn lookup_key_ct_eq_matches_value_eq() {
464        let h = hasher();
465        let (a, _) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
466        let (b, _) = h.lookup_key(SecretDomain::Code, "ABCD2345").unwrap();
467        let (c, _) = h.lookup_key(SecretDomain::Code, "DIFFEREN").unwrap();
468        assert!(a.ct_eq(&b));
469        assert!(!a.ct_eq(&c));
470    }
471
472    #[test]
473    fn key_material_redacted_in_debug() {
474        let kp = StaticKeyProvider::single("v1", b"secret-bytes".to_vec()).unwrap();
475        let dbg = format!("{kp:?}");
476        assert!(!dbg.contains("secret-bytes"), "key bytes leaked: {dbg}");
477        assert!(dbg.contains("<redacted>"));
478        let key = kp.active_hmac_key().unwrap();
479        let kdbg = format!("{key:?}");
480        assert!(!kdbg.contains("secret-bytes"));
481    }
482}