1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
//! 0-RTT Session Resumption
//!
//! Аналог TLS Session Tickets / QUIC 0-RTT:
//! - Первое подключение: полный PQC handshake → сохраняем ResumptionTicket
//! - Повторное подключение: ticket → мгновенный 0-RTT (данные в первом пакете)
//! - Periodic rekeying через resumption_secret для forward secrecy
//!
//! LRU eviction для ограничения памяти на IoT.
use crate::crypto::adaptive_crypto::CipherSuite;
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// Maximum tickets in cache (Constrained: 8, Standard: 64, Performance: 256)
const DEFAULT_MAX_TICKETS: usize = 64;
/// Default ticket lifetime
const DEFAULT_TICKET_LIFETIME: Duration = Duration::from_secs(3600); // 1 hour
/// Session ID type
pub type SessionId = [u8; 32];
/// Resumption ticket — stored after a successful handshake. Single-use:
/// [`SessionCache::try_resume`] removes the ticket on the first lookup,
/// which is the one-shot anti-replay guarantee for 0-RTT early-data
/// (Phase 4.1).
#[derive(Clone)]
pub struct ResumptionTicket {
/// Resumption secret — stored **verbatim**, byte-identical to the
/// value `Session::resumption_hint()` hands the client. Both peers
/// feed it into `crypto::kdf::derive_early_data_keying`, so the
/// stored bytes MUST equal the client's hint — no extra derivation
/// layer here.
pub resumption_secret: [u8; 32],
/// Negotiated cipher suite
pub cipher_suite: CipherSuite,
/// When the ticket was created
pub created_at: Instant,
/// When the ticket expires
pub expires_at: Instant,
}
impl ResumptionTicket {
/// Create a ticket holding `resumption_secret` **verbatim**.
///
/// The caller passes the already-HKDF-derived `resumption_secret`
/// (the same value the client's `Session::resumption_hint()`
/// exposes). No further derivation happens here — an extra
/// derivation layer would desync the server's stored secret from
/// the client's hint and break early-data key agreement.
pub fn new(
resumption_secret: &[u8; 32],
cipher_suite: CipherSuite,
lifetime: Duration,
) -> Self {
let now = Instant::now();
Self {
resumption_secret: *resumption_secret,
cipher_suite,
created_at: now,
expires_at: now + lifetime,
}
}
/// Check if ticket is still valid
pub fn is_valid(&self) -> bool {
Instant::now() < self.expires_at
}
}
/// LRU Session Cache with eviction
pub struct SessionCache {
tickets: HashMap<SessionId, ResumptionTicket>,
/// LRU order: most recently used at the end
lru_order: Vec<SessionId>,
max_entries: usize,
ticket_lifetime: Duration,
}
impl SessionCache {
/// Create with default settings
pub fn new() -> Self {
Self {
tickets: HashMap::new(),
lru_order: Vec::new(),
max_entries: DEFAULT_MAX_TICKETS,
ticket_lifetime: DEFAULT_TICKET_LIFETIME,
}
}
}
impl Default for SessionCache {
fn default() -> Self {
Self::new()
}
}
impl SessionCache {
/// Create with custom limits (for Device Profiles)
pub fn with_capacity(max_entries: usize, ticket_lifetime: Duration) -> Self {
Self {
tickets: HashMap::with_capacity(max_entries),
lru_order: Vec::with_capacity(max_entries),
max_entries,
ticket_lifetime,
}
}
/// Store a ticket after a successful handshake.
///
/// `resumption_secret` must be the same value
/// `Session::resumption_hint()` exposes to the client — it is
/// stored verbatim so both peers derive the same early-data key.
pub fn store(
&mut self,
session_id: SessionId,
resumption_secret: &[u8; 32],
cipher_suite: CipherSuite,
) {
// Evict if full
if self.tickets.len() >= self.max_entries {
self.evict_oldest();
}
let ticket = ResumptionTicket::new(resumption_secret, cipher_suite, self.ticket_lifetime);
self.tickets.insert(session_id, ticket);
self.lru_order.retain(|id| id != &session_id);
self.lru_order.push(session_id);
}
/// Attempt to resume a session (0-RTT). **One-shot**: a successful
/// lookup REMOVES the ticket, so a replayed `ClientHello` carrying
/// the same `resume_session_id` finds nothing and falls back to a
/// full 1-RTT handshake. This is the anti-replay guarantee for
/// 0-RTT early-data (Phase 4.1).
///
/// Returns `(raw resumption_secret, cipher_suite)` — the verbatim
/// secret stored at `store` time, ready to feed into
/// `crypto::kdf::derive_early_data_keying`.
pub fn try_resume(&mut self, session_id: &SessionId) -> Option<([u8; 32], CipherSuite)> {
let ticket = self.tickets.get(session_id)?;
if !ticket.is_valid() {
self.remove(session_id);
return None;
}
let secret = ticket.resumption_secret;
let suite = ticket.cipher_suite;
// One-shot consume: a replayed ClientHello must not find this
// ticket a second time.
self.remove(session_id);
Some((secret, suite))
}
/// Look up a still-valid ticket **without consuming it** (HS-03). Expired
/// tickets are removed and `None` returned. The returned
/// `created_at`/`expires_at` let the caller re-insert the ticket unchanged
/// via [`reinsert_with_expiry`](Self::reinsert_with_expiry) if a resume that
/// passed the binder check later fails (ZERORTT-2) — without extending the
/// lifetime. Actual consumption is a separate explicit [`remove`](Self::remove)
/// once the resume's proof-of-possession (binder) has been verified.
pub fn peek(
&mut self,
session_id: &SessionId,
) -> Option<([u8; 32], CipherSuite, Instant, Instant)> {
let ticket = self.tickets.get(session_id)?;
if !ticket.is_valid() {
self.remove(session_id);
return None;
}
Some((
ticket.resumption_secret,
ticket.cipher_suite,
ticket.created_at,
ticket.expires_at,
))
}
/// Re-insert a ticket that a resume attempt consumed but then failed to
/// complete (ZERORTT-2 — e.g. a corrupted KEM ciphertext aborts the
/// handshake after the ticket was removed). Restores the ticket with its
/// **original** timestamps so the lifetime is not extended, and refuses to
/// resurrect an already-expired ticket. Mirrors [`store`](Self::store)'s
/// eviction + LRU bookkeeping so `evict_oldest` stays consistent.
pub fn reinsert_with_expiry(
&mut self,
session_id: SessionId,
resumption_secret: &[u8; 32],
cipher_suite: CipherSuite,
created_at: Instant,
expires_at: Instant,
) {
// Never resurrect a ticket that expired in the meantime.
if Instant::now() >= expires_at {
return;
}
if self.tickets.len() >= self.max_entries {
self.evict_oldest();
}
let ticket = ResumptionTicket {
resumption_secret: *resumption_secret,
cipher_suite,
created_at,
expires_at,
};
self.tickets.insert(session_id, ticket);
self.lru_order.retain(|id| id != &session_id);
self.lru_order.push(session_id);
}
/// Remove a specific ticket. Returns `true` iff a ticket was actually
/// present — the resume path uses this to make eager consumption race-free:
/// of two concurrent resumes of the same id, exactly one observes `true`
/// and proceeds, so the same 0-RTT early-data cannot be accepted twice.
pub fn remove(&mut self, session_id: &SessionId) -> bool {
let existed = self.tickets.remove(session_id).is_some();
self.lru_order.retain(|id| id != session_id);
existed
}
/// Evict oldest ticket (LRU)
fn evict_oldest(&mut self) {
// First try to evict expired tickets
let now = Instant::now();
let expired: Vec<SessionId> = self
.tickets
.iter()
.filter(|(_, t)| now >= t.expires_at)
.map(|(id, _)| *id)
.collect();
for id in &expired {
self.tickets.remove(id);
}
self.lru_order.retain(|id| !expired.contains(id));
// If still full, evict LRU
if self.tickets.len() >= self.max_entries {
if let Some(oldest) = self.lru_order.first().copied() {
self.tickets.remove(&oldest);
self.lru_order.remove(0);
}
}
}
/// Number of cached tickets
pub fn len(&self) -> usize {
self.tickets.len()
}
/// Returns `true` if no tickets are cached
pub fn is_empty(&self) -> bool {
self.tickets.is_empty()
}
/// Clear all tickets
pub fn clear(&mut self) {
self.tickets.clear();
self.lru_order.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_and_resume_returns_verbatim_secret() {
let mut cache = SessionCache::new();
let session_id = [0xABu8; 32];
let secret = [0xCDu8; 32];
cache.store(session_id, &secret, CipherSuite::Aes256Gcm);
assert_eq!(cache.len(), 1);
let (returned, suite) = cache.try_resume(&session_id).expect("ticket present");
assert_eq!(suite, CipherSuite::Aes256Gcm);
// try_resume returns the secret VERBATIM — the client's
// `resumption_hint()` exposes the identical bytes, which is
// what lets both sides derive the same early-data key.
assert_eq!(returned, secret);
}
#[test]
fn try_resume_is_one_shot() {
// Anti-replay: the first try_resume consumes the ticket, the
// second finds nothing. A replayed ClientHello carrying the
// same resume_session_id therefore cannot re-use 0-RTT.
let mut cache = SessionCache::new();
let session_id = [0xABu8; 32];
let secret = [0xCDu8; 32];
cache.store(session_id, &secret, CipherSuite::ChaCha20Poly1305);
assert_eq!(cache.len(), 1);
assert!(
cache.try_resume(&session_id).is_some(),
"first resume succeeds"
);
assert_eq!(cache.len(), 0, "ticket consumed");
assert!(
cache.try_resume(&session_id).is_none(),
"second resume must find nothing (one-shot)"
);
}
#[test]
fn lru_eviction() {
let mut cache = SessionCache::with_capacity(2, Duration::from_secs(3600));
let id1 = [0x01u8; 32];
let id2 = [0x02u8; 32];
let id3 = [0x03u8; 32];
let secret = [0xABu8; 32];
cache.store(id1, &secret, CipherSuite::Aes256Gcm);
cache.store(id2, &secret, CipherSuite::Aes256Gcm);
assert_eq!(cache.len(), 2);
// Adding third should evict id1 (LRU)
cache.store(id3, &secret, CipherSuite::Aes256Gcm);
assert_eq!(cache.len(), 2);
assert!(cache.try_resume(&id1).is_none(), "id1 was evicted");
assert!(cache.try_resume(&id2).is_some(), "id2 still present");
}
#[test]
fn expired_ticket() {
let mut cache = SessionCache::with_capacity(64, Duration::from_millis(1));
let id = [0x01u8; 32];
cache.store(id, &[0xAB; 32], CipherSuite::Aes256Gcm);
// Wait for expiry
std::thread::sleep(Duration::from_millis(5));
assert!(cache.try_resume(&id).is_none());
}
#[test]
fn peek_does_not_consume_but_returns_secret_and_timestamps() {
// HS-03: the binder check peeks the ticket WITHOUT consuming it, so a
// resume that fails its proof-of-possession leaves the ticket intact.
let mut cache = SessionCache::new();
let id = [0xABu8; 32];
let secret = [0xCDu8; 32];
cache.store(id, &secret, CipherSuite::Aes256Gcm);
let (s, suite, created, expires) = cache.peek(&id).expect("ticket present");
assert_eq!(s, secret);
assert_eq!(suite, CipherSuite::Aes256Gcm);
assert!(expires > created);
// Peek did NOT consume — still there, still peekable, still resumable.
assert_eq!(cache.len(), 1, "peek must not consume the ticket");
assert!(cache.peek(&id).is_some());
assert!(cache.try_resume(&id).is_some());
}
#[test]
fn reinsert_preserves_expiry_and_refuses_expired() {
// ZERORTT-2: a resume consumed the ticket but the handshake then failed;
// re-insert restores it with its ORIGINAL timestamps (no lifetime
// extension), and never resurrects an already-expired ticket.
let mut cache = SessionCache::new();
let id = [0x01u8; 32];
let secret = [0x02u8; 32];
cache.store(id, &secret, CipherSuite::Aes256Gcm);
let (s, suite, created, expires) = cache.peek(&id).expect("present");
// Consume (as the resume path does), then re-insert on failure.
cache.remove(&id);
assert_eq!(cache.len(), 0);
cache.reinsert_with_expiry(id, &s, suite, created, expires);
let (_, _, c2, e2) = cache.peek(&id).expect("re-inserted");
assert_eq!(
(c2, e2),
(created, expires),
"timestamps preserved, lifetime not extended"
);
// An already-expired ticket is not resurrected.
let past_created = created - Duration::from_secs(7200);
let past_expires = created - Duration::from_secs(3600);
cache.remove(&id);
cache.reinsert_with_expiry(id, &s, suite, past_created, past_expires);
assert_eq!(cache.len(), 0, "expired ticket must not be resurrected");
}
}