use std::sync::Arc;
use arc_swap::ArcSwapOption;
use jiff::Timestamp;
use subtle::ConstantTimeEq;
use tollgate_core::Principal;
use zeroize::Zeroize;
use crate::verifier::{CredentialVerifier, Verified};
struct VerifiedCredential {
credential: Box<[u8]>,
verified: Verified,
}
impl std::fmt::Debug for VerifiedCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VerifiedCredential")
.field("verified", &self.verified)
.finish_non_exhaustive()
}
}
impl VerifiedCredential {
fn wipe(&mut self) {
self.credential.zeroize();
}
}
impl Drop for VerifiedCredential {
fn drop(&mut self) {
self.wipe();
#[cfg(test)]
tests::record_wipe(&self.credential);
}
}
#[derive(Debug, Clone, Default)]
pub struct SessionCredential {
verified: Arc<ArcSwapOption<VerifiedCredential>>,
}
impl SessionCredential {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn authenticate<V: CredentialVerifier + ?Sized>(
&self,
credential: Option<&[u8]>,
verifier: &V,
now: Timestamp,
) -> Option<Principal> {
let Some(credential) = credential else {
self.verified.store(None);
return None;
};
let cached = self.verified.load();
if let Some(cached) = cached.as_ref()
&& bool::from(cached.credential.as_ref().ct_eq(credential))
&& cached.verified.is_reusable_at(now)
{
return Some(cached.verified.principal);
}
self.verified.store(None);
let verified = verifier.verify(credential)?;
if !verified.is_reusable_at(now) {
return None;
}
self.verified.store(Some(Arc::new(VerifiedCredential {
credential: credential.into(),
verified,
})));
Some(verified.principal)
}
#[must_use]
pub fn is_authenticated(&self) -> bool {
self.verified.load().is_some()
}
}
#[cfg(test)]
mod tests {
#[test]
fn session_debug_never_discloses_cached_credential_bytes() {
let bytes = b"fixture-private-session-credential-105";
let cached = super::VerifiedCredential {
credential: bytes.to_vec().into_boxed_slice(),
verified: crate::Verified::indefinite(tollgate_core::Principal(7)),
};
let session = super::SessionCredential::new();
session.verified.store(Some(std::sync::Arc::new(cached)));
let rendered = format!("{session:?}");
assert!(rendered.contains("verified"));
assert!(!rendered.contains(std::str::from_utf8(bytes).unwrap()));
assert!(!rendered.contains(&format!("{bytes:?}")));
}
use super::*;
use crate::HmacRegistry;
fn t(secs: i64) -> Timestamp {
Timestamp::from_second(secs).expect("in range")
}
thread_local! {
static WIPED: std::cell::RefCell<Vec<Vec<u8>>> =
const { std::cell::RefCell::new(Vec::new()) };
}
pub(super) fn record_wipe(credential: &[u8]) {
WIPED.with(|wiped| wiped.borrow_mut().push(credential.to_vec()));
}
fn take_wiped() -> Vec<Vec<u8>> {
WIPED.with(|wiped| std::mem::take(&mut *wiped.borrow_mut()))
}
#[test]
fn a_dropped_cache_entry_is_wiped_not_merely_freed() {
let verifier = Counting::new();
let _ = take_wiped();
{
let session = SessionCredential::new();
session
.authenticate(Some(b"key-one"), &verifier, t(0))
.expect("verifies");
session
.authenticate(Some(b"key-two"), &verifier, t(0))
.expect("verifies");
}
let wiped = take_wiped();
assert_eq!(wiped.len(), 2, "both cache entries were dropped");
for buffer in wiped {
assert!(
!buffer.is_empty(),
"the entry should still have its length, only zeroed contents"
);
assert!(
buffer.iter().all(|byte| *byte == 0),
"a dropped credential must be zeroed, got {buffer:?}"
);
}
}
struct Counting {
inner: HmacRegistry,
calls: std::sync::atomic::AtomicU64,
}
impl Counting {
fn new() -> Self {
let inner = HmacRegistry::new(b"server-secret");
inner.install_credentials([b"key-one".as_slice(), b"key-two".as_slice()]);
Counting {
inner,
calls: std::sync::atomic::AtomicU64::new(0),
}
}
fn calls(&self) -> u64 {
self.calls.load(std::sync::atomic::Ordering::Relaxed)
}
}
impl CredentialVerifier for Counting {
fn verify(&self, credential: &[u8]) -> Option<Verified> {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.inner.verify(credential)
}
}
#[test]
fn an_unchanged_credential_is_verified_once_per_session() {
let verifier = Counting::new();
let session = SessionCredential::new();
let first = session
.authenticate(Some(b"key-one"), &verifier, t(0))
.expect("verifies");
for _ in 0..16 {
assert_eq!(
session.authenticate(Some(b"key-one"), &verifier, t(0)),
Some(first),
"a repeat must resolve to the same principal"
);
}
assert_eq!(
verifier.calls(),
1,
"the credential must be verified once, not once per request"
);
}
#[test]
fn the_cache_is_isolated_per_session() {
let verifier = Counting::new();
let one = SessionCredential::new();
let two = SessionCredential::new();
let one_principal = one
.authenticate(Some(b"key-one"), &verifier, t(0))
.expect("verifies");
assert!(one.is_authenticated());
assert!(
!two.is_authenticated(),
"a second session must not inherit the first's proof"
);
let two_principal = two
.authenticate(Some(b"key-two"), &verifier, t(0))
.expect("verifies");
assert_ne!(one_principal, two_principal);
assert_eq!(
verifier.calls(),
2,
"each session verifies for itself, and only once"
);
}
#[test]
fn a_failed_replacement_does_not_leave_the_previous_principal_usable() {
let verifier = Counting::new();
let session = SessionCredential::new();
session
.authenticate(Some(b"key-one"), &verifier, t(0))
.expect("verifies");
assert_eq!(
session.authenticate(Some(b"not-registered"), &verifier, t(0)),
None
);
assert!(
!session.is_authenticated(),
"a refused credential must not leave the prior proof standing"
);
}
#[test]
fn a_changed_credential_revalidates_as_the_new_principal() {
let verifier = Counting::new();
let session = SessionCredential::new();
let one = session
.authenticate(Some(b"key-one"), &verifier, t(0))
.expect("verifies");
let two = session
.authenticate(Some(b"key-two"), &verifier, t(0))
.expect("verifies");
assert_ne!(one, two, "a different credential is a different principal");
assert_eq!(
session.authenticate(Some(b"key-two"), &verifier, t(0)),
Some(two)
);
assert_eq!(verifier.calls(), 2, "only the change re-verified");
}
#[test]
fn presenting_nothing_clears_the_proof() {
let verifier = Counting::new();
let session = SessionCredential::new();
session
.authenticate(Some(b"key-one"), &verifier, t(0))
.expect("verifies");
assert_eq!(session.authenticate(None, &verifier, t(0)), None);
assert!(!session.is_authenticated());
}
#[test]
fn a_prefix_or_extension_of_the_cached_credential_is_not_accepted() {
let verifier = Counting::new();
let session = SessionCredential::new();
session
.authenticate(Some(b"key-one"), &verifier, t(0))
.expect("verifies");
assert_eq!(session.authenticate(Some(b"key-on"), &verifier, t(0)), None);
assert_eq!(
session.authenticate(Some(b"key-one-and-more"), &verifier, t(0)),
None
);
}
#[test]
fn the_cache_works_with_an_arbitrary_verifier() {
struct PrefixScheme;
impl CredentialVerifier for PrefixScheme {
fn verify(&self, credential: &[u8]) -> Option<Verified> {
credential
.strip_prefix(b"tok-")
.map(|rest| Verified::indefinite(Principal(rest.len() as u128)))
}
}
let session = SessionCredential::new();
assert_eq!(
session.authenticate(Some(b"tok-abcd"), &PrefixScheme, t(0)),
Some(Principal(4))
);
assert_eq!(
session.authenticate(Some(b"nope"), &PrefixScheme, t(0)),
None
);
}
}
#[cfg(test)]
mod expiry_tests {
use super::*;
use crate::verifier::Verified;
fn t(secs: i64) -> Timestamp {
Timestamp::from_second(secs).expect("in range")
}
struct Expiring {
expires_at: std::sync::Mutex<Timestamp>,
calls: std::sync::atomic::AtomicU64,
}
impl Expiring {
fn new(expires_at: Timestamp) -> Self {
Expiring {
expires_at: std::sync::Mutex::new(expires_at),
calls: std::sync::atomic::AtomicU64::new(0),
}
}
fn calls(&self) -> u64 {
self.calls.load(std::sync::atomic::Ordering::Relaxed)
}
}
impl CredentialVerifier for Expiring {
fn verify(&self, credential: &[u8]) -> Option<Verified> {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let expires_at = *self.expires_at.lock().expect("not poisoned");
(credential == b"token").then(|| Verified::until(Principal(7), expires_at))
}
}
#[test]
fn a_cached_answer_does_not_outlive_the_validity_it_was_given() {
let verifier = Expiring::new(t(60));
let session = SessionCredential::new();
assert_eq!(
session.authenticate(Some(b"token"), &verifier, t(0)),
Some(Principal(7))
);
assert_eq!(
session.authenticate(Some(b"token"), &verifier, t(59)),
Some(Principal(7)),
"still inside its validity, so still a cache hit"
);
assert_eq!(verifier.calls(), 1, "no re-verification while valid");
*verifier.expires_at.lock().expect("not poisoned") = t(120);
assert_eq!(
session.authenticate(Some(b"token"), &verifier, t(60)),
Some(Principal(7)),
"the same bytes re-verify, and the renewed validity is honoured"
);
assert_eq!(
verifier.calls(),
2,
"expiry forces exactly one re-verification"
);
}
#[test]
fn an_already_expired_answer_is_refused_and_not_cached() {
let verifier = Expiring::new(t(10));
let session = SessionCredential::new();
assert_eq!(session.authenticate(Some(b"token"), &verifier, t(20)), None);
assert!(
!session.is_authenticated(),
"a refusal must leave nothing cached"
);
}
#[test]
fn an_expired_answer_that_no_longer_verifies_denies() {
struct OnceValid(std::sync::atomic::AtomicBool);
impl CredentialVerifier for OnceValid {
fn verify(&self, _credential: &[u8]) -> Option<Verified> {
if self.0.swap(false, std::sync::atomic::Ordering::Relaxed) {
Some(Verified::until(Principal(7), t(60)))
} else {
None
}
}
}
let verifier = OnceValid(std::sync::atomic::AtomicBool::new(true));
let session = SessionCredential::new();
assert_eq!(
session.authenticate(Some(b"token"), &verifier, t(0)),
Some(Principal(7))
);
assert_eq!(
session.authenticate(Some(b"token"), &verifier, t(60)),
None,
"the credential stopped verifying, so expiry must not admit it"
);
assert!(!session.is_authenticated());
}
}