1use 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
13struct VerifiedCredential {
15 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 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#[derive(Debug, Clone, Default)]
81pub struct SessionCredential {
82 verified: Arc<ArcSwapOption<VerifiedCredential>>,
83}
84
85impl SessionCredential {
86 #[must_use]
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 #[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 self.verified.store(None);
139 let verified = verifier.verify(credential)?;
140 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 #[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 static WIPED: std::cell::RefCell<Vec<Vec<u8>>> =
189 const { std::cell::RefCell::new(Vec::new()) };
190 }
191
192 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 #[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 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 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 #[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 #[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 #[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 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 #[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 *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 #[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 #[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}