phantom_protocol/transport/
session_cache.rs1use crate::crypto::adaptive_crypto::CipherSuite;
19use std::collections::HashMap;
20use std::time::{Duration, Instant};
21use zeroize::ZeroizeOnDrop;
22
23const DEFAULT_MAX_TICKETS: usize = 64;
25
26const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600); pub type SessionId = [u8; 32];
31
32#[derive(Clone, ZeroizeOnDrop)]
37pub struct ResumptionTicket {
38 pub resumption_secret: [u8; 32],
44 #[zeroize(skip)]
46 pub cipher_suite: CipherSuite,
47 #[zeroize(skip)]
49 pub created_at: Instant,
50 #[zeroize(skip)]
52 pub expires_at: Instant,
53}
54
55const _: fn() = || {
59 fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
60 assert_zeroize_on_drop::<ResumptionTicket>();
61};
62
63impl ResumptionTicket {
64 pub fn new(
72 resumption_secret: &[u8; 32],
73 cipher_suite: CipherSuite,
74 lifetime: Duration,
75 ) -> Self {
76 let now = Instant::now();
77 Self {
78 resumption_secret: *resumption_secret,
79 cipher_suite,
80 created_at: now,
81 expires_at: now + lifetime,
82 }
83 }
84
85 pub fn is_valid(&self) -> bool {
87 Instant::now() < self.expires_at
88 }
89}
90
91pub struct SessionCache {
93 tickets: HashMap<SessionId, ResumptionTicket>,
94 lru_order: Vec<SessionId>,
96 max_entries: usize,
97 ticket_lifetime: Duration,
98}
99
100impl SessionCache {
101 pub fn new() -> Self {
103 Self {
104 tickets: HashMap::new(),
105 lru_order: Vec::new(),
106 max_entries: DEFAULT_MAX_TICKETS,
107 ticket_lifetime: DEFAULT_TICKET_LIFETIME,
108 }
109 }
110}
111
112impl Default for SessionCache {
113 fn default() -> Self {
114 Self::new()
115 }
116}
117
118impl SessionCache {
119 pub fn with_capacity(max_entries: usize, ticket_lifetime: Duration) -> Self {
121 Self {
122 tickets: HashMap::with_capacity(max_entries),
123 lru_order: Vec::with_capacity(max_entries),
124 max_entries,
125 ticket_lifetime,
126 }
127 }
128
129 pub fn store(
135 &mut self,
136 session_id: SessionId,
137 resumption_secret: &[u8; 32],
138 cipher_suite: CipherSuite,
139 ) {
140 if self.tickets.len() >= self.max_entries {
142 self.evict_oldest();
143 }
144
145 let ticket = ResumptionTicket::new(resumption_secret, cipher_suite, self.ticket_lifetime);
146 self.tickets.insert(session_id, ticket);
147 self.lru_order.retain(|id| id != &session_id);
148 self.lru_order.push(session_id);
149 }
150
151 pub fn try_resume(&mut self, session_id: &SessionId) -> Option<([u8; 32], CipherSuite)> {
161 let ticket = self.tickets.get(session_id)?;
162
163 if !ticket.is_valid() {
164 self.remove(session_id);
165 return None;
166 }
167
168 let secret = ticket.resumption_secret;
169 let suite = ticket.cipher_suite;
170
171 self.remove(session_id);
174
175 Some((secret, suite))
176 }
177
178 pub fn peek(
186 &mut self,
187 session_id: &SessionId,
188 ) -> Option<([u8; 32], CipherSuite, Instant, Instant)> {
189 let ticket = self.tickets.get(session_id)?;
190 if !ticket.is_valid() {
191 self.remove(session_id);
192 return None;
193 }
194 Some((
195 ticket.resumption_secret,
196 ticket.cipher_suite,
197 ticket.created_at,
198 ticket.expires_at,
199 ))
200 }
201
202 pub fn reinsert_with_expiry(
209 &mut self,
210 session_id: SessionId,
211 resumption_secret: &[u8; 32],
212 cipher_suite: CipherSuite,
213 created_at: Instant,
214 expires_at: Instant,
215 ) {
216 if Instant::now() >= expires_at {
218 return;
219 }
220 if self.tickets.len() >= self.max_entries {
221 self.evict_oldest();
222 }
223 let ticket = ResumptionTicket {
224 resumption_secret: *resumption_secret,
225 cipher_suite,
226 created_at,
227 expires_at,
228 };
229 self.tickets.insert(session_id, ticket);
230 self.lru_order.retain(|id| id != &session_id);
231 self.lru_order.push(session_id);
232 }
233
234 pub fn remove(&mut self, session_id: &SessionId) -> bool {
239 let existed = self.tickets.remove(session_id).is_some();
240 self.lru_order.retain(|id| id != session_id);
241 existed
242 }
243
244 fn evict_oldest(&mut self) {
246 let now = Instant::now();
248 let expired: Vec<SessionId> = self
249 .tickets
250 .iter()
251 .filter(|(_, t)| now >= t.expires_at)
252 .map(|(id, _)| *id)
253 .collect();
254
255 for id in &expired {
256 self.tickets.remove(id);
257 }
258 self.lru_order.retain(|id| !expired.contains(id));
259
260 if self.tickets.len() >= self.max_entries {
262 if let Some(oldest) = self.lru_order.first().copied() {
263 self.tickets.remove(&oldest);
264 self.lru_order.remove(0);
265 }
266 }
267 }
268
269 pub fn len(&self) -> usize {
271 self.tickets.len()
272 }
273
274 pub fn is_empty(&self) -> bool {
276 self.tickets.is_empty()
277 }
278
279 pub fn clear(&mut self) {
281 self.tickets.clear();
282 self.lru_order.clear();
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn store_and_resume_returns_verbatim_secret() {
292 let mut cache = SessionCache::new();
293 let session_id = [0xABu8; 32];
294 let secret = [0xCDu8; 32];
295
296 cache.store(session_id, &secret, CipherSuite::Aes256Gcm);
297 assert_eq!(cache.len(), 1);
298
299 let (returned, suite) = cache.try_resume(&session_id).expect("ticket present");
300 assert_eq!(suite, CipherSuite::Aes256Gcm);
301 assert_eq!(returned, secret);
305 }
306
307 #[test]
308 fn try_resume_is_one_shot() {
309 let mut cache = SessionCache::new();
313 let session_id = [0xABu8; 32];
314 let secret = [0xCDu8; 32];
315
316 cache.store(session_id, &secret, CipherSuite::ChaCha20Poly1305);
317 assert_eq!(cache.len(), 1);
318
319 assert!(
320 cache.try_resume(&session_id).is_some(),
321 "first resume succeeds"
322 );
323 assert_eq!(cache.len(), 0, "ticket consumed");
324 assert!(
325 cache.try_resume(&session_id).is_none(),
326 "second resume must find nothing (one-shot)"
327 );
328 }
329
330 #[test]
331 fn lru_eviction() {
332 let mut cache = SessionCache::with_capacity(2, Duration::from_secs(3600));
333
334 let id1 = [0x01u8; 32];
335 let id2 = [0x02u8; 32];
336 let id3 = [0x03u8; 32];
337 let secret = [0xABu8; 32];
338
339 cache.store(id1, &secret, CipherSuite::Aes256Gcm);
340 cache.store(id2, &secret, CipherSuite::Aes256Gcm);
341 assert_eq!(cache.len(), 2);
342
343 cache.store(id3, &secret, CipherSuite::Aes256Gcm);
345 assert_eq!(cache.len(), 2);
346 assert!(cache.try_resume(&id1).is_none(), "id1 was evicted");
347 assert!(cache.try_resume(&id2).is_some(), "id2 still present");
348 }
349
350 #[test]
351 fn expired_ticket() {
352 let mut cache = SessionCache::with_capacity(64, Duration::from_millis(1));
353 let id = [0x01u8; 32];
354 cache.store(id, &[0xAB; 32], CipherSuite::Aes256Gcm);
355
356 std::thread::sleep(Duration::from_millis(5));
358 assert!(cache.try_resume(&id).is_none());
359 }
360
361 #[test]
362 fn peek_does_not_consume_but_returns_secret_and_timestamps() {
363 let mut cache = SessionCache::new();
366 let id = [0xABu8; 32];
367 let secret = [0xCDu8; 32];
368 cache.store(id, &secret, CipherSuite::Aes256Gcm);
369
370 let (s, suite, created, expires) = cache.peek(&id).expect("ticket present");
371 assert_eq!(s, secret);
372 assert_eq!(suite, CipherSuite::Aes256Gcm);
373 assert!(expires > created);
374 assert_eq!(cache.len(), 1, "peek must not consume the ticket");
376 assert!(cache.peek(&id).is_some());
377 assert!(cache.try_resume(&id).is_some());
378 }
379
380 #[test]
381 fn reinsert_preserves_expiry_and_refuses_expired() {
382 let mut cache = SessionCache::new();
386 let id = [0x01u8; 32];
387 let secret = [0x02u8; 32];
388 cache.store(id, &secret, CipherSuite::Aes256Gcm);
389 let (s, suite, created, expires) = cache.peek(&id).expect("present");
390
391 cache.remove(&id);
393 assert_eq!(cache.len(), 0);
394 cache.reinsert_with_expiry(id, &s, suite, created, expires);
395 let (_, _, c2, e2) = cache.peek(&id).expect("re-inserted");
396 assert_eq!(
397 (c2, e2),
398 (created, expires),
399 "timestamps preserved, lifetime not extended"
400 );
401
402 let past_created = created - Duration::from_secs(7200);
404 let past_expires = created - Duration::from_secs(3600);
405 cache.remove(&id);
406 cache.reinsert_with_expiry(id, &s, suite, past_created, past_expires);
407 assert_eq!(cache.len(), 0, "expired ticket must not be resurrected");
408 }
409}