Skip to main content

tollgate_auth/
session.rs

1//! The session-scoped credential cache: verify once, then compare.
2
3use std::sync::Arc;
4
5use arc_swap::ArcSwapOption;
6use jiff::Timestamp;
7use subtle::ConstantTimeEq;
8use tollgate_core::Principal;
9use zeroize::Zeroize;
10
11use crate::verifier::{CredentialVerifier, Verified};
12
13/// The credential a session has already had verified, and what it verified as.
14struct VerifiedCredential {
15    /// The exact bytes that were verified. Retained so nothing else can reuse
16    /// the principal, and so the value compared is always the value verified —
17    /// caching some *other* proof alongside it is how those two drift apart.
18    ///
19    /// This is the one place in the design where a usable credential sits in
20    /// memory, which is why it is wiped on drop rather than merely freed: the
21    /// same "process memory, a core dump, a backup" threat that rules out a
22    /// raw-credential registry applies to a live session's cache, only bounded
23    /// by the number of open sessions instead of the whole customer base.
24    credential: Box<[u8]>,
25    verified: Verified,
26}
27
28impl std::fmt::Debug for VerifiedCredential {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("VerifiedCredential")
31            .field("verified", &self.verified)
32            .finish_non_exhaustive()
33    }
34}
35
36impl VerifiedCredential {
37    /// Wipe the credential in place.
38    ///
39    /// Separated from [`Drop`] so the wiping is observable while the bytes are
40    /// still ours: reading them *after* the drop would be undefined behaviour,
41    /// so there is no safe test on that side of it.
42    fn wipe(&mut self) {
43        self.credential.zeroize();
44    }
45}
46
47impl Drop for VerifiedCredential {
48    fn drop(&mut self) {
49        self.wipe();
50        #[cfg(test)]
51        tests::record_wipe(&self.credential);
52    }
53}
54
55/// Authentication state whose lifetime is exactly one session.
56///
57/// A "session" is whatever the embedder binds this to — an accepted TCP
58/// connection, a TLS session, an HTTP/2 stream. This crate deliberately does
59/// not know: binding it is transport-specific, and the mechanism is not.
60///
61/// **What this is for.** Verifying a credential costs real work — on one
62/// machine ~800 ns for an HMAC-backed scheme, against ~107 ns for an entire
63/// quota admission. Left per-request it dominates everything the rest of the
64/// stack does. Doing it once per session and comparing thereafter brings the
65/// per-request cost to ~16 ns.
66///
67/// **What it must never become.** A cached credential proves *identity*, never
68/// *authorization*. A hit skips the verifier and nothing else: the caller
69/// still runs admission against the current snapshot, so status, staleness,
70/// permissions, rate and quota are decided fresh every request. Revocation
71/// stays bounded by snapshot refresh exactly as it is without this cache.
72///
73/// A cached answer is also bounded by whatever validity the verifier attached
74/// to it ([`Verified::reusable_until`]), so an expiring scheme — PASETO, JWT,
75/// a client certificate — does not get to outlive its own expiry just because
76/// the session stayed open.
77///
78/// Clones share one slot, so concurrent requests on a multiplexed session
79/// authenticate independently without a lock.
80#[derive(Debug, Clone, Default)]
81pub struct SessionCredential {
82    verified: Arc<ArcSwapOption<VerifiedCredential>>,
83}
84
85impl SessionCredential {
86    /// An empty cache. Give each session its own.
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Resolve `credential` to a [`Principal`] as of `now`, verifying it only
93    /// when this session has not already verified exactly these bytes, or when
94    /// the previous answer is no longer reusable.
95    ///
96    /// `credential` is the credential itself, with any transport framing
97    /// already removed — the `Bearer ` prefix, the header name, the cookie
98    /// attributes. Pass the same bytes you would pass to
99    /// [`CredentialVerifier::verify`]; this compares and verifies the same
100    /// slice, so there is no second value to fall out of step with it.
101    ///
102    /// `now` is supplied by the caller rather than read here, because this
103    /// runs on the request path and the request path does not read clocks
104    /// (INVARIANTS.md GL-5).
105    ///
106    /// `None` means the session presented nothing, and clears any prior proof.
107    ///
108    /// The ordering below is load bearing: a credential that does not match
109    /// the cached one invalidates the cache **before** its replacement is
110    /// verified, so a failed verification can never leave the previous
111    /// principal reusable. Failed verification is never cached.
112    #[must_use]
113    pub fn authenticate<V: CredentialVerifier + ?Sized>(
114        &self,
115        credential: Option<&[u8]>,
116        verifier: &V,
117        now: Timestamp,
118    ) -> Option<Principal> {
119        let Some(credential) = credential else {
120            self.verified.store(None);
121            return None;
122        };
123
124        let cached = self.verified.load();
125        if let Some(cached) = cached.as_ref()
126            && bool::from(cached.credential.as_ref().ct_eq(credential))
127            && cached.verified.is_reusable_at(now)
128        {
129            return Some(cached.verified.principal);
130        }
131
132        // Reaching here means one of three things: nothing cached, different
133        // bytes, or an answer whose validity has run out. All three ask the
134        // verifier again — an expired answer is never extended on the grounds
135        // that the bytes still match, because the credential behind them may
136        // have been renewed or may now be dead.
137
138        self.verified.store(None);
139        let verified = verifier.verify(credential)?;
140        // A verifier that hands back an already-expired answer is answering
141        // "no" in a roundabout way; caching it would be caching a refusal.
142        if !verified.is_reusable_at(now) {
143            return None;
144        }
145        self.verified.store(Some(Arc::new(VerifiedCredential {
146            credential: credential.into(),
147            verified,
148        })));
149        Some(verified.principal)
150    }
151
152    /// Whether this session currently holds a verified credential. For tests
153    /// and diagnostics; the credential itself is never exposed.
154    #[must_use]
155    pub fn is_authenticated(&self) -> bool {
156        self.verified.load().is_some()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    #[test]
163    fn session_debug_never_discloses_cached_credential_bytes() {
164        let bytes = b"fixture-private-session-credential-105";
165        let cached = super::VerifiedCredential {
166            credential: bytes.to_vec().into_boxed_slice(),
167            verified: crate::Verified::indefinite(tollgate_core::Principal(7)),
168        };
169        let session = super::SessionCredential::new();
170        session.verified.store(Some(std::sync::Arc::new(cached)));
171        let rendered = format!("{session:?}");
172        assert!(rendered.contains("verified"));
173        assert!(!rendered.contains(std::str::from_utf8(bytes).unwrap()));
174        assert!(!rendered.contains(&format!("{bytes:?}")));
175    }
176
177    use super::*;
178    use crate::HmacRegistry;
179
180    fn t(secs: i64) -> Timestamp {
181        Timestamp::from_second(secs).expect("in range")
182    }
183
184    thread_local! {
185        /// What each dropped cache entry's buffer looked like *after* its
186        /// wipe, recorded from inside the drop — the last instant those bytes
187        /// are still defined to read.
188        static WIPED: std::cell::RefCell<Vec<Vec<u8>>> =
189            const { std::cell::RefCell::new(Vec::new()) };
190    }
191
192    /// Called by `VerifiedCredential::drop`. Lives here so the witness and the
193    /// thing it witnesses cannot drift into different files.
194    pub(super) fn record_wipe(credential: &[u8]) {
195        WIPED.with(|wiped| wiped.borrow_mut().push(credential.to_vec()));
196    }
197
198    fn take_wiped() -> Vec<Vec<u8>> {
199        WIPED.with(|wiped| std::mem::take(&mut *wiped.borrow_mut()))
200    }
201
202    /// A live session holds a usable credential in memory — the one place in
203    /// this design that does. `HmacRegistry`'s own doc names "process memory,
204    /// a core dump, a backup" as the threat that rules out a raw-credential
205    /// registry, and that threat applies here too, bounded by open sessions
206    /// rather than by the whole customer base. So the bytes are wiped rather
207    /// than merely freed, and this is the witness for it.
208    #[test]
209    fn a_dropped_cache_entry_is_wiped_not_merely_freed() {
210        let verifier = Counting::new();
211        let _ = take_wiped();
212        {
213            let session = SessionCredential::new();
214            session
215                .authenticate(Some(b"key-one"), &verifier, t(0))
216                .expect("verifies");
217            // Replacing the entry drops the old one.
218            session
219                .authenticate(Some(b"key-two"), &verifier, t(0))
220                .expect("verifies");
221        }
222
223        let wiped = take_wiped();
224        assert_eq!(wiped.len(), 2, "both cache entries were dropped");
225        for buffer in wiped {
226            assert!(
227                !buffer.is_empty(),
228                "the entry should still have its length, only zeroed contents"
229            );
230            assert!(
231                buffer.iter().all(|byte| *byte == 0),
232                "a dropped credential must be zeroed, got {buffer:?}"
233            );
234        }
235    }
236
237    /// Counts how often the underlying verifier is actually reached, so a test
238    /// can assert the digest was *skipped* rather than merely that the answer
239    /// was right — the difference between testing this optimisation and
240    /// testing around it.
241    ///
242    /// That this is an ordinary `CredentialVerifier` and not a hole poked in
243    /// the real one is the trait earning its keep.
244    struct Counting {
245        inner: HmacRegistry,
246        calls: std::sync::atomic::AtomicU64,
247    }
248
249    impl Counting {
250        fn new() -> Self {
251            let inner = HmacRegistry::new(b"server-secret");
252            inner.install_credentials([b"key-one".as_slice(), b"key-two".as_slice()]);
253            Counting {
254                inner,
255                calls: std::sync::atomic::AtomicU64::new(0),
256            }
257        }
258
259        fn calls(&self) -> u64 {
260            self.calls.load(std::sync::atomic::Ordering::Relaxed)
261        }
262    }
263
264    impl CredentialVerifier for Counting {
265        fn verify(&self, credential: &[u8]) -> Option<Verified> {
266            self.calls
267                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
268            self.inner.verify(credential)
269        }
270    }
271
272    #[test]
273    fn an_unchanged_credential_is_verified_once_per_session() {
274        let verifier = Counting::new();
275        let session = SessionCredential::new();
276        let first = session
277            .authenticate(Some(b"key-one"), &verifier, t(0))
278            .expect("verifies");
279        for _ in 0..16 {
280            assert_eq!(
281                session.authenticate(Some(b"key-one"), &verifier, t(0)),
282                Some(first),
283                "a repeat must resolve to the same principal"
284            );
285        }
286        assert_eq!(
287            verifier.calls(),
288            1,
289            "the credential must be verified once, not once per request"
290        );
291    }
292
293    #[test]
294    fn the_cache_is_isolated_per_session() {
295        let verifier = Counting::new();
296        let one = SessionCredential::new();
297        let two = SessionCredential::new();
298        let one_principal = one
299            .authenticate(Some(b"key-one"), &verifier, t(0))
300            .expect("verifies");
301        assert!(one.is_authenticated());
302        assert!(
303            !two.is_authenticated(),
304            "a second session must not inherit the first's proof"
305        );
306        let two_principal = two
307            .authenticate(Some(b"key-two"), &verifier, t(0))
308            .expect("verifies");
309        assert_ne!(one_principal, two_principal);
310        assert_eq!(
311            verifier.calls(),
312            2,
313            "each session verifies for itself, and only once"
314        );
315    }
316
317    /// The ordering that matters: a credential that does not match the cached
318    /// one clears the proof *before* its replacement is verified, so a failed
319    /// replacement cannot leave the previous principal reusable.
320    #[test]
321    fn a_failed_replacement_does_not_leave_the_previous_principal_usable() {
322        let verifier = Counting::new();
323        let session = SessionCredential::new();
324        session
325            .authenticate(Some(b"key-one"), &verifier, t(0))
326            .expect("verifies");
327
328        assert_eq!(
329            session.authenticate(Some(b"not-registered"), &verifier, t(0)),
330            None
331        );
332        assert!(
333            !session.is_authenticated(),
334            "a refused credential must not leave the prior proof standing"
335        );
336    }
337
338    #[test]
339    fn a_changed_credential_revalidates_as_the_new_principal() {
340        let verifier = Counting::new();
341        let session = SessionCredential::new();
342        let one = session
343            .authenticate(Some(b"key-one"), &verifier, t(0))
344            .expect("verifies");
345        let two = session
346            .authenticate(Some(b"key-two"), &verifier, t(0))
347            .expect("verifies");
348        assert_ne!(one, two, "a different credential is a different principal");
349        assert_eq!(
350            session.authenticate(Some(b"key-two"), &verifier, t(0)),
351            Some(two)
352        );
353        assert_eq!(verifier.calls(), 2, "only the change re-verified");
354    }
355
356    #[test]
357    fn presenting_nothing_clears_the_proof() {
358        let verifier = Counting::new();
359        let session = SessionCredential::new();
360        session
361            .authenticate(Some(b"key-one"), &verifier, t(0))
362            .expect("verifies");
363        assert_eq!(session.authenticate(None, &verifier, t(0)), None);
364        assert!(!session.is_authenticated());
365    }
366
367    /// A prefix or extension of the cached credential must not ride in on the
368    /// comparison. `ct_eq` is length-checked, but this is the kind of property
369    /// worth pinning rather than assuming.
370    #[test]
371    fn a_prefix_or_extension_of_the_cached_credential_is_not_accepted() {
372        let verifier = Counting::new();
373        let session = SessionCredential::new();
374        session
375            .authenticate(Some(b"key-one"), &verifier, t(0))
376            .expect("verifies");
377        assert_eq!(session.authenticate(Some(b"key-on"), &verifier, t(0)), None);
378        assert_eq!(
379            session.authenticate(Some(b"key-one-and-more"), &verifier, t(0)),
380            None
381        );
382    }
383
384    /// A verifier is pluggable, so the cache must work with one that has
385    /// nothing to do with HMAC. This one authenticates anything starting with
386    /// `tok-`, which no digest scheme would.
387    #[test]
388    fn the_cache_works_with_an_arbitrary_verifier() {
389        struct PrefixScheme;
390        impl CredentialVerifier for PrefixScheme {
391            fn verify(&self, credential: &[u8]) -> Option<Verified> {
392                credential
393                    .strip_prefix(b"tok-")
394                    .map(|rest| Verified::indefinite(Principal(rest.len() as u128)))
395            }
396        }
397        let session = SessionCredential::new();
398        assert_eq!(
399            session.authenticate(Some(b"tok-abcd"), &PrefixScheme, t(0)),
400            Some(Principal(4))
401        );
402        assert_eq!(
403            session.authenticate(Some(b"nope"), &PrefixScheme, t(0)),
404            None
405        );
406    }
407}
408
409#[cfg(test)]
410mod expiry_tests {
411    use super::*;
412    use crate::verifier::Verified;
413
414    fn t(secs: i64) -> Timestamp {
415        Timestamp::from_second(secs).expect("in range")
416    }
417
418    /// An expiring scheme — a PASETO or JWT `exp`, a certificate `notAfter` —
419    /// hands back the instant its answer stops being reusable. Every call
420    /// re-reads the token, so a renewed credential is honoured too.
421    struct Expiring {
422        expires_at: std::sync::Mutex<Timestamp>,
423        calls: std::sync::atomic::AtomicU64,
424    }
425
426    impl Expiring {
427        fn new(expires_at: Timestamp) -> Self {
428            Expiring {
429                expires_at: std::sync::Mutex::new(expires_at),
430                calls: std::sync::atomic::AtomicU64::new(0),
431            }
432        }
433
434        fn calls(&self) -> u64 {
435            self.calls.load(std::sync::atomic::Ordering::Relaxed)
436        }
437    }
438
439    impl CredentialVerifier for Expiring {
440        fn verify(&self, credential: &[u8]) -> Option<Verified> {
441            self.calls
442                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
443            let expires_at = *self.expires_at.lock().expect("not poisoned");
444            (credential == b"token").then(|| Verified::until(Principal(7), expires_at))
445        }
446    }
447
448    /// The hole a pluggable verifier opens if the cache is not time-bounded:
449    /// a short-lived token presented once on a long-lived session would be
450    /// honoured for as long as the session stayed open, and admission cannot
451    /// compensate because expiry is a property of the credential, not of the
452    /// account snapshot.
453    #[test]
454    fn a_cached_answer_does_not_outlive_the_validity_it_was_given() {
455        let verifier = Expiring::new(t(60));
456        let session = SessionCredential::new();
457
458        assert_eq!(
459            session.authenticate(Some(b"token"), &verifier, t(0)),
460            Some(Principal(7))
461        );
462        assert_eq!(
463            session.authenticate(Some(b"token"), &verifier, t(59)),
464            Some(Principal(7)),
465            "still inside its validity, so still a cache hit"
466        );
467        assert_eq!(verifier.calls(), 1, "no re-verification while valid");
468
469        // At the expiry instant exactly, the cached answer is spent.
470        *verifier.expires_at.lock().expect("not poisoned") = t(120);
471        assert_eq!(
472            session.authenticate(Some(b"token"), &verifier, t(60)),
473            Some(Principal(7)),
474            "the same bytes re-verify, and the renewed validity is honoured"
475        );
476        assert_eq!(
477            verifier.calls(),
478            2,
479            "expiry forces exactly one re-verification"
480        );
481    }
482
483    /// A token that has already expired by the time it is first presented is
484    /// refused, and — critically — not cached, so it cannot be reused.
485    #[test]
486    fn an_already_expired_answer_is_refused_and_not_cached() {
487        let verifier = Expiring::new(t(10));
488        let session = SessionCredential::new();
489        assert_eq!(session.authenticate(Some(b"token"), &verifier, t(20)), None);
490        assert!(
491            !session.is_authenticated(),
492            "a refusal must leave nothing cached"
493        );
494    }
495
496    /// An expired cached answer whose credential is now rejected outright must
497    /// not fall back on the stale principal.
498    #[test]
499    fn an_expired_answer_that_no_longer_verifies_denies() {
500        struct OnceValid(std::sync::atomic::AtomicBool);
501        impl CredentialVerifier for OnceValid {
502            fn verify(&self, _credential: &[u8]) -> Option<Verified> {
503                if self.0.swap(false, std::sync::atomic::Ordering::Relaxed) {
504                    Some(Verified::until(Principal(7), t(60)))
505                } else {
506                    None
507                }
508            }
509        }
510        let verifier = OnceValid(std::sync::atomic::AtomicBool::new(true));
511        let session = SessionCredential::new();
512        assert_eq!(
513            session.authenticate(Some(b"token"), &verifier, t(0)),
514            Some(Principal(7))
515        );
516        assert_eq!(
517            session.authenticate(Some(b"token"), &verifier, t(60)),
518            None,
519            "the credential stopped verifying, so expiry must not admit it"
520        );
521        assert!(!session.is_authenticated());
522    }
523}