tollgate_auth/verifier.rs
1//! The seam: what it means to verify a credential, independent of how.
2
3use jiff::Timestamp;
4use tollgate_core::{KeyId, Principal};
5
6use crate::hmac_registry::{EntropyUnavailable, MintedKey};
7
8/// A successful verification: who presented the credential, and how long that
9/// answer may be reused without asking again.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct Verified {
12 /// Who presented the credential: the identity it authenticates as, and
13 /// nothing about what that identity may do.
14 pub principal: Principal,
15 /// The instant from which this answer must be re-derived.
16 ///
17 /// `None` means the credential does not expire of its own accord — a
18 /// digest of a server-issued key is valid until it is withdrawn, and
19 /// withdrawal travels by snapshot rather than by clock.
20 ///
21 /// **A scheme that carries an expiry must put it here.** A PASETO or JWT
22 /// `exp`, a certificate's `notAfter`: without it, a session that
23 /// authenticated once would keep spending an expired token for as long as
24 /// it stayed connected, and admission could not compensate because expiry
25 /// is a property of the credential and not of the account snapshot.
26 pub reusable_until: Option<Timestamp>,
27}
28
29impl Verified {
30 /// A credential that does not expire on its own.
31 #[must_use]
32 pub const fn indefinite(principal: Principal) -> Self {
33 Verified {
34 principal,
35 reusable_until: None,
36 }
37 }
38
39 /// A credential whose verification stops being reusable at `until`.
40 #[must_use]
41 pub const fn until(principal: Principal, until: Timestamp) -> Self {
42 Verified {
43 principal,
44 reusable_until: Some(until),
45 }
46 }
47
48 /// Whether this answer may still be reused at `now`.
49 #[must_use]
50 pub fn is_reusable_at(&self, now: Timestamp) -> bool {
51 self.reusable_until.is_none_or(|until| now < until)
52 }
53}
54
55/// Turns a presented credential into the [`Principal`] it authenticates as.
56///
57/// This is the vocabulary boundary. Credential *schemes* differ per deployment
58/// — an API key digest, a PASETO token, a JWT signature, a client-certificate
59/// fingerprint — and this crate does not pick one. What it does own is the
60/// expensive and subtle part around them: doing this at most once per session
61/// ([`SessionCredential`]), comparing in constant time, and never letting a
62/// cached answer outlive either the credential that earned it or the validity
63/// that credential carried.
64///
65/// [`HmacRegistry`](crate::HmacRegistry) is the implementation shipped in the
66/// box, and is a reasonable default for server-issued API keys.
67///
68/// # Contract
69///
70/// - **Deterministic in the credential.** The same bytes must always yield the
71/// same principal. Time-varying *validity* is expressed through
72/// [`Verified::reusable_until`], not by returning different answers to the
73/// same input — a verifier that did the latter would disagree with its own
74/// cached result.
75/// - **Constant-time in the secret.** Compare digests or signatures with
76/// `subtle`, never `==`. A verifier that leaks by timing leaks through the
77/// cache miss just as it would without one.
78/// - **No I/O, no blocking, no locks held across it.** A miss runs on the
79/// request path, so this inherits the request path's rules (INVARIANTS.md
80/// GL-5, GL-6). A verifier needing a database belongs behind a snapshot, not
81/// here.
82/// - **Identity only.** Returning a `Principal` says who presented the
83/// credential and nothing about what they may do. Status, permissions, rate
84/// and quota are admission's decision, every request.
85///
86/// [`SessionCredential`]: crate::SessionCredential
87pub trait CredentialVerifier {
88 /// Verify `credential`, or return `None` if it authenticates as nobody.
89 ///
90 /// `credential` is the credential itself, with transport framing already
91 /// removed — no `Bearer ` prefix, no header name, no cookie attributes.
92 fn verify(&self, credential: &[u8]) -> Option<Verified>;
93}
94
95/// Minting the credentials a [`CredentialVerifier`] will later accept (GL-121).
96///
97/// Separate from verification on purpose, and not merged into it. Every
98/// deployment verifies; only one that administers accounts over HTTP needs to
99/// *mint*, and a server that never issues should not hold the capability to.
100/// Keeping them apart lets a deployment answer "this instance does not issue
101/// credentials" by simply not having an issuer, rather than by configuration
102/// that could be got wrong.
103///
104/// **The secret exists exactly once.** An implementation returns it in
105/// [`MintedKey`] and retains nothing from which it can be recovered — what
106/// persists is a digest. A caller therefore has one opportunity to deliver it,
107/// and losing it means revoking the credential and issuing another, never
108/// asking for the same secret again.
109///
110/// **The secret is the presented form.** [`MintedKey::secret`] must be the
111/// exact bytes the owner will present and the digest covers: visible ASCII
112/// text, disclosed without re-encoding. An issuer that digests one form and
113/// hands out another mints credentials no verifier accepts as presented.
114pub trait CredentialIssuer {
115 /// Mint a credential for `key_id`, chosen by the caller.
116 ///
117 /// The caller supplies the id so that a lost response is recoverable: the
118 /// same request resent is refused as a duplicate by the directory instead
119 /// of minting a second credential. An implementation must not derive the
120 /// id from the secret, or the two would share a fate.
121 fn mint(&self, key_id: KeyId) -> Result<MintedKey, EntropyUnavailable>;
122}
123
124impl<I: CredentialIssuer + ?Sized> CredentialIssuer for &I {
125 fn mint(&self, key_id: KeyId) -> Result<MintedKey, EntropyUnavailable> {
126 (**self).mint(key_id)
127 }
128}
129
130impl<V: CredentialVerifier + ?Sized> CredentialVerifier for &V {
131 fn verify(&self, credential: &[u8]) -> Option<Verified> {
132 (**self).verify(credential)
133 }
134}
135
136impl<V: CredentialVerifier + ?Sized> CredentialVerifier for std::sync::Arc<V> {
137 fn verify(&self, credential: &[u8]) -> Option<Verified> {
138 (**self).verify(credential)
139 }
140}
141
142impl<V: CredentialVerifier + ?Sized> CredentialVerifier for Box<V> {
143 fn verify(&self, credential: &[u8]) -> Option<Verified> {
144 (**self).verify(credential)
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use std::sync::Arc;
152
153 struct Fixed;
154 impl CredentialVerifier for Fixed {
155 fn verify(&self, credential: &[u8]) -> Option<Verified> {
156 (credential == b"good").then(|| Verified::indefinite(Principal(1)))
157 }
158 }
159
160 /// The blanket impls are what let an embedder hold its verifier however it
161 /// needs to — behind a reference, an `Arc` shared across tasks, or a `Box`
162 /// chosen at startup from configuration. A `dyn CredentialVerifier` is the
163 /// whole reason the seam is a trait rather than a generic parameter, so
164 /// each forwarding impl is exercised rather than assumed.
165 #[test]
166 fn every_forwarding_impl_reaches_the_verifier() {
167 let expected = Some(Verified::indefinite(Principal(1)));
168
169 assert_eq!(CredentialVerifier::verify(&&Fixed, b"good"), expected);
170 assert_eq!(Arc::new(Fixed).verify(b"good"), expected);
171 assert_eq!(Box::new(Fixed).verify(b"good"), expected);
172
173 // Behind `dyn`, which is the shape that needs them most.
174 let boxed: Box<dyn CredentialVerifier> = Box::new(Fixed);
175 assert_eq!(boxed.verify(b"good"), expected);
176 let shared: Arc<dyn CredentialVerifier> = Arc::new(Fixed);
177 assert_eq!(shared.verify(b"good"), expected);
178
179 // And a refusal forwards as a refusal, not as a swallowed `None`
180 // indistinguishable from a broken delegation.
181 assert_eq!(boxed.verify(b"bad"), None);
182 assert_eq!(shared.verify(b"bad"), None);
183 }
184
185 #[test]
186 fn an_indefinite_answer_is_reusable_at_any_instant() {
187 let verified = Verified::indefinite(Principal(1));
188 assert!(verified.is_reusable_at(Timestamp::from_second(0).unwrap()));
189 assert!(verified.is_reusable_at(Timestamp::MAX));
190 }
191
192 /// The boundary is exclusive: at the expiry instant the answer is spent,
193 /// matching how the rest of the stack treats `usable_until` (INVARIANTS GL-12).
194 #[test]
195 fn a_bounded_answer_expires_at_its_instant_not_after_it() {
196 let until = Timestamp::from_second(60).unwrap();
197 let verified = Verified::until(Principal(1), until);
198 assert!(verified.is_reusable_at(Timestamp::from_second(59).unwrap()));
199 assert!(!verified.is_reusable_at(until));
200 assert!(!verified.is_reusable_at(Timestamp::from_second(61).unwrap()));
201 }
202}