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}