Skip to main content

tollgate_auth/
hmac_registry.rs

1//! The concrete verifier in the box: server-issued API keys held as
2//! HMAC-SHA256 digests.
3//!
4//! The digest table is a *projection*, not the system of record. Durable
5//! credential lifecycle lives in a `KeyDirectory` (`tollgate-store`), and the
6//! control plane installs the active set here the way it installs account
7//! snapshots into a `SnapshotMap`. That split is why verification can stay
8//! I/O-free on the request path (INVARIANTS.md GL-5, GL-6) while issuance and
9//! revocation remain transactional and fleet-wide.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use arc_swap::ArcSwap;
15use hmac::{Hmac, Mac};
16use jiff::Timestamp;
17use sha2::Sha256;
18use subtle::ConstantTimeEq;
19use tollgate_core::{KeyId, Principal};
20use zeroize::Zeroizing;
21
22use crate::verifier::{CredentialVerifier, Verified};
23
24type HmacSha256 = Hmac<Sha256>;
25
26/// How many bytes of entropy a minted secret carries.
27///
28/// 32 bytes from the OS CSPRNG. The digest is HMAC-SHA256 and its own output
29/// is 32 bytes, so a longer secret would not raise the strength of what is
30/// stored, and a shorter one would be the weakest link in a chain whose other
31/// links are all 256 bits.
32const SECRET_BYTES: usize = 32;
33
34/// A freshly minted credential, returned exactly once.
35///
36/// The secret is [`Zeroizing`], so the only copy the process holds is wiped
37/// when this value drops. The digest is suitable for durable storage; debug
38/// output exposes only the non-secret identifiers.
39///
40/// **Nothing can recover the secret from the record.** That is the point of
41/// storing digests, and it is also the constraint on issuance ordering: the
42/// record must be durable *before* the secret is disclosed, because a crash
43/// between the two leaves a credential the server has never heard of and no
44/// reconciliation can repair it.
45pub struct MintedKey {
46    /// The non-secret identifier for this credential, used to revoke it.
47    pub key_id: KeyId,
48    /// The principal it authenticates as: the digest's leading 128 bits.
49    pub principal: Principal,
50    /// HMAC-SHA256 of the secret under the server secret. This is what a
51    /// `tollgate_store::KeyDirectory` stores.
52    pub digest: [u8; 32],
53    /// The credential: 64 lowercase hexadecimal characters, exactly the bytes
54    /// the digest covers. It is what is disclosed, what a customer presents
55    /// (for example after `Bearer `), and what a verifier is handed, with no
56    /// encoding step anywhere between them. Hand it to its owner and drop it;
57    /// it cannot be derived again from anything retained.
58    pub secret: Zeroizing<Vec<u8>>,
59}
60
61impl std::fmt::Debug for MintedKey {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("MintedKey")
64            .field("key_id", &self.key_id)
65            .field("principal", &self.principal)
66            .finish_non_exhaustive()
67    }
68}
69
70/// One credential as the projection holds it.
71#[derive(Clone, Copy)]
72struct ProjectedKey {
73    digest: [u8; 32],
74    /// Surfaced through [`Verified::reusable_until`], so a session cache
75    /// cannot outlive the credential it authenticated with.
76    not_after: Option<Timestamp>,
77}
78
79/// Credentials held as HMAC-SHA256 digests under a server secret.
80///
81/// **Digests at rest is the non-negotiable property.** A raw-credential map
82/// would verify in tens of nanoseconds, but every place the map lives —
83/// process memory, a core dump, a backup — would then hold usable
84/// credentials, so one read leaks every customer's key. Timing is not what
85/// rules that out (credentials are high-entropy and the map is randomly
86/// keyed); exposure is.
87///
88/// The truncated digest is the [`Principal`] admission is keyed by, and the
89/// *full* digest is what the comparison decides on, so truncation is never the
90/// deciding comparison.
91///
92/// **Why HMAC and not a plain SHA-256 digest**, which is cheaper: HMAC splits
93/// the secret from the digest table, so neither alone verifies anything. That
94/// is worth paying for at the rate a digest is actually computed — once per
95/// session under [`SessionCredential`], not once per request. Measured on one
96/// machine: HMAC-SHA256 741 ns against SHA-256 184 ns, a 4x difference that
97/// amortises to about 5.6 ns per request at a hundred requests per session,
98/// against a ~23 ns cached path neither touches. The cheaper digest buys a
99/// saving that rounds to nothing and costs a real security property.
100///
101/// **The table is arc-swapped**, not locked. Installation is an operator-rate
102/// event and verification is on the request path, so readers must never block
103/// behind a writer — the same reasoning, and the same mechanism, as
104/// `ArcSwapSnapshotMap`. A write clones the table; that cost is paid by
105/// whoever is issuing keys, not by whoever is serving requests.
106///
107/// An embedder who disagrees is not stuck with this: implement
108/// [`CredentialVerifier`] instead.
109///
110/// [`SessionCredential`]: crate::SessionCredential
111pub struct HmacRegistry {
112    secret: Zeroizing<Vec<u8>>,
113    keys: ArcSwap<HashMap<u128, ProjectedKey>>,
114}
115
116impl std::fmt::Debug for HmacRegistry {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("HmacRegistry")
119            .field("projected_keys", &self.len())
120            .finish_non_exhaustive()
121    }
122}
123
124impl HmacRegistry {
125    /// A registry keyed by `secret`, holding no credentials until one is
126    /// installed. Load the secret from the environment or a secret store;
127    /// never commit one.
128    #[must_use]
129    pub fn new(secret: &[u8]) -> Self {
130        HmacRegistry {
131            secret: Zeroizing::new(secret.to_vec()),
132            keys: ArcSwap::from_pointee(HashMap::new()),
133        }
134    }
135
136    fn digest(&self, credential: &[u8]) -> [u8; 32] {
137        let mut mac = HmacSha256::new_from_slice(&self.secret).expect("any key length works");
138        mac.update(credential);
139        mac.finalize().into_bytes().into()
140    }
141
142    /// The stable identity of a digest: its leading 128 bits.
143    fn fingerprint(digest: &[u8; 32]) -> u128 {
144        let mut bytes = [0u8; 16];
145        bytes.copy_from_slice(&digest[..16]);
146        u128::from_be_bytes(bytes)
147    }
148
149    /// Derive the durable digest and identity of an existing credential without
150    /// installing it. This supports fixture bootstrap and imports; new issuance
151    /// should use [`Self::mint`] so entropy comes from the OS. Persist the digest
152    /// before projecting it, and never persist or log the credential argument.
153    pub fn digest_credential(&self, credential: &[u8]) -> (Principal, [u8; 32]) {
154        let digest = self.digest(credential);
155        (Principal(Self::fingerprint(&digest)), digest)
156    }
157
158    /// Generate a credential and return it once, with the non-secret record
159    /// its durable half needs.
160    ///
161    /// The secret comes from the OS CSPRNG rather than from a caller, because
162    /// "generate it properly, show it once, never store it" is exactly the
163    /// part of issuance that goes wrong quietly, and it is not a thing each
164    /// embedder should re-implement. The registry does **not** install the
165    /// result: publish the record durably first, then project it.
166    ///
167    /// # Errors
168    ///
169    /// Fails only if the operating system cannot supply entropy, which is not
170    /// a condition to paper over with a weaker source.
171    ///
172    /// The credential is minted in its textual form, and the digest covers
173    /// that text. A credential disclosed as text but digested as the bytes it
174    /// encodes verifies only for a caller that decodes it first, and every
175    /// caller that forwards what it was presented — a `Bearer` header, as the
176    /// reference embedder does — would be refused with no error anywhere.
177    pub fn mint(&self, key_id: KeyId) -> Result<MintedKey, EntropyUnavailable> {
178        const DIGITS: &[u8; 16] = b"0123456789abcdef";
179        let mut entropy = Zeroizing::new([0u8; SECRET_BYTES]);
180        getrandom::fill(entropy.as_mut_slice()).map_err(|_| EntropyUnavailable)?;
181        let mut secret = Zeroizing::new(Vec::with_capacity(SECRET_BYTES * 2));
182        for byte in entropy.iter() {
183            secret.push(DIGITS[usize::from(byte >> 4)]);
184            secret.push(DIGITS[usize::from(byte & 0x0f)]);
185        }
186        let (principal, digest) = self.digest_credential(&secret);
187        Ok(MintedKey {
188            key_id,
189            principal,
190            digest,
191            secret,
192        })
193    }
194
195    /// Replace the projection with the currently active credential set.
196    ///
197    /// Whole-table replacement, never a merge: the directory decides which
198    /// credentials are live, and a registry that merged would keep verifying
199    /// a credential the directory has already retired. This is the same
200    /// contract `SnapshotMap` publication has, for the same reason.
201    pub fn install(
202        &self,
203        keys: impl IntoIterator<Item = (Principal, [u8; 32], Option<Timestamp>)>,
204    ) {
205        let projected: HashMap<u128, ProjectedKey> = keys
206            .into_iter()
207            .map(|(principal, digest, not_after)| (principal.0, ProjectedKey { digest, not_after }))
208            .collect();
209        self.keys.store(Arc::new(projected));
210    }
211
212    /// Project a set of credentials this process did not mint.
213    ///
214    /// For fixtures, and for an embedder importing a key set it already
215    /// holds. Prefer [`mint`](Self::mint) with a durable record: a credential
216    /// accepted here was generated somewhere this crate cannot vouch for, and
217    /// "shown once, never stored" then rests on the caller having done it
218    /// right — the part of issuance most worth centralising.
219    ///
220    /// Returns the principal each credential authenticates as, in the order
221    /// given.
222    pub fn install_credentials(
223        &self,
224        credentials: impl IntoIterator<Item = impl AsRef<[u8]>>,
225    ) -> Vec<Principal> {
226        let projected: Vec<_> = credentials
227            .into_iter()
228            .map(|credential| {
229                let (principal, digest) = self.digest_credential(credential.as_ref());
230                (principal, digest, None)
231            })
232            .collect();
233        let principals = projected.iter().map(|(p, _, _)| *p).collect();
234        self.install(projected);
235        principals
236    }
237
238    /// How many credentials the projection currently holds. For readiness
239    /// reporting and tests; the digests themselves are never exposed.
240    #[must_use]
241    pub fn len(&self) -> usize {
242        self.keys.load().len()
243    }
244
245    /// Whether the projection is empty, which for a serving instance means
246    /// every credential will be refused.
247    #[must_use]
248    pub fn is_empty(&self) -> bool {
249        self.len() == 0
250    }
251}
252
253/// The operating system could not supply entropy for a new credential.
254///
255/// Its own type rather than a `bool` or a swallowed default: a mint that
256/// cannot be random must fail loudly, because the alternative is issuing a
257/// guessable credential that verifies perfectly.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct EntropyUnavailable;
260
261impl std::fmt::Display for EntropyUnavailable {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        f.write_str("the operating system could not supply entropy for a new credential")
264    }
265}
266
267impl std::error::Error for EntropyUnavailable {}
268
269impl CredentialVerifier for HmacRegistry {
270    /// A credential's own expiry, when it carries one, travels back through
271    /// [`Verified::reusable_until`] so the session cache cannot outlive it.
272    /// A credential without one is indefinite: it stops being accepted when
273    /// it stops being projected, or when the account behind it is revoked —
274    /// and revocation travels by snapshot, which admission consults on every
275    /// request regardless of any cache.
276    fn verify(&self, credential: &[u8]) -> Option<Verified> {
277        let digest = self.digest(credential);
278        let principal = Self::fingerprint(&digest);
279        let keys = self.keys.load();
280        let projected = keys.get(&principal)?;
281        if projected.digest.ct_eq(&digest).into() {
282            Some(match projected.not_after {
283                Some(until) => Verified::until(Principal(principal), until),
284                None => Verified::indefinite(Principal(principal)),
285            })
286        } else {
287            None
288        }
289    }
290}
291
292impl crate::CredentialIssuer for HmacRegistry {
293    /// The inherent method, exposed through the seam. Minting is the registry's
294    /// job either way; the trait exists so a server can hold the capability
295    /// without naming this type — and so a deployment that does not issue can
296    /// hold nothing at all.
297    fn mint(&self, key_id: KeyId) -> Result<MintedKey, EntropyUnavailable> {
298        HmacRegistry::mint(self, key_id)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    #![allow(
305        clippy::disallowed_methods,
306        reason = "unit tests that build an arbitrary `now` the assertions are relative to; \
307                  no assertion here depends on what the clock actually said"
308    )]
309    #[test]
310    fn credential_diagnostics_never_disclose_issuer_or_customer_secrets() {
311        let secret = b"fixture-debug-redaction-hmac-secret-108";
312        let registry = HmacRegistry::new(secret);
313        let key = registry.mint(KeyId(1)).unwrap();
314        registry.install([(key.principal, key.digest, None)]);
315        let diagnostic = format!("{registry:?} {key:?}");
316        for protected in [
317            format!("{secret:?}"),
318            format!("{:?}", *key.secret),
319            format!("{:?}", key.digest),
320        ] {
321            assert!(!diagnostic.contains(&protected));
322        }
323        assert!(diagnostic.contains("projected_keys: 1"));
324    }
325
326    use super::*;
327
328    /// The minted credential is its presented form: 64 lowercase hex
329    /// characters, verified exactly as disclosed and not after decoding.
330    #[test]
331    fn a_minted_credential_verifies_as_the_text_it_is_disclosed_as() {
332        let registry = HmacRegistry::new(b"fixture-presented-form-secret-143");
333        let key = registry.mint(KeyId(1)).unwrap();
334        assert_eq!(key.secret.len(), 64);
335        assert!(
336            key.secret
337                .iter()
338                .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
339        );
340        registry.install([(key.principal, key.digest, None)]);
341        assert_eq!(
342            registry.verify(&key.secret).map(|v| v.principal),
343            Some(key.principal)
344        );
345        let decoded: Vec<u8> = key
346            .secret
347            .chunks(2)
348            .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
349            .collect();
350        assert!(
351            registry.verify(&decoded).is_none(),
352            "the decoded bytes are not the credential"
353        );
354    }
355
356    fn registry() -> HmacRegistry {
357        let registry = HmacRegistry::new(b"server-secret");
358        registry.install_credentials([b"key-one".as_slice()]);
359        registry
360    }
361
362    #[test]
363    fn a_registered_credential_verifies_to_a_stable_principal_with_no_expiry() {
364        let registry = registry();
365        assert_eq!(registry.verify(b"key-one"), registry.verify(b"key-one"));
366        let verified = registry.verify(b"key-one").expect("registered");
367        assert_eq!(
368            verified.reusable_until, None,
369            "a server-issued key does not expire on its own; withdrawal travels by snapshot"
370        );
371    }
372
373    #[test]
374    fn an_unregistered_or_altered_credential_does_not_verify() {
375        let registry = registry();
376        assert_eq!(registry.verify(b"key-two"), None);
377        assert_eq!(registry.verify(b"key-one "), None);
378        assert_eq!(registry.verify(b""), None);
379    }
380
381    /// The secret is half the proof: a digest table lifted without it verifies
382    /// nothing. This is the property HMAC is paid for, so it is checked rather
383    /// than assumed.
384    #[test]
385    fn the_same_credential_under_a_different_secret_is_a_different_principal() {
386        let other = HmacRegistry::new(b"a-different-secret");
387        let elsewhere = other.install_credentials([b"key-one".as_slice()])[0];
388        assert_ne!(
389            registry().verify(b"key-one").expect("registered").principal,
390            elsewhere
391        );
392    }
393
394    /// The registry stores digests, never credentials. Nothing else in the
395    /// crate would notice if that broke, and it is the whole reason for the
396    /// design, so it gets its own witness.
397    #[test]
398    fn no_credential_is_retained_in_the_registry() {
399        let registry = registry();
400        let credential: &[u8] = b"key-one";
401        for projected in registry.keys.load().values() {
402            assert!(
403                !projected
404                    .digest
405                    .windows(credential.len())
406                    .any(|w| w == credential),
407                "a stored digest contains the credential it came from"
408            );
409        }
410        assert!(
411            !registry
412                .secret
413                .windows(credential.len())
414                .any(|w| w == credential),
415            "the secret contains the credential"
416        );
417    }
418}