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};
13
14/// Maximum tickets in cache (Constrained: 8, Standard: 64, Performance: 256)
15const DEFAULT_MAX_TICKETS: usize = 64;
16
17/// Default ticket lifetime
18const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600); // 1 hour
19
20/// Session ID type
21pub type SessionId = [u8; 32];
22
23/// Resumption ticket — stored after a successful handshake. Single-use:
24/// [`SessionCache::try_resume`] removes the ticket on the first lookup,
25/// which is the one-shot anti-replay guarantee for 0-RTT early-data
26/// (Phase 4.1).
27#[derive(Clone)]
28pub struct ResumptionTicket {
29    /// Resumption secret — stored **verbatim**, byte-identical to the
30    /// value `Session::resumption_hint()` hands the client. Both peers
31    /// feed it into `crypto::kdf::derive_early_data_keying`, so the
32    /// stored bytes MUST equal the client's hint — no extra derivation
33    /// layer here.
34    pub resumption_secret: [u8; 32],
35    /// Negotiated cipher suite
36    pub cipher_suite: CipherSuite,
37    /// When the ticket was created
38    pub created_at: Instant,
39    /// When the ticket expires
40    pub expires_at: Instant,
41}
42
43impl ResumptionTicket {
44    /// Create a ticket holding `resumption_secret` **verbatim**.
45    ///
46    /// The caller passes the already-HKDF-derived `resumption_secret`
47    /// (the same value the client's `Session::resumption_hint()`
48    /// exposes). No further derivation happens here — an extra
49    /// derivation layer would desync the server's stored secret from
50    /// the client's hint and break early-data key agreement.
51    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    /// Check if ticket is still valid
66    pub fn is_valid(&self) -> bool {
67        Instant::now() < self.expires_at
68    }
69}
70
71/// LRU Session Cache with eviction
72pub struct SessionCache {
73    tickets: HashMap<SessionId, ResumptionTicket>,
74    /// LRU order: most recently used at the end
75    lru_order: Vec<SessionId>,
76    max_entries: usize,
77    ticket_lifetime: Duration,
78}
79
80impl SessionCache {
81    /// Create with default settings
82    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    /// Create with custom limits (for Device Profiles)
100    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    /// Store a ticket after a successful handshake.
110    ///
111    /// `resumption_secret` must be the same value
112    /// `Session::resumption_hint()` exposes to the client — it is
113    /// stored verbatim so both peers derive the same early-data key.
114    pub fn store(
115        &mut self,
116        session_id: SessionId,
117        resumption_secret: &[u8; 32],
118        cipher_suite: CipherSuite,
119    ) {
120        // Evict if full
121        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    /// Attempt to resume a session (0-RTT). **One-shot**: a successful
132    /// lookup REMOVES the ticket, so a replayed `ClientHello` carrying
133    /// the same `resume_session_id` finds nothing and falls back to a
134    /// full 1-RTT handshake. This is the anti-replay guarantee for
135    /// 0-RTT early-data (Phase 4.1).
136    ///
137    /// Returns `(raw resumption_secret, cipher_suite)` — the verbatim
138    /// secret stored at `store` time, ready to feed into
139    /// `crypto::kdf::derive_early_data_keying`.
140    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        // One-shot consume: a replayed ClientHello must not find this
152        // ticket a second time.
153        self.remove(session_id);
154
155        Some((secret, suite))
156    }
157
158    /// Look up a still-valid ticket **without consuming it** (HS-03). Expired
159    /// tickets are removed and `None` returned. The returned
160    /// `created_at`/`expires_at` let the caller re-insert the ticket unchanged
161    /// via [`reinsert_with_expiry`](Self::reinsert_with_expiry) if a resume that
162    /// passed the binder check later fails (ZERORTT-2) — without extending the
163    /// lifetime. Actual consumption is a separate explicit [`remove`](Self::remove)
164    /// once the resume's proof-of-possession (binder) has been verified.
165    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    /// Re-insert a ticket that a resume attempt consumed but then failed to
183    /// complete (ZERORTT-2 — e.g. a corrupted KEM ciphertext aborts the
184    /// handshake after the ticket was removed). Restores the ticket with its
185    /// **original** timestamps so the lifetime is not extended, and refuses to
186    /// resurrect an already-expired ticket. Mirrors [`store`](Self::store)'s
187    /// eviction + LRU bookkeeping so `evict_oldest` stays consistent.
188    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        // Never resurrect a ticket that expired in the meantime.
197        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    /// Remove a specific ticket. Returns `true` iff a ticket was actually
215    /// present — the resume path uses this to make eager consumption race-free:
216    /// of two concurrent resumes of the same id, exactly one observes `true`
217    /// and proceeds, so the same 0-RTT early-data cannot be accepted twice.
218    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    /// Evict oldest ticket (LRU)
225    fn evict_oldest(&mut self) {
226        // First try to evict expired tickets
227        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 still full, evict LRU
241        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    /// Number of cached tickets
250    pub fn len(&self) -> usize {
251        self.tickets.len()
252    }
253
254    /// Returns `true` if no tickets are cached
255    pub fn is_empty(&self) -> bool {
256        self.tickets.is_empty()
257    }
258
259    /// Clear all tickets
260    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        // try_resume returns the secret VERBATIM — the client's
282        // `resumption_hint()` exposes the identical bytes, which is
283        // what lets both sides derive the same early-data key.
284        assert_eq!(returned, secret);
285    }
286
287    #[test]
288    fn try_resume_is_one_shot() {
289        // Anti-replay: the first try_resume consumes the ticket, the
290        // second finds nothing. A replayed ClientHello carrying the
291        // same resume_session_id therefore cannot re-use 0-RTT.
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::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        // Adding third should evict id1 (LRU)
324        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        // Wait for expiry
337        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        // HS-03: the binder check peeks the ticket WITHOUT consuming it, so a
344        // resume that fails its proof-of-possession leaves the ticket intact.
345        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        // Peek did NOT consume — still there, still peekable, still resumable.
355        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        // ZERORTT-2: a resume consumed the ticket but the handshake then failed;
363        // re-insert restores it with its ORIGINAL timestamps (no lifetime
364        // extension), and never resurrects an already-expired ticket.
365        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        // Consume (as the resume path does), then re-insert on failure.
372        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        // An already-expired ticket is not resurrected.
383        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}