Skip to main content

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}