use crate::crypto::adaptive_crypto::CipherSuite;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use zeroize::ZeroizeOnDrop;
const DEFAULT_MAX_TICKETS: usize = 64;
const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600);
pub type SessionId = [u8; 32];
#[derive(Clone, ZeroizeOnDrop)]
pub struct ResumptionTicket {
pub resumption_secret: [u8; 32],
#[zeroize(skip)]
pub cipher_suite: CipherSuite,
#[zeroize(skip)]
pub created_at: Instant,
#[zeroize(skip)]
pub expires_at: Instant,
}
const _: fn() = || {
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<ResumptionTicket>();
};
impl ResumptionTicket {
pub fn new(
resumption_secret: &[u8; 32],
cipher_suite: CipherSuite,
lifetime: Duration,
) -> Self {
let now = Instant::now();
Self {
resumption_secret: *resumption_secret,
cipher_suite,
created_at: now,
expires_at: now + lifetime,
}
}
pub fn is_valid(&self) -> bool {
Instant::now() < self.expires_at
}
}
pub struct SessionCache {
tickets: HashMap<SessionId, ResumptionTicket>,
lru_order: Vec<SessionId>,
max_entries: usize,
ticket_lifetime: Duration,
}
impl SessionCache {
pub fn new() -> Self {
Self {
tickets: HashMap::new(),
lru_order: Vec::new(),
max_entries: DEFAULT_MAX_TICKETS,
ticket_lifetime: DEFAULT_TICKET_LIFETIME,
}
}
}
impl Default for SessionCache {
fn default() -> Self {
Self::new()
}
}
impl SessionCache {
pub fn with_capacity(max_entries: usize, ticket_lifetime: Duration) -> Self {
Self {
tickets: HashMap::with_capacity(max_entries),
lru_order: Vec::with_capacity(max_entries),
max_entries,
ticket_lifetime,
}
}
pub fn store(
&mut self,
session_id: SessionId,
resumption_secret: &[u8; 32],
cipher_suite: CipherSuite,
) {
if self.tickets.len() >= self.max_entries {
self.evict_oldest();
}
let ticket = ResumptionTicket::new(resumption_secret, cipher_suite, self.ticket_lifetime);
self.tickets.insert(session_id, ticket);
self.lru_order.retain(|id| id != &session_id);
self.lru_order.push(session_id);
}
pub fn try_resume(&mut self, session_id: &SessionId) -> Option<([u8; 32], CipherSuite)> {
let ticket = self.tickets.get(session_id)?;
if !ticket.is_valid() {
self.remove(session_id);
return None;
}
let secret = ticket.resumption_secret;
let suite = ticket.cipher_suite;
self.remove(session_id);
Some((secret, suite))
}
pub fn peek(
&mut self,
session_id: &SessionId,
) -> Option<([u8; 32], CipherSuite, Instant, Instant)> {
let ticket = self.tickets.get(session_id)?;
if !ticket.is_valid() {
self.remove(session_id);
return None;
}
Some((
ticket.resumption_secret,
ticket.cipher_suite,
ticket.created_at,
ticket.expires_at,
))
}
pub fn reinsert_with_expiry(
&mut self,
session_id: SessionId,
resumption_secret: &[u8; 32],
cipher_suite: CipherSuite,
created_at: Instant,
expires_at: Instant,
) {
if Instant::now() >= expires_at {
return;
}
if self.tickets.len() >= self.max_entries {
self.evict_oldest();
}
let ticket = ResumptionTicket {
resumption_secret: *resumption_secret,
cipher_suite,
created_at,
expires_at,
};
self.tickets.insert(session_id, ticket);
self.lru_order.retain(|id| id != &session_id);
self.lru_order.push(session_id);
}
pub fn remove(&mut self, session_id: &SessionId) -> bool {
let existed = self.tickets.remove(session_id).is_some();
self.lru_order.retain(|id| id != session_id);
existed
}
fn evict_oldest(&mut self) {
let now = Instant::now();
let expired: Vec<SessionId> = self
.tickets
.iter()
.filter(|(_, t)| now >= t.expires_at)
.map(|(id, _)| *id)
.collect();
for id in &expired {
self.tickets.remove(id);
}
self.lru_order.retain(|id| !expired.contains(id));
if self.tickets.len() >= self.max_entries {
if let Some(oldest) = self.lru_order.first().copied() {
self.tickets.remove(&oldest);
self.lru_order.remove(0);
}
}
}
pub fn len(&self) -> usize {
self.tickets.len()
}
pub fn is_empty(&self) -> bool {
self.tickets.is_empty()
}
pub fn clear(&mut self) {
self.tickets.clear();
self.lru_order.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_and_resume_returns_verbatim_secret() {
let mut cache = SessionCache::new();
let session_id = [0xABu8; 32];
let secret = [0xCDu8; 32];
cache.store(session_id, &secret, CipherSuite::Aes256Gcm);
assert_eq!(cache.len(), 1);
let (returned, suite) = cache.try_resume(&session_id).expect("ticket present");
assert_eq!(suite, CipherSuite::Aes256Gcm);
assert_eq!(returned, secret);
}
#[test]
fn try_resume_is_one_shot() {
let mut cache = SessionCache::new();
let session_id = [0xABu8; 32];
let secret = [0xCDu8; 32];
cache.store(session_id, &secret, CipherSuite::ChaCha20Poly1305);
assert_eq!(cache.len(), 1);
assert!(
cache.try_resume(&session_id).is_some(),
"first resume succeeds"
);
assert_eq!(cache.len(), 0, "ticket consumed");
assert!(
cache.try_resume(&session_id).is_none(),
"second resume must find nothing (one-shot)"
);
}
#[test]
fn lru_eviction() {
let mut cache = SessionCache::with_capacity(2, Duration::from_secs(3600));
let id1 = [0x01u8; 32];
let id2 = [0x02u8; 32];
let id3 = [0x03u8; 32];
let secret = [0xABu8; 32];
cache.store(id1, &secret, CipherSuite::Aes256Gcm);
cache.store(id2, &secret, CipherSuite::Aes256Gcm);
assert_eq!(cache.len(), 2);
cache.store(id3, &secret, CipherSuite::Aes256Gcm);
assert_eq!(cache.len(), 2);
assert!(cache.try_resume(&id1).is_none(), "id1 was evicted");
assert!(cache.try_resume(&id2).is_some(), "id2 still present");
}
#[test]
fn expired_ticket() {
let mut cache = SessionCache::with_capacity(64, Duration::from_millis(1));
let id = [0x01u8; 32];
cache.store(id, &[0xAB; 32], CipherSuite::Aes256Gcm);
std::thread::sleep(Duration::from_millis(5));
assert!(cache.try_resume(&id).is_none());
}
#[test]
fn peek_does_not_consume_but_returns_secret_and_timestamps() {
let mut cache = SessionCache::new();
let id = [0xABu8; 32];
let secret = [0xCDu8; 32];
cache.store(id, &secret, CipherSuite::Aes256Gcm);
let (s, suite, created, expires) = cache.peek(&id).expect("ticket present");
assert_eq!(s, secret);
assert_eq!(suite, CipherSuite::Aes256Gcm);
assert!(expires > created);
assert_eq!(cache.len(), 1, "peek must not consume the ticket");
assert!(cache.peek(&id).is_some());
assert!(cache.try_resume(&id).is_some());
}
#[test]
fn reinsert_preserves_expiry_and_refuses_expired() {
let mut cache = SessionCache::new();
let id = [0x01u8; 32];
let secret = [0x02u8; 32];
cache.store(id, &secret, CipherSuite::Aes256Gcm);
let (s, suite, created, expires) = cache.peek(&id).expect("present");
cache.remove(&id);
assert_eq!(cache.len(), 0);
cache.reinsert_with_expiry(id, &s, suite, created, expires);
let (_, _, c2, e2) = cache.peek(&id).expect("re-inserted");
assert_eq!(
(c2, e2),
(created, expires),
"timestamps preserved, lifetime not extended"
);
let past_created = created - Duration::from_secs(7200);
let past_expires = created - Duration::from_secs(3600);
cache.remove(&id);
cache.reinsert_with_expiry(id, &s, suite, past_created, past_expires);
assert_eq!(cache.len(), 0, "expired ticket must not be resurrected");
}
}