phantom_protocol/transport/
session_cache.rs1use crate::crypto::adaptive_crypto::CipherSuite;
11use std::collections::HashMap;
12use std::time::{Duration, Instant};
13use zeroize::ZeroizeOnDrop;
14
15const DEFAULT_MAX_TICKETS: usize = 64;
17
18const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600); pub type SessionId = [u8; 32];
23
24#[derive(Clone, ZeroizeOnDrop)]
29pub struct ResumptionTicket {
30 pub resumption_secret: [u8; 32],
36 #[zeroize(skip)]
38 pub cipher_suite: CipherSuite,
39 #[zeroize(skip)]
41 pub created_at: Instant,
42 #[zeroize(skip)]
44 pub expires_at: Instant,
45}
46
47const _: fn() = || {
51 fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
52 assert_zeroize_on_drop::<ResumptionTicket>();
53};
54
55impl ResumptionTicket {
56 pub fn new(
64 resumption_secret: &[u8; 32],
65 cipher_suite: CipherSuite,
66 lifetime: Duration,
67 ) -> Self {
68 let now = Instant::now();
69 Self {
70 resumption_secret: *resumption_secret,
71 cipher_suite,
72 created_at: now,
73 expires_at: now + lifetime,
74 }
75 }
76
77 pub fn is_valid(&self) -> bool {
79 Instant::now() < self.expires_at
80 }
81}
82
83pub struct SessionCache {
85 tickets: HashMap<SessionId, ResumptionTicket>,
86 lru_order: Vec<SessionId>,
88 max_entries: usize,
89 ticket_lifetime: Duration,
90}
91
92impl SessionCache {
93 pub fn new() -> Self {
95 Self {
96 tickets: HashMap::new(),
97 lru_order: Vec::new(),
98 max_entries: DEFAULT_MAX_TICKETS,
99 ticket_lifetime: DEFAULT_TICKET_LIFETIME,
100 }
101 }
102}
103
104impl Default for SessionCache {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl SessionCache {
111 pub fn with_capacity(max_entries: usize, ticket_lifetime: Duration) -> Self {
113 Self {
114 tickets: HashMap::with_capacity(max_entries),
115 lru_order: Vec::with_capacity(max_entries),
116 max_entries,
117 ticket_lifetime,
118 }
119 }
120
121 pub fn store(
127 &mut self,
128 session_id: SessionId,
129 resumption_secret: &[u8; 32],
130 cipher_suite: CipherSuite,
131 ) {
132 if self.tickets.len() >= self.max_entries {
134 self.evict_oldest();
135 }
136
137 let ticket = ResumptionTicket::new(resumption_secret, cipher_suite, self.ticket_lifetime);
138 self.tickets.insert(session_id, ticket);
139 self.lru_order.retain(|id| id != &session_id);
140 self.lru_order.push(session_id);
141 }
142
143 pub fn try_resume(&mut self, session_id: &SessionId) -> Option<([u8; 32], CipherSuite)> {
153 let ticket = self.tickets.get(session_id)?;
154
155 if !ticket.is_valid() {
156 self.remove(session_id);
157 return None;
158 }
159
160 let secret = ticket.resumption_secret;
161 let suite = ticket.cipher_suite;
162
163 self.remove(session_id);
166
167 Some((secret, suite))
168 }
169
170 pub fn peek(
178 &mut self,
179 session_id: &SessionId,
180 ) -> Option<([u8; 32], CipherSuite, Instant, Instant)> {
181 let ticket = self.tickets.get(session_id)?;
182 if !ticket.is_valid() {
183 self.remove(session_id);
184 return None;
185 }
186 Some((
187 ticket.resumption_secret,
188 ticket.cipher_suite,
189 ticket.created_at,
190 ticket.expires_at,
191 ))
192 }
193
194 pub fn reinsert_with_expiry(
201 &mut self,
202 session_id: SessionId,
203 resumption_secret: &[u8; 32],
204 cipher_suite: CipherSuite,
205 created_at: Instant,
206 expires_at: Instant,
207 ) {
208 if Instant::now() >= expires_at {
210 return;
211 }
212 if self.tickets.len() >= self.max_entries {
213 self.evict_oldest();
214 }
215 let ticket = ResumptionTicket {
216 resumption_secret: *resumption_secret,
217 cipher_suite,
218 created_at,
219 expires_at,
220 };
221 self.tickets.insert(session_id, ticket);
222 self.lru_order.retain(|id| id != &session_id);
223 self.lru_order.push(session_id);
224 }
225
226 pub fn remove(&mut self, session_id: &SessionId) -> bool {
231 let existed = self.tickets.remove(session_id).is_some();
232 self.lru_order.retain(|id| id != session_id);
233 existed
234 }
235
236 fn evict_oldest(&mut self) {
238 let now = Instant::now();
240 let expired: Vec<SessionId> = self
241 .tickets
242 .iter()
243 .filter(|(_, t)| now >= t.expires_at)
244 .map(|(id, _)| *id)
245 .collect();
246
247 for id in &expired {
248 self.tickets.remove(id);
249 }
250 self.lru_order.retain(|id| !expired.contains(id));
251
252 if self.tickets.len() >= self.max_entries {
254 if let Some(oldest) = self.lru_order.first().copied() {
255 self.tickets.remove(&oldest);
256 self.lru_order.remove(0);
257 }
258 }
259 }
260
261 pub fn len(&self) -> usize {
263 self.tickets.len()
264 }
265
266 pub fn is_empty(&self) -> bool {
268 self.tickets.is_empty()
269 }
270
271 pub fn clear(&mut self) {
273 self.tickets.clear();
274 self.lru_order.clear();
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn store_and_resume_returns_verbatim_secret() {
284 let mut cache = SessionCache::new();
285 let session_id = [0xABu8; 32];
286 let secret = [0xCDu8; 32];
287
288 cache.store(session_id, &secret, CipherSuite::Aes256Gcm);
289 assert_eq!(cache.len(), 1);
290
291 let (returned, suite) = cache.try_resume(&session_id).expect("ticket present");
292 assert_eq!(suite, CipherSuite::Aes256Gcm);
293 assert_eq!(returned, secret);
297 }
298
299 #[test]
300 fn try_resume_is_one_shot() {
301 let mut cache = SessionCache::new();
305 let session_id = [0xABu8; 32];
306 let secret = [0xCDu8; 32];
307
308 cache.store(session_id, &secret, CipherSuite::ChaCha20Poly1305);
309 assert_eq!(cache.len(), 1);
310
311 assert!(
312 cache.try_resume(&session_id).is_some(),
313 "first resume succeeds"
314 );
315 assert_eq!(cache.len(), 0, "ticket consumed");
316 assert!(
317 cache.try_resume(&session_id).is_none(),
318 "second resume must find nothing (one-shot)"
319 );
320 }
321
322 #[test]
323 fn lru_eviction() {
324 let mut cache = SessionCache::with_capacity(2, Duration::from_secs(3600));
325
326 let id1 = [0x01u8; 32];
327 let id2 = [0x02u8; 32];
328 let id3 = [0x03u8; 32];
329 let secret = [0xABu8; 32];
330
331 cache.store(id1, &secret, CipherSuite::Aes256Gcm);
332 cache.store(id2, &secret, CipherSuite::Aes256Gcm);
333 assert_eq!(cache.len(), 2);
334
335 cache.store(id3, &secret, CipherSuite::Aes256Gcm);
337 assert_eq!(cache.len(), 2);
338 assert!(cache.try_resume(&id1).is_none(), "id1 was evicted");
339 assert!(cache.try_resume(&id2).is_some(), "id2 still present");
340 }
341
342 #[test]
343 fn expired_ticket() {
344 let mut cache = SessionCache::with_capacity(64, Duration::from_millis(1));
345 let id = [0x01u8; 32];
346 cache.store(id, &[0xAB; 32], CipherSuite::Aes256Gcm);
347
348 std::thread::sleep(Duration::from_millis(5));
350 assert!(cache.try_resume(&id).is_none());
351 }
352
353 #[test]
354 fn peek_does_not_consume_but_returns_secret_and_timestamps() {
355 let mut cache = SessionCache::new();
358 let id = [0xABu8; 32];
359 let secret = [0xCDu8; 32];
360 cache.store(id, &secret, CipherSuite::Aes256Gcm);
361
362 let (s, suite, created, expires) = cache.peek(&id).expect("ticket present");
363 assert_eq!(s, secret);
364 assert_eq!(suite, CipherSuite::Aes256Gcm);
365 assert!(expires > created);
366 assert_eq!(cache.len(), 1, "peek must not consume the ticket");
368 assert!(cache.peek(&id).is_some());
369 assert!(cache.try_resume(&id).is_some());
370 }
371
372 #[test]
373 fn reinsert_preserves_expiry_and_refuses_expired() {
374 let mut cache = SessionCache::new();
378 let id = [0x01u8; 32];
379 let secret = [0x02u8; 32];
380 cache.store(id, &secret, CipherSuite::Aes256Gcm);
381 let (s, suite, created, expires) = cache.peek(&id).expect("present");
382
383 cache.remove(&id);
385 assert_eq!(cache.len(), 0);
386 cache.reinsert_with_expiry(id, &s, suite, created, expires);
387 let (_, _, c2, e2) = cache.peek(&id).expect("re-inserted");
388 assert_eq!(
389 (c2, e2),
390 (created, expires),
391 "timestamps preserved, lifetime not extended"
392 );
393
394 let past_created = created - Duration::from_secs(7200);
396 let past_expires = created - Duration::from_secs(3600);
397 cache.remove(&id);
398 cache.reinsert_with_expiry(id, &s, suite, past_created, past_expires);
399 assert_eq!(cache.len(), 0, "expired ticket must not be resurrected");
400 }
401}