Skip to main content

phantom_protocol/transport/
session_cache.rs

1//! 0-RTT Session Resumption
2//!
3//! Analogous to TLS session tickets / QUIC 0-RTT:
4//! - First connection: a full PQC handshake establishes the session and stores a
5//!   `ResumptionTicket` (server-side) keyed by `session_id`.
6//! - Later connection: the client presents the ticket's session id + binder, and
7//!   the server can accept 0-RTT early-data folded into the first `ClientHello`.
8//!
9//! Each ticket is **single-use** ([`SessionCache::try_resume`] / [`SessionCache::remove`]
10//! consume it), the anti-replay guarantee for 0-RTT early-data. Forward secrecy of
11//! the post-handshake session always comes from the fresh hybrid KEM run on every
12//! connect — never from the long-lived `resumption_secret`, which only seeds the
13//! best-effort early-data key.
14//!
15//! A bounded LRU cache (default 64 entries, 1-hour lifetime) caps memory — important
16//! for constrained / IoT servers.
17
18use crate::crypto::adaptive_crypto::CipherSuite;
19use std::collections::HashMap;
20use std::time::{Duration, Instant};
21use zeroize::ZeroizeOnDrop;
22
23/// Maximum tickets in cache (Constrained: 8, Standard: 64, Performance: 256)
24const DEFAULT_MAX_TICKETS: usize = 64;
25
26/// Default ticket lifetime
27const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600); // 1 hour
28
29/// Session ID type
30pub type SessionId = [u8; 32];
31
32/// Resumption ticket — stored after a successful handshake. Single-use:
33/// [`SessionCache::try_resume`] removes the ticket on the first lookup,
34/// which is the one-shot anti-replay guarantee for 0-RTT early-data
35/// (Phase 4.1).
36#[derive(Clone, ZeroizeOnDrop)]
37pub struct ResumptionTicket {
38    /// Resumption secret — stored **verbatim**, byte-identical to the
39    /// value `Session::resumption_hint()` hands the client. Both peers
40    /// feed it into `crypto::kdf::derive_early_data_keying`, so the
41    /// stored bytes MUST equal the client's hint — no extra derivation
42    /// layer here. Zeroized on drop (T5.1).
43    pub resumption_secret: [u8; 32],
44    /// Negotiated cipher suite
45    #[zeroize(skip)]
46    pub cipher_suite: CipherSuite,
47    /// When the ticket was created
48    #[zeroize(skip)]
49    pub created_at: Instant,
50    /// When the ticket expires
51    #[zeroize(skip)]
52    pub expires_at: Instant,
53}
54
55// T5.1 — `ResumptionTicket` holds a verbatim resumption secret, so it MUST zeroize
56// on drop. This compile-time assertion fails the build if the `ZeroizeOnDrop` derive
57// is ever removed (the secret would otherwise linger in freed memory).
58const _: fn() = || {
59    fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
60    assert_zeroize_on_drop::<ResumptionTicket>();
61};
62
63impl ResumptionTicket {
64    /// Create a ticket holding `resumption_secret` **verbatim**.
65    ///
66    /// The caller passes the already-HKDF-derived `resumption_secret`
67    /// (the same value the client's `Session::resumption_hint()`
68    /// exposes). No further derivation happens here — an extra
69    /// derivation layer would desync the server's stored secret from
70    /// the client's hint and break early-data key agreement.
71    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    /// Check if ticket is still valid
86    pub fn is_valid(&self) -> bool {
87        Instant::now() < self.expires_at
88    }
89}
90
91/// LRU Session Cache with eviction
92pub struct SessionCache {
93    tickets: HashMap<SessionId, ResumptionTicket>,
94    /// LRU order: most recently used at the end
95    lru_order: Vec<SessionId>,
96    max_entries: usize,
97    ticket_lifetime: Duration,
98}
99
100impl SessionCache {
101    /// Create with default settings
102    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    /// Create with custom limits (for Device Profiles)
120    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    /// Store a ticket after a successful handshake.
130    ///
131    /// `resumption_secret` must be the same value
132    /// `Session::resumption_hint()` exposes to the client — it is
133    /// stored verbatim so both peers derive the same early-data key.
134    pub fn store(
135        &mut self,
136        session_id: SessionId,
137        resumption_secret: &[u8; 32],
138        cipher_suite: CipherSuite,
139    ) {
140        // Evict if full
141        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    /// Attempt to resume a session (0-RTT). **One-shot**: a successful
152    /// lookup REMOVES the ticket, so a replayed `ClientHello` carrying
153    /// the same `resume_session_id` finds nothing and falls back to a
154    /// full 1-RTT handshake. This is the anti-replay guarantee for
155    /// 0-RTT early-data (Phase 4.1).
156    ///
157    /// Returns `(raw resumption_secret, cipher_suite)` — the verbatim
158    /// secret stored at `store` time, ready to feed into
159    /// `crypto::kdf::derive_early_data_keying`.
160    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        // One-shot consume: a replayed ClientHello must not find this
172        // ticket a second time.
173        self.remove(session_id);
174
175        Some((secret, suite))
176    }
177
178    /// Look up a still-valid ticket **without consuming it** (HS-03). Expired
179    /// tickets are removed and `None` returned. The returned
180    /// `created_at`/`expires_at` let the caller re-insert the ticket unchanged
181    /// via [`reinsert_with_expiry`](Self::reinsert_with_expiry) if a resume that
182    /// passed the binder check later fails (ZERORTT-2) — without extending the
183    /// lifetime. Actual consumption is a separate explicit [`remove`](Self::remove)
184    /// once the resume's proof-of-possession (binder) has been verified.
185    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    /// Re-insert a ticket that a resume attempt consumed but then failed to
203    /// complete (ZERORTT-2 — e.g. a corrupted KEM ciphertext aborts the
204    /// handshake after the ticket was removed). Restores the ticket with its
205    /// **original** timestamps so the lifetime is not extended, and refuses to
206    /// resurrect an already-expired ticket. Mirrors [`store`](Self::store)'s
207    /// eviction + LRU bookkeeping so `evict_oldest` stays consistent.
208    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        // Never resurrect a ticket that expired in the meantime.
217        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    /// Remove a specific ticket. Returns `true` iff a ticket was actually
235    /// present — the resume path uses this to make eager consumption race-free:
236    /// of two concurrent resumes of the same id, exactly one observes `true`
237    /// and proceeds, so the same 0-RTT early-data cannot be accepted twice.
238    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    /// Evict oldest ticket (LRU)
245    fn evict_oldest(&mut self) {
246        // First try to evict expired tickets
247        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 still full, evict LRU
261        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    /// Number of cached tickets
270    pub fn len(&self) -> usize {
271        self.tickets.len()
272    }
273
274    /// Returns `true` if no tickets are cached
275    pub fn is_empty(&self) -> bool {
276        self.tickets.is_empty()
277    }
278
279    /// Clear all tickets
280    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        // try_resume returns the secret VERBATIM — the client's
302        // `resumption_hint()` exposes the identical bytes, which is
303        // what lets both sides derive the same early-data key.
304        assert_eq!(returned, secret);
305    }
306
307    #[test]
308    fn try_resume_is_one_shot() {
309        // Anti-replay: the first try_resume consumes the ticket, the
310        // second finds nothing. A replayed ClientHello carrying the
311        // same resume_session_id therefore cannot re-use 0-RTT.
312        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        // Adding third should evict id1 (LRU)
344        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        // Wait for expiry
357        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        // HS-03: the binder check peeks the ticket WITHOUT consuming it, so a
364        // resume that fails its proof-of-possession leaves the ticket intact.
365        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        // Peek did NOT consume — still there, still peekable, still resumable.
375        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        // ZERORTT-2: a resume consumed the ticket but the handshake then failed;
383        // re-insert restores it with its ORIGINAL timestamps (no lifetime
384        // extension), and never resurrects an already-expired ticket.
385        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        // Consume (as the resume path does), then re-insert on failure.
392        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        // An already-expired ticket is not resurrected.
403        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}