//! Bearers verified recently enough to be granted again without a store //! lookup. //! //! Only grants are held. A denial is never remembered, so a credential that //! starts verifying is accepted on its next request; a credential that stops //! verifying (re-minted, or the agent destroyed) is still accepted until its //! entry expires, at most the TTL after its last successful lookup. //! //! Entries are keyed by the SHA-256 of the bearer, so the process never holds //! a presented secret past the request that carried it. use std::collections::HashMap; use std::time::{Duration, Instant}; use sha2::{Digest, Sha256}; type Key = [u8; 32]; fn key(bearer: &str) -> Key { Sha256::digest(bearer.as_bytes()).into() } /// Granted bearers, each until its expiry, at most `cap` of them. pub struct Verdicts { ttl: Duration, cap: usize, expiries: HashMap, } impl Verdicts { /// A TTL of zero holds nothing, so every request reaches the store. pub fn new(ttl: Duration, cap: usize) -> Self { Self { ttl, cap, expiries: HashMap::new(), } } /// Whether `bearer` was granted less than the TTL before `now`. pub fn granted(&self, bearer: &str, now: Instant) -> bool { self.expiries .get(&key(bearer)) .is_some_and(|expiry| now < *expiry) } /// Records that `bearer` verified at `now`. When full, the entry closest /// to expiry goes, which with one TTL for every entry is the oldest. pub fn grant(&mut self, bearer: &str, now: Instant) { if self.ttl.is_zero() { return; } let key = key(bearer); if !self.expiries.contains_key(&key) && self.expiries.len() >= self.cap && let Some(oldest) = self .expiries .iter() .min_by_key(|(_, expiry)| **expiry) .map(|(k, _)| *k) { self.expiries.remove(&oldest); } self.expiries.insert(key, now + self.ttl); } #[cfg(test)] fn len(&self) -> usize { self.expiries.len() } } #[cfg(test)] mod tests { use super::*; const TTL: Duration = Duration::from_mins(1); #[test] fn a_grant_holds_until_the_ttl() { let t0 = Instant::now(); let mut v = Verdicts::new(TTL, 8); assert!(!v.granted("swarm-agent.atlas.s", t0)); v.grant("swarm-agent.atlas.s", t0); assert!(v.granted("swarm-agent.atlas.s", t0)); assert!(v.granted("swarm-agent.atlas.s", t0 + Duration::from_millis(59_999))); assert!(!v.granted("swarm-agent.atlas.s", t0 + TTL)); } #[test] fn a_grant_covers_only_the_exact_bearer() { let t0 = Instant::now(); let mut v = Verdicts::new(TTL, 8); v.grant("swarm-agent.atlas.s", t0); assert!(!v.granted("swarm-agent.atlas.t", t0)); assert!(!v.granted("swarm-agent.argus.s", t0)); } #[test] fn a_zero_ttl_holds_nothing() { let t0 = Instant::now(); let mut v = Verdicts::new(Duration::ZERO, 8); v.grant("swarm-agent.atlas.s", t0); assert!(!v.granted("swarm-agent.atlas.s", t0)); assert_eq!(v.len(), 0); } #[test] fn a_full_cache_evicts_the_oldest_grant() { let t0 = Instant::now(); let mut v = Verdicts::new(TTL, 3); for (i, bearer) in ["a", "b", "c", "d"].into_iter().enumerate() { v.grant(bearer, t0 + Duration::from_secs(i as u64)); } let now = t0 + Duration::from_secs(4); assert_eq!(v.len(), 3); assert!(!v.granted("a", now)); assert!(v.granted("b", now) && v.granted("c", now) && v.granted("d", now)); } #[test] fn regranting_a_held_bearer_in_a_full_cache_evicts_nothing() { let t0 = Instant::now(); let mut v = Verdicts::new(TTL, 2); v.grant("a", t0); v.grant("b", t0); v.grant("a", t0 + Duration::from_secs(1)); assert!(v.granted("a", t0 + Duration::from_secs(1))); assert!(v.granted("b", t0 + Duration::from_secs(1))); } }