phantom_protocol/transport/
session_cache.rs1use crate::crypto::adaptive_crypto::CipherSuite;
11use std::collections::HashMap;
12use std::time::{Duration, Instant};
13
14const DEFAULT_MAX_TICKETS: usize = 64;
16
17const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600); pub type SessionId = [u8; 32];
22
23#[derive(Clone)]
28pub struct ResumptionTicket {
29 pub resumption_secret: [u8; 32],
35 pub cipher_suite: CipherSuite,
37 pub created_at: Instant,
39 pub expires_at: Instant,
41}
42
43impl ResumptionTicket {
44 pub fn new(
52 resumption_secret: &[u8; 32],
53 cipher_suite: CipherSuite,
54 lifetime: Duration,
55 ) -> Self {
56 let now = Instant::now();
57 Self {
58 resumption_secret: *resumption_secret,
59 cipher_suite,
60 created_at: now,
61 expires_at: now + lifetime,
62 }
63 }
64
65 pub fn is_valid(&self) -> bool {
67 Instant::now() < self.expires_at
68 }
69}
70
71pub struct SessionCache {
73 tickets: HashMap<SessionId, ResumptionTicket>,
74 lru_order: Vec<SessionId>,
76 max_entries: usize,
77 ticket_lifetime: Duration,
78}
79
80impl SessionCache {
81 pub fn new() -> Self {
83 Self {
84 tickets: HashMap::new(),
85 lru_order: Vec::new(),
86 max_entries: DEFAULT_MAX_TICKETS,
87 ticket_lifetime: DEFAULT_TICKET_LIFETIME,
88 }
89 }
90}
91
92impl Default for SessionCache {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98impl SessionCache {
99 pub fn with_capacity(max_entries: usize, ticket_lifetime: Duration) -> Self {
101 Self {
102 tickets: HashMap::with_capacity(max_entries),
103 lru_order: Vec::with_capacity(max_entries),
104 max_entries,
105 ticket_lifetime,
106 }
107 }
108
109 pub fn store(
115 &mut self,
116 session_id: SessionId,
117 resumption_secret: &[u8; 32],
118 cipher_suite: CipherSuite,
119 ) {
120 if self.tickets.len() >= self.max_entries {
122 self.evict_oldest();
123 }
124
125 let ticket = ResumptionTicket::new(resumption_secret, cipher_suite, self.ticket_lifetime);
126 self.tickets.insert(session_id, ticket);
127 self.lru_order.retain(|id| id != &session_id);
128 self.lru_order.push(session_id);
129 }
130
131 pub fn try_resume(&mut self, session_id: &SessionId) -> Option<([u8; 32], CipherSuite)> {
141 let ticket = self.tickets.get(session_id)?;
142
143 if !ticket.is_valid() {
144 self.remove(session_id);
145 return None;
146 }
147
148 let secret = ticket.resumption_secret;
149 let suite = ticket.cipher_suite;
150
151 self.remove(session_id);
154
155 Some((secret, suite))
156 }
157
158 pub fn peek(
166 &mut self,
167 session_id: &SessionId,
168 ) -> Option<([u8; 32], CipherSuite, Instant, Instant)> {
169 let ticket = self.tickets.get(session_id)?;
170 if !ticket.is_valid() {
171 self.remove(session_id);
172 return None;
173 }
174 Some((
175 ticket.resumption_secret,
176 ticket.cipher_suite,
177 ticket.created_at,
178 ticket.expires_at,
179 ))
180 }
181
182 pub fn reinsert_with_expiry(
189 &mut self,
190 session_id: SessionId,
191 resumption_secret: &[u8; 32],
192 cipher_suite: CipherSuite,
193 created_at: Instant,
194 expires_at: Instant,
195 ) {
196 if Instant::now() >= expires_at {
198 return;
199 }
200 if self.tickets.len() >= self.max_entries {
201 self.evict_oldest();
202 }
203 let ticket = ResumptionTicket {
204 resumption_secret: *resumption_secret,
205 cipher_suite,
206 created_at,
207 expires_at,
208 };
209 self.tickets.insert(session_id, ticket);
210 self.lru_order.retain(|id| id != &session_id);
211 self.lru_order.push(session_id);
212 }
213
214 pub fn remove(&mut self, session_id: &SessionId) -> bool {
219 let existed = self.tickets.remove(session_id).is_some();
220 self.lru_order.retain(|id| id != session_id);
221 existed
222 }
223
224 fn evict_oldest(&mut self) {
226 let now = Instant::now();
228 let expired: Vec<SessionId> = self
229 .tickets
230 .iter()
231 .filter(|(_, t)| now >= t.expires_at)
232 .map(|(id, _)| *id)
233 .collect();
234
235 for id in &expired {
236 self.tickets.remove(id);
237 }
238 self.lru_order.retain(|id| !expired.contains(id));
239
240 if self.tickets.len() >= self.max_entries {
242 if let Some(oldest) = self.lru_order.first().copied() {
243 self.tickets.remove(&oldest);
244 self.lru_order.remove(0);
245 }
246 }
247 }
248
249 pub fn len(&self) -> usize {
251 self.tickets.len()
252 }
253
254 pub fn is_empty(&self) -> bool {
256 self.tickets.is_empty()
257 }
258
259 pub fn clear(&mut self) {
261 self.tickets.clear();
262 self.lru_order.clear();
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn store_and_resume_returns_verbatim_secret() {
272 let mut cache = SessionCache::new();
273 let session_id = [0xABu8; 32];
274 let secret = [0xCDu8; 32];
275
276 cache.store(session_id, &secret, CipherSuite::Aes256Gcm);
277 assert_eq!(cache.len(), 1);
278
279 let (returned, suite) = cache.try_resume(&session_id).expect("ticket present");
280 assert_eq!(suite, CipherSuite::Aes256Gcm);
281 assert_eq!(returned, secret);
285 }
286
287 #[test]
288 fn try_resume_is_one_shot() {
289 let mut cache = SessionCache::new();
293 let session_id = [0xABu8; 32];
294 let secret = [0xCDu8; 32];
295
296 cache.store(session_id, &secret, CipherSuite::ChaCha20Poly1305);
297 assert_eq!(cache.len(), 1);
298
299 assert!(
300 cache.try_resume(&session_id).is_some(),
301 "first resume succeeds"
302 );
303 assert_eq!(cache.len(), 0, "ticket consumed");
304 assert!(
305 cache.try_resume(&session_id).is_none(),
306 "second resume must find nothing (one-shot)"
307 );
308 }
309
310 #[test]
311 fn lru_eviction() {
312 let mut cache = SessionCache::with_capacity(2, Duration::from_secs(3600));
313
314 let id1 = [0x01u8; 32];
315 let id2 = [0x02u8; 32];
316 let id3 = [0x03u8; 32];
317 let secret = [0xABu8; 32];
318
319 cache.store(id1, &secret, CipherSuite::Aes256Gcm);
320 cache.store(id2, &secret, CipherSuite::Aes256Gcm);
321 assert_eq!(cache.len(), 2);
322
323 cache.store(id3, &secret, CipherSuite::Aes256Gcm);
325 assert_eq!(cache.len(), 2);
326 assert!(cache.try_resume(&id1).is_none(), "id1 was evicted");
327 assert!(cache.try_resume(&id2).is_some(), "id2 still present");
328 }
329
330 #[test]
331 fn expired_ticket() {
332 let mut cache = SessionCache::with_capacity(64, Duration::from_millis(1));
333 let id = [0x01u8; 32];
334 cache.store(id, &[0xAB; 32], CipherSuite::Aes256Gcm);
335
336 std::thread::sleep(Duration::from_millis(5));
338 assert!(cache.try_resume(&id).is_none());
339 }
340
341 #[test]
342 fn peek_does_not_consume_but_returns_secret_and_timestamps() {
343 let mut cache = SessionCache::new();
346 let id = [0xABu8; 32];
347 let secret = [0xCDu8; 32];
348 cache.store(id, &secret, CipherSuite::Aes256Gcm);
349
350 let (s, suite, created, expires) = cache.peek(&id).expect("ticket present");
351 assert_eq!(s, secret);
352 assert_eq!(suite, CipherSuite::Aes256Gcm);
353 assert!(expires > created);
354 assert_eq!(cache.len(), 1, "peek must not consume the ticket");
356 assert!(cache.peek(&id).is_some());
357 assert!(cache.try_resume(&id).is_some());
358 }
359
360 #[test]
361 fn reinsert_preserves_expiry_and_refuses_expired() {
362 let mut cache = SessionCache::new();
366 let id = [0x01u8; 32];
367 let secret = [0x02u8; 32];
368 cache.store(id, &secret, CipherSuite::Aes256Gcm);
369 let (s, suite, created, expires) = cache.peek(&id).expect("present");
370
371 cache.remove(&id);
373 assert_eq!(cache.len(), 0);
374 cache.reinsert_with_expiry(id, &s, suite, created, expires);
375 let (_, _, c2, e2) = cache.peek(&id).expect("re-inserted");
376 assert_eq!(
377 (c2, e2),
378 (created, expires),
379 "timestamps preserved, lifetime not extended"
380 );
381
382 let past_created = created - Duration::from_secs(7200);
384 let past_expires = created - Duration::from_secs(3600);
385 cache.remove(&id);
386 cache.reinsert_with_expiry(id, &s, suite, past_created, past_expires);
387 assert_eq!(cache.len(), 0, "expired ticket must not be resurrected");
388 }
389}