Skip to main content

phantom_protocol/transport/
session_cache.rs

1//! 0-RTT Session Resumption
2//!
3//! Аналог TLS Session Tickets / QUIC 0-RTT:
4//! - Первое подключение: полный PQC handshake → сохраняем ResumptionTicket
5//! - Повторное подключение: ticket → мгновенный 0-RTT (данные в первом пакете)
6//! - Periodic rekeying через resumption_secret для forward secrecy
7//!
8//! LRU eviction для ограничения памяти на IoT.
9
10use crate::crypto::adaptive_crypto::CipherSuite;
11use std::collections::HashMap;
12use std::time::{Duration, Instant};
13use zeroize::ZeroizeOnDrop;
14
15/// Maximum tickets in cache (Constrained: 8, Standard: 64, Performance: 256)
16const DEFAULT_MAX_TICKETS: usize = 64;
17
18/// Default ticket lifetime
19const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600); // 1 hour
20
21/// Session ID type
22pub type SessionId = [u8; 32];
23
24/// Resumption ticket — stored after a successful handshake. Single-use:
25/// [`SessionCache::try_resume`] removes the ticket on the first lookup,
26/// which is the one-shot anti-replay guarantee for 0-RTT early-data
27/// (Phase 4.1).
28#[derive(Clone, ZeroizeOnDrop)]
29pub struct ResumptionTicket {
30    /// Resumption secret — stored **verbatim**, byte-identical to the
31    /// value `Session::resumption_hint()` hands the client. Both peers
32    /// feed it into `crypto::kdf::derive_early_data_keying`, so the
33    /// stored bytes MUST equal the client's hint — no extra derivation
34    /// layer here. Zeroized on drop (T5.1).
35    pub resumption_secret: [u8; 32],
36    /// Negotiated cipher suite
37    #[zeroize(skip)]
38    pub cipher_suite: CipherSuite,
39    /// When the ticket was created
40    #[zeroize(skip)]
41    pub created_at: Instant,
42    /// When the ticket expires
43    #[zeroize(skip)]
44    pub expires_at: Instant,
45}
46
47// T5.1 — `ResumptionTicket` holds a verbatim resumption secret, so it MUST zeroize
48// on drop. This compile-time assertion fails the build if the `ZeroizeOnDrop` derive
49// is ever removed (the secret would otherwise linger in freed memory).
50const _: fn() = || {
51    fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
52    assert_zeroize_on_drop::<ResumptionTicket>();
53};
54
55impl ResumptionTicket {
56    /// Create a ticket holding `resumption_secret` **verbatim**.
57    ///
58    /// The caller passes the already-HKDF-derived `resumption_secret`
59    /// (the same value the client's `Session::resumption_hint()`
60    /// exposes). No further derivation happens here — an extra
61    /// derivation layer would desync the server's stored secret from
62    /// the client's hint and break early-data key agreement.
63    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    /// Check if ticket is still valid
78    pub fn is_valid(&self) -> bool {
79        Instant::now() < self.expires_at
80    }
81}
82
83/// LRU Session Cache with eviction
84pub struct SessionCache {
85    tickets: HashMap<SessionId, ResumptionTicket>,
86    /// LRU order: most recently used at the end
87    lru_order: Vec<SessionId>,
88    max_entries: usize,
89    ticket_lifetime: Duration,
90}
91
92impl SessionCache {
93    /// Create with default settings
94    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    /// Create with custom limits (for Device Profiles)
112    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    /// Store a ticket after a successful handshake.
122    ///
123    /// `resumption_secret` must be the same value
124    /// `Session::resumption_hint()` exposes to the client — it is
125    /// stored verbatim so both peers derive the same early-data key.
126    pub fn store(
127        &mut self,
128        session_id: SessionId,
129        resumption_secret: &[u8; 32],
130        cipher_suite: CipherSuite,
131    ) {
132        // Evict if full
133        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    /// Attempt to resume a session (0-RTT). **One-shot**: a successful
144    /// lookup REMOVES the ticket, so a replayed `ClientHello` carrying
145    /// the same `resume_session_id` finds nothing and falls back to a
146    /// full 1-RTT handshake. This is the anti-replay guarantee for
147    /// 0-RTT early-data (Phase 4.1).
148    ///
149    /// Returns `(raw resumption_secret, cipher_suite)` — the verbatim
150    /// secret stored at `store` time, ready to feed into
151    /// `crypto::kdf::derive_early_data_keying`.
152    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        // One-shot consume: a replayed ClientHello must not find this
164        // ticket a second time.
165        self.remove(session_id);
166
167        Some((secret, suite))
168    }
169
170    /// Look up a still-valid ticket **without consuming it** (HS-03). Expired
171    /// tickets are removed and `None` returned. The returned
172    /// `created_at`/`expires_at` let the caller re-insert the ticket unchanged
173    /// via [`reinsert_with_expiry`](Self::reinsert_with_expiry) if a resume that
174    /// passed the binder check later fails (ZERORTT-2) — without extending the
175    /// lifetime. Actual consumption is a separate explicit [`remove`](Self::remove)
176    /// once the resume's proof-of-possession (binder) has been verified.
177    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    /// Re-insert a ticket that a resume attempt consumed but then failed to
195    /// complete (ZERORTT-2 — e.g. a corrupted KEM ciphertext aborts the
196    /// handshake after the ticket was removed). Restores the ticket with its
197    /// **original** timestamps so the lifetime is not extended, and refuses to
198    /// resurrect an already-expired ticket. Mirrors [`store`](Self::store)'s
199    /// eviction + LRU bookkeeping so `evict_oldest` stays consistent.
200    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        // Never resurrect a ticket that expired in the meantime.
209        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    /// Remove a specific ticket. Returns `true` iff a ticket was actually
227    /// present — the resume path uses this to make eager consumption race-free:
228    /// of two concurrent resumes of the same id, exactly one observes `true`
229    /// and proceeds, so the same 0-RTT early-data cannot be accepted twice.
230    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    /// Evict oldest ticket (LRU)
237    fn evict_oldest(&mut self) {
238        // First try to evict expired tickets
239        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 still full, evict LRU
253        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    /// Number of cached tickets
262    pub fn len(&self) -> usize {
263        self.tickets.len()
264    }
265
266    /// Returns `true` if no tickets are cached
267    pub fn is_empty(&self) -> bool {
268        self.tickets.is_empty()
269    }
270
271    /// Clear all tickets
272    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        // try_resume returns the secret VERBATIM — the client's
294        // `resumption_hint()` exposes the identical bytes, which is
295        // what lets both sides derive the same early-data key.
296        assert_eq!(returned, secret);
297    }
298
299    #[test]
300    fn try_resume_is_one_shot() {
301        // Anti-replay: the first try_resume consumes the ticket, the
302        // second finds nothing. A replayed ClientHello carrying the
303        // same resume_session_id therefore cannot re-use 0-RTT.
304        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        // Adding third should evict id1 (LRU)
336        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        // Wait for expiry
349        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        // HS-03: the binder check peeks the ticket WITHOUT consuming it, so a
356        // resume that fails its proof-of-possession leaves the ticket intact.
357        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        // Peek did NOT consume — still there, still peekable, still resumable.
367        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        // ZERORTT-2: a resume consumed the ticket but the handshake then failed;
375        // re-insert restores it with its ORIGINAL timestamps (no lifetime
376        // extension), and never resurrects an already-expired ticket.
377        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        // Consume (as the resume path does), then re-insert on failure.
384        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        // An already-expired ticket is not resurrected.
395        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}