phantom_protocol/transport/handshake.rs
1//! Unified Phantom Protocol Handshake
2//!
3//! The post-quantum, mutually-authenticated key-establishment state machine.
4//! [`HandshakeClient`] and [`HandshakeServer`] exchange a `ClientHello` /
5//! `ServerHello` pair that runs a hybrid KEM (X25519 + ML-KEM-768; ECDH-P-256 +
6//! ML-KEM-768 under `--features fips`) and a hybrid signature (Ed25519 +
7//! ML-DSA-65) over a single SHA-256 transcript. The signed transcript binds the
8//! whole `ClientHello` plus the build-side [`PROTOCOL_VARIANT`], giving the
9//! downgrade / cross-mode resistance of Invariants 7 and 10.
10//!
11//! On top of the core exchange the module layers: a stateless cookie + adaptive
12//! proof-of-work DoS gate; per-IP reputation-driven difficulty (DOS-2); 0-RTT
13//! session resumption with a proof-of-possession binder (HS-03) and one-shot
14//! anti-replay (Invariant 9); and optional AEAD-sealed early-data folded into
15//! the single `ClientHello`. [`HandshakeStage`] tracks the staged progression
16//! (`Initial -> ClassicalReady -> Established | Failed`).
17
18use borsh::{BorshDeserialize, BorshSerialize};
19use hmac::{Hmac, KeyInit, Mac};
20use parking_lot::RwLock;
21use sha2::{Digest, Sha256};
22use std::net::IpAddr;
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24use std::time::{SystemTime, UNIX_EPOCH};
25use subtle::ConstantTimeEq;
26use zeroize::ZeroizeOnDrop;
27
28use crate::crypto::adaptive_crypto::{CipherSuite, CryptoSession};
29use crate::crypto::hybrid_kem::{HybridCiphertext, HybridKeyPackage, HybridSecretKey};
30use crate::crypto::hybrid_sign::{HybridSignature, HybridSigningKey, HybridVerifyingKey};
31use crate::crypto::kdf::derive_early_data_keying;
32use crate::crypto::pow::{PoWChallenge, PoWSolution};
33use crate::errors::CoreError;
34use crate::transport::reputation::ReputationTracker;
35use crate::transport::session::{CryptoState, Session};
36use crate::transport::session_cache::SessionCache;
37use crate::transport::types::{SchedulerMode, SessionId};
38use std::sync::Arc;
39
40/// Maximum 0-RTT early-data plaintext, in bytes.
41/// The client constructor rejects a larger payload; the server drops
42/// an oversized blob and continues as a normal 1-RTT handshake. Caps
43/// the work an unauthenticated peer can force before the handshake
44/// completes.
45pub const EARLY_DATA_MAX_LEN: usize = 16 * 1024;
46
47/// Compile-time protocol-variant tag, baked into every `ClientHello`
48/// (cleartext field) **and** the signed handshake transcript. Peers
49/// reject mismatched variants up front with
50/// [`HandshakeError::ProtocolVariantMismatch`]; even an attacker who
51/// rewrites the cleartext field cannot escape detection because the
52/// transcript signature is computed over the build's own variant.
53///
54/// The `--features fips` build advertises `phantom-fips-1` so a
55/// fips client and a non-fips server (or vice versa) fail loudly at
56/// handshake time rather than producing a silently-wrong shared
57/// secret across mismatched primitive sets.
58#[cfg(not(feature = "fips"))]
59pub const PROTOCOL_VARIANT: &[u8] = b"phantom-default-1";
60#[cfg(feature = "fips")]
61pub const PROTOCOL_VARIANT: &[u8] = b"phantom-fips-1";
62
63/// The sole protocol version carried in `ClientHello.version` and bound into the
64/// handshake transcript. Pinned to one value — the protocol is not negotiated
65/// (pre-1.0, no users). It is a tamper-check anchor and a hook for a future,
66/// deliberate version increment.
67pub const PROTOCOL_VERSION: u8 = 3;
68
69/// Marker leading a [`ServerReject`] body. Reply *kind* dispatch is by the
70/// explicit [`ServerReply`] discriminant byte (`from_wire`), not by this marker
71/// — but the marker is a defense-in-depth integrity check inside the reject
72/// payload, so a malformed or confusable blob can never be silently mistaken for
73/// a genuine reject (see [`ServerReject::has_marker`]).
74pub const SERVER_REJECT_MARKER: [u8; 4] = *b"PRJ1";
75
76/// [`ServerReject::code`]: the client's `ClientHello.version` is one this server
77/// does not speak. `supported_version` carries the version it *does* speak.
78pub const REJECT_UNSUPPORTED_VERSION: u8 = 1;
79
80/// Typed handshake rejection the server returns *instead of* silently dropping
81/// the connection when it structurally cannot satisfy a `ClientHello` — today,
82/// an unknown `version`. It gives a forward/backward-incompatible peer an
83/// actionable signal (the version the server speaks) rather than a bare
84/// connection reset.
85///
86/// **Downgrade safety.** The client surfaces a reject as a hard error and does
87/// **not** auto-retry at the advertised version. Auto-downgrading on an
88/// attacker-injected reject would defeat the transcript-bound downgrade
89/// resistance of Invariant 7 (the version is signed into the transcript). The
90/// reject is diagnostic only; protocol selection stays pinned.
91#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq, Eq)]
92pub struct ServerReject {
93 /// Always [`SERVER_REJECT_MARKER`]; an in-body integrity tag the client
94 /// validates via [`ServerReject::has_marker`].
95 pub marker: [u8; 4],
96 /// Reject reason — see [`REJECT_UNSUPPORTED_VERSION`].
97 pub code: u8,
98 /// The `PROTOCOL_VERSION` this server speaks.
99 pub supported_version: u8,
100}
101
102impl ServerReject {
103 /// Build the unsupported-version reject advertising this build's
104 /// [`PROTOCOL_VERSION`].
105 pub fn unsupported_version() -> Self {
106 Self {
107 marker: SERVER_REJECT_MARKER,
108 code: REJECT_UNSUPPORTED_VERSION,
109 supported_version: PROTOCOL_VERSION,
110 }
111 }
112
113 /// True iff the body carries the reject marker — a guard against treating a
114 /// malformed or confusable blob (one that nonetheless decoded structurally)
115 /// as a genuine reject.
116 pub fn has_marker(&self) -> bool {
117 self.marker == SERVER_REJECT_MARKER
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum HandshakeStage {
123 /// Initial state, no messages exchanged
124 Initial,
125 /// Classical DH established, data can flow (Optimistic Start)
126 ClassicalReady,
127 /// Hybrid (PQC) established, session fully secure
128 Established,
129 /// Handshake failed
130 Failed,
131}
132
133/// Client hello message (initiates handshake).
134///
135/// Carries the client's hybrid key material, the pinned [`PROTOCOL_VERSION`]
136/// (transcript-bound), the DoS-gate fields (cookie / PoW), an optional
137/// resumption id, the build-side [`PROTOCOL_VARIANT`] tag, and an optional
138/// AEAD-sealed 0-RTT `early_data` blob.
139#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
140pub struct ClientHello {
141 /// hybrid public key for key exchange
142 pub client_key_package: HybridKeyPackage,
143 /// hybrid verifying key for signatures
144 pub client_verify_key: HybridVerifyingKey,
145 /// Random nonce (32 bytes) for replay protection
146 pub nonce: [u8; 32],
147 /// Protocol version. Pinned to [`PROTOCOL_VERSION`] and bound into the
148 /// signed handshake transcript; the server rejects any other value with
149 /// [`HandshakeError::UnsupportedVersion`].
150 pub version: u8,
151 /// Stateless generic cookie to prove IP ownership
152 pub cookie: Option<[u8; 32]>,
153 /// Proof-of-Work solution (if required by server)
154 pub pow_solution: Option<PoWSolution>,
155 /// Optional session ID for 0-RTT resumption
156 pub resume_session_id: Option<[u8; 32]>,
157 /// Resumption proof-of-possession binder (HS-03). Present iff
158 /// `resume_session_id` is — a keyed PRF over `resumption_secret ||
159 /// resume_session_id || nonce` (see `derive_resumption_binder`). The server
160 /// verifies it (constant-time) against the cached ticket's secret *before*
161 /// consuming the one-shot ticket, so a passive observer that copied the
162 /// cleartext `resume_session_id` cannot burn a victim's ticket. Bound into
163 /// the transcript (the whole `ClientHello` is signed), so it is also
164 /// tamper-evident. Placed after `resume_session_id` and before
165 /// `protocol_variant` — borsh field order is wire-load-bearing.
166 pub resumption_binder: Option<[u8; 32]>,
167 /// Cleartext copy of [`PROTOCOL_VARIANT`]. Lets the server reject
168 /// a mismatched-mode client up front (before signature
169 /// verification); the same value is bound into the handshake
170 /// transcript so an attacker rewriting this field on the wire is
171 /// still caught by the signature check.
172 pub protocol_variant: Vec<u8>,
173 /// Optional AEAD-sealed 0-RTT early-data — AES-256-GCM under a key both
174 /// peers derive from the prior session's `resumption_secret` via
175 /// [`derive_early_data_keying`]. `None` means no 0-RTT data on this
176 /// connect. The whole `ClientHello` (this field included) is covered by
177 /// the transcript signature, so a tampered or stripped blob breaks the
178 /// server's signature check (Invariant 7).
179 pub early_data: Option<Vec<u8>>,
180}
181
182/// Real maximum byte lengths of the variable-length `ClientHello` fields (M-7). The ML-KEM-768
183/// encapsulation key is 1184 B and the ML-DSA-65 verifying key 1952 B — both fixed by FIPS
184/// 203 / 204, independent of the classical/fips build — and the `PROTOCOL_VARIANT` tag is well
185/// under 32 B; 0-RTT early-data is capped at [`EARLY_DATA_MAX_LEN`].
186const ML_KEM_PK_MAX: usize = 1184;
187const ML_DSA_PK_MAX: usize = 1952;
188const PROTOCOL_VARIANT_MAX: usize = 64;
189
190/// Structural length pre-check for a borsh-encoded [`ClientHello`] (M-7). Walks the frozen
191/// borsh field layout reading ONLY the `Vec<u8>` length prefixes (u32 little-endian) and the
192/// 1-byte `Option` tags, validating each variable field against its real maximum WITHOUT
193/// allocating — so a forged length (e.g. `0xFFFF_FFFF` on `ml_kem_pk`) is rejected before
194/// `borsh::from_slice` performs its `vec![0u8; len.min(1 MiB)]` eager allocate+memset. Returns
195/// `false` on any out-of-bounds length, truncation, or trailing bytes; the caller then drops
196/// the frame.
197///
198/// NOTE: coupled to the `ClientHello` borsh layout (frozen by `core/tests/wire_vectors`). A
199/// field-order/type change must update this AND the vectors together; the unit test feeds a
200/// real `create_client_hello()` through it, so a layout drift that breaks a genuine hello is
201/// caught here as a hard red.
202pub(crate) fn client_hello_lengths_within_bounds(bytes: &[u8]) -> bool {
203 /// Advance `pos` by `n` fixed bytes; false on truncation.
204 fn skip(bytes: &[u8], pos: &mut usize, n: usize) -> bool {
205 match pos.checked_add(n) {
206 Some(end) if end <= bytes.len() => {
207 *pos = end;
208 true
209 }
210 _ => false,
211 }
212 }
213 /// Read a borsh u32 length prefix (little-endian); `None` on truncation.
214 fn read_len(bytes: &[u8], pos: &mut usize) -> Option<usize> {
215 let end = pos.checked_add(4)?;
216 if end > bytes.len() {
217 return None;
218 }
219 let l = u32::from_le_bytes([
220 bytes[*pos],
221 bytes[*pos + 1],
222 bytes[*pos + 2],
223 bytes[*pos + 3],
224 ]);
225 *pos = end;
226 Some(l as usize)
227 }
228 /// Read a borsh `Option` tag (1 byte: 0 = None, 1 = Some); `None` on a bad tag/truncation.
229 fn read_opt(bytes: &[u8], pos: &mut usize) -> Option<bool> {
230 let tag = *bytes.get(*pos)?;
231 *pos += 1;
232 match tag {
233 0 => Some(false),
234 1 => Some(true),
235 _ => None,
236 }
237 }
238 /// Validate a length-prefixed `Vec<u8>` whose length must be `<= max`.
239 fn vec_le(bytes: &[u8], pos: &mut usize, max: usize) -> bool {
240 match read_len(bytes, pos) {
241 Some(l) if l <= max => skip(bytes, pos, l),
242 _ => false,
243 }
244 }
245 /// Validate an `Option<[u8; n]>` fixed-size field.
246 fn opt_fixed(bytes: &[u8], pos: &mut usize, n: usize) -> bool {
247 match read_opt(bytes, pos) {
248 Some(true) => skip(bytes, pos, n),
249 Some(false) => true,
250 None => false,
251 }
252 }
253
254 let mut pos = 0usize;
255 // client_key_package: classical_pk [u8; CLASSICAL_PK_BYTES] ++ ml_kem_pk Vec<u8>
256 if !skip(
257 bytes,
258 &mut pos,
259 crate::crypto::hybrid_kem::CLASSICAL_PK_BYTES,
260 ) || !vec_le(bytes, &mut pos, ML_KEM_PK_MAX)
261 {
262 return false;
263 }
264 // client_verify_key: ed25519_pk [u8; 32] ++ ml_dsa_pk Vec<u8>
265 if !skip(bytes, &mut pos, 32) || !vec_le(bytes, &mut pos, ML_DSA_PK_MAX) {
266 return false;
267 }
268 // nonce [u8; 32] ++ version u8
269 if !skip(bytes, &mut pos, 32 + 1) {
270 return false;
271 }
272 // cookie Option<[u8;32]>, pow_solution Option<PoWSolution = [u8;32] ++ u64 = 40 B>,
273 // resume_session_id Option<[u8;32]>, resumption_binder Option<[u8;32]>
274 if !opt_fixed(bytes, &mut pos, 32)
275 || !opt_fixed(bytes, &mut pos, 40)
276 || !opt_fixed(bytes, &mut pos, 32)
277 || !opt_fixed(bytes, &mut pos, 32)
278 {
279 return false;
280 }
281 // protocol_variant Vec<u8>
282 if !vec_le(bytes, &mut pos, PROTOCOL_VARIANT_MAX) {
283 return false;
284 }
285 // early_data Option<Vec<u8>>
286 match read_opt(bytes, &mut pos) {
287 Some(true) => {
288 if !vec_le(bytes, &mut pos, EARLY_DATA_MAX_LEN) {
289 return false;
290 }
291 }
292 Some(false) => {}
293 None => return false,
294 }
295 // Exactly consumed: a well-formed, bounded ClientHello. Trailing bytes = malformed
296 // (borsh::from_slice itself rejects trailing data).
297 pos == bytes.len()
298}
299
300/// Server response to ClientHello
301//
302// Intentionally large — the `Success` variant carries a full `Session`.
303// Boxing it would add a heap allocation on every successful handshake
304// (the hot path); the type is internal and lives only on the handshake
305// stack, so the size is acceptable. Same rationale as the
306// `result_large_err` allow on the gate/finalize helpers below.
307#[allow(clippy::large_enum_variant)]
308#[derive(Debug)]
309pub enum HandshakeResponse {
310 /// Success: the `ServerHello` to send back, the established `Session`,
311 /// and the decrypted 0-RTT early-data plaintext (`None` when the client
312 /// sent none or it was rejected — `ServerHello.early_data_accepted`
313 /// carries the verdict the client sees).
314 Success(ServerHello, Session, Option<Vec<u8>>),
315 /// Retry: Demand PoW or Cookie
316 Retry(HelloRetryRequest),
317 /// Reject: the server structurally cannot speak this `ClientHello` (e.g. an
318 /// unknown `version`). Unlike `Fail`, the listener serialises the carried
319 /// [`ServerReject`] back to the client before closing, so the peer gets a
320 /// typed downgrade signal instead of a bare connection error.
321 Reject(ServerReject),
322 /// Fail: Handshake aborted
323 Fail(HandshakeError),
324}
325
326/// Hello Retry Request (Server demands PoW or Cookie)
327#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
328pub struct HelloRetryRequest {
329 pub challenge: Option<PoWChallenge>,
330 pub cookie: Option<[u8; 32]>,
331}
332
333/// Outcome of the UDP demux address-validation pre-gate
334/// ([`HandshakeServer::udp_admit`], H-2).
335pub(crate) enum UdpAdmit {
336 /// The hello carries a valid IP-bound cookie — the caller may allocate a slot.
337 Admit,
338 /// No/invalid cookie — reply with this stateless cookie demand; commit no slot.
339 Retry(HelloRetryRequest),
340 /// Internal cookie-generation failure — drop without reply.
341 Drop,
342}
343
344/// Server hello message (response to ClientHello)
345#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
346pub struct ServerHello {
347 /// Server-contributed 32-byte nonce (T4.3). Replaces the former ~1184 B
348 /// `server_key_package` — a full ML-KEM key package whose KEM secret was discarded
349 /// (the protocol runs no second KEM round). It is bound into the signed transcript,
350 /// so it remains a session-specific, tamper-evident server contribution beyond
351 /// `session_id` + the client nonce; a future second-KEM ring could repurpose this
352 /// slot. Saves ~1.1 KB on every ServerHello.
353 pub server_nonce: [u8; 32],
354 /// Encapsulated secret (ciphertext for client)
355 pub ciphertext: HybridCiphertext,
356 /// Server's hybrid verifying key
357 pub server_verify_key: HybridVerifyingKey,
358 /// Signature over handshake transcript
359 pub signature: HybridSignature,
360 /// Session ID assigned by server
361 pub session_id: [u8; 32],
362 /// `true` iff the server decrypted and accepted the client's 0-RTT
363 /// early-data. `false` when there was none, the resumption ticket was
364 /// unknown/expired, the blob exceeded the size cap, or AEAD decryption
365 /// failed — in every `false` case the handshake still completes as a
366 /// normal 1-RTT exchange.
367 pub early_data_accepted: bool,
368}
369
370/// A discriminated server handshake reply (T4.4).
371///
372/// The wire form is `[kind: u8] ‖ borsh(body)`. The leading discriminant byte lets the
373/// client dispatch the three possible replies **explicitly**, instead of the former
374/// trial-deserialization that distinguished them by message size (a `ServerReject` is a
375/// handful of bytes, a `HelloRetryRequest` tens, a `ServerHello` thousands — robust in
376/// practice, but a same-size confusion was structurally possible). The discriminant is an
377/// API-layer framing byte that sits *outside* the borsh message structs, so it does not
378/// affect the frozen `wire_vectors` (which fix the bare message encodings).
379// `ServerReply` is a transient wire-framing wrapper: it is constructed once per handshake
380// reply, immediately `to_wire`/`from_wire`'d, then dropped — never stored or passed in bulk.
381// The `Hello` variant is intrinsically large (the multi-KiB signed `ServerHello`); boxing it
382// would add a heap allocation to every reply for no real benefit at this lifetime.
383#[allow(clippy::large_enum_variant)]
384#[derive(Debug, Clone)]
385pub enum ServerReply {
386 /// Handshake succeeded — carries the signed [`ServerHello`].
387 Hello(ServerHello),
388 /// DoS gate — carries a cookie / PoW [`HelloRetryRequest`].
389 Retry(HelloRetryRequest),
390 /// Structural rejection (e.g. unknown `version`) — carries a [`ServerReject`].
391 Reject(ServerReject),
392}
393
394impl ServerReply {
395 const KIND_HELLO: u8 = 0;
396 const KIND_RETRY: u8 = 1;
397 const KIND_REJECT: u8 = 2;
398
399 /// Serialise to `[kind: u8] ‖ borsh(body)`.
400 pub fn to_wire(&self) -> Result<Vec<u8>, HandshakeError> {
401 let map = |e: borsh::io::Error| HandshakeError::SerializationError(e.to_string());
402 let (kind, body) = match self {
403 ServerReply::Hello(h) => (Self::KIND_HELLO, borsh::to_vec(h).map_err(map)?),
404 ServerReply::Retry(r) => (Self::KIND_RETRY, borsh::to_vec(r).map_err(map)?),
405 ServerReply::Reject(r) => (Self::KIND_REJECT, borsh::to_vec(r).map_err(map)?),
406 };
407 let mut out = Vec::with_capacity(1 + body.len());
408 out.push(kind);
409 out.extend_from_slice(&body);
410 Ok(out)
411 }
412
413 /// Parse `[kind: u8] ‖ borsh(body)` with explicit dispatch on the discriminant. An
414 /// empty input or an unknown kind byte is an error — never a silent misparse.
415 pub fn from_wire(bytes: &[u8]) -> Result<Self, HandshakeError> {
416 let (&kind, body) = bytes
417 .split_first()
418 .ok_or_else(|| HandshakeError::SerializationError("empty server reply".into()))?;
419 let map = |e: borsh::io::Error| HandshakeError::SerializationError(e.to_string());
420 match kind {
421 Self::KIND_HELLO => Ok(ServerReply::Hello(borsh::from_slice(body).map_err(map)?)),
422 Self::KIND_RETRY => Ok(ServerReply::Retry(borsh::from_slice(body).map_err(map)?)),
423 Self::KIND_REJECT => Ok(ServerReply::Reject(borsh::from_slice(body).map_err(map)?)),
424 other => Err(HandshakeError::SerializationError(format!(
425 "unknown server-reply discriminant {other}"
426 ))),
427 }
428 }
429}
430
431/// Handshake transcript for signing.
432///
433/// Embeds the whole `ClientHello` by reference — including the optional
434/// 0-RTT `early_data` ciphertext — so the server's signature covers it and a
435/// tampered or stripped blob breaks the client-side signature check
436/// (Invariant 7). The transcript leads with the build-side
437/// [`PROTOCOL_VARIANT`] tag, so a cross-mode (fips ↔ non-fips) attempt fails
438/// the signature check rather than landing a wrong shared secret. Both peers
439/// MUST plumb the same byte string for the signature to verify.
440#[derive(BorshSerialize)]
441struct HandshakeTranscript<'a> {
442 protocol_variant: &'a [u8],
443 client_hello: &'a ClientHello,
444 server_nonce: &'a [u8; 32],
445 ciphertext: &'a HybridCiphertext,
446 server_verify_key: &'a HybridVerifyingKey,
447 session_id: &'a [u8; 32],
448 /// The server's 0-RTT verdict (H2). Signing it stops an on-path attacker
449 /// from flipping `ServerHello.early_data_accepted` to make the client
450 /// duplicate or silently drop early-data (Invariant 9). Placed LAST so
451 /// `protocol_variant` stays the leading transcript field (Invariant 10).
452 early_data_accepted: bool,
453}
454
455/// Hash a borsh-serializable transcript. The transcript leads with the
456/// `protocol_variant` tag, so the hash binds the build-side variant.
457fn compute_transcript_hash<T: BorshSerialize>(transcript: &T) -> Result<[u8; 32], HandshakeError> {
458 let mut hasher = Sha256::new();
459 let bytes =
460 borsh::to_vec(transcript).map_err(|e| HandshakeError::SerializationError(e.to_string()))?;
461 hasher.update(&bytes);
462 Ok(hasher.finalize().into())
463}
464
465/// A resumption ticket that the resume fast-path has eagerly consumed after a
466/// successful binder check (HS-03 / ZERORTT-2). Carries everything needed to
467/// re-insert the ticket **unchanged** (preserving its original lifetime) if a
468/// later handshake step fails, so a corrupted resuming `ClientHello` cannot burn
469/// a victim's one-shot ticket.
470struct ConsumedTicket {
471 rid: [u8; 32],
472 secret: [u8; 32],
473 suite: CipherSuite,
474 created_at: std::time::Instant,
475 expires_at: std::time::Instant,
476}
477
478/// Derive the HS-03 resumption proof-of-possession binder: a keyed PRF over
479/// `resumption_secret || resume_session_id || client_nonce` via
480/// [`crate::crypto::kdf::derive_key_32`] (blake3 on default builds, HKDF-SHA256
481/// under `--features fips`). Binding the per-connect `client_nonce` makes the
482/// binder connect-specific; keying it on the secret means only a client that
483/// actually holds `resumption_secret` — not a passive observer of the cleartext
484/// `resume_session_id` — can produce a value the server will accept.
485fn derive_resumption_binder(
486 resumption_secret: &[u8; 32],
487 resume_session_id: &[u8; 32],
488 client_nonce: &[u8; 32],
489) -> [u8; 32] {
490 // The IKM holds the resumption secret; wipe it on every exit path.
491 let mut ikm = zeroize::Zeroizing::new([0u8; 96]);
492 ikm[..32].copy_from_slice(resumption_secret);
493 ikm[32..64].copy_from_slice(resume_session_id);
494 ikm[64..].copy_from_slice(client_nonce);
495 crate::crypto::kdf::derive_key_32("phantom-resume-binder-v1", &*ikm)
496}
497
498/// Pluggable 0-RTT anti-replay hook — the distributed-deployment seam (A2b).
499///
500/// The built-in one-shot guarantee (Invariant 9) — a resumption ticket is consumed on first
501/// use, so a replayed 0-RTT `ClientHello` finds no ticket and falls back to 1-RTT — holds
502/// only under a **single coherent** [`SessionCache`]: one process, or routing that pins a
503/// resuming client to the node that minted its ticket. A horizontally-scaled deployment whose
504/// nodes each keep their own (or a replicated) cache lets an attacker replay a captured 0-RTT
505/// `ClientHello` to a **different** node, which still holds the ticket and would accept the
506/// early-data a second time.
507///
508/// To close that, such a deployment installs a `ZeroRttAntiReplay` backed by a store shared by
509/// all nodes (e.g. Redis `SET key NX`, a single-row compare-and-set, a DynamoDB conditional
510/// put) via [`HandshakeServer::set_zero_rtt_anti_replay`]. The transport does **not** ship a
511/// distributed store — it is the embedder's infrastructure. When none is installed the
512/// single-node `SessionCache` is the authority. The orthogonal escape hatch is to disable
513/// 0-RTT early-data entirely ([`HandshakeServer::set_early_data_enabled`]).
514pub trait ZeroRttAntiReplay: Send + Sync {
515 /// Atomically record the first use of resumption ticket `ticket_id` and report whether
516 /// THIS call is that first use: `true` if the id had not been seen before (0-RTT may
517 /// proceed), `false` if it was already consumed (a replay — the server rejects the
518 /// early-data and completes a normal 1-RTT handshake). The implementation MUST be atomic
519 /// across every node sharing the resumption cache, and SHOULD retain each consumed id for
520 /// at least the ticket lifetime (after which the ticket has expired anyway). Called only
521 /// after the resumption proof-of-possession binder has verified (HS-03), so only a holder
522 /// of the ticket secret reaches it.
523 fn check_and_set(&self, ticket_id: &[u8; 32]) -> bool;
524}
525
526/// Handshake Server State Machine
527///
528/// Holds the server's long-lived signing key (via [`HybridSigningKey`], which
529/// itself zeroes on drop) and a *master* secret from which the actually-used
530/// per-hour PoW/cookie secret is derived on each call (see
531/// `derive_session_secret_for_hour`). On drop the master is zeroed via the
532/// derived `ZeroizeOnDrop`.
533///
534/// Rotation (Phase 1.11): the master itself rotates only on process restart,
535/// but the derived hour-bucketed secret rotates every hour. Validation
536/// accepts the current hour and the immediately-previous hour, so a cookie
537/// or PoW solution captured at minute 59 of one hour is still honored at
538/// minute 5 of the next.
539#[derive(ZeroizeOnDrop)]
540pub struct HandshakeServer {
541 // SAFETY: `HybridSigningKey` has its own ZeroizeOnDrop. The wrapping field
542 // is skipped here so the derive doesn't try to call `Zeroize::zeroize`
543 // (which the inner type does not implement).
544 #[zeroize(skip)]
545 signing_key: HybridSigningKey,
546 // Public material — never sensitive.
547 #[zeroize(skip)]
548 verifying_key: HybridVerifyingKey,
549 master_secret: [u8; 32],
550 /// Adaptive-difficulty counters (Phase 1.14). Atomics so they are
551 /// thread-safe for the concurrent `accept()` path; not secret, hence
552 /// `#[zeroize(skip)]`.
553 #[zeroize(skip)]
554 handshakes_this_minute: AtomicU64,
555 #[zeroize(skip)]
556 minute_start_unix_sec: AtomicU64,
557 /// Server-side resumption cache (Phase 4.1). Stores
558 /// `ResumptionTicket` keyed on the session id derived at handshake
559 /// success. Bounded LRU with a 1-hour ticket lifetime by default;
560 /// `try_resume` returns a forward-secret derived secret per call.
561 /// `Arc<Mutex<>>` so all handshake threads share one cache.
562 #[zeroize(skip)]
563 session_cache: Arc<parking_lot::Mutex<SessionCache>>,
564 /// Per-IP reputation tracker (DOS-2). Drives a PoW-difficulty escalation for
565 /// abusive sources on top of the global load tier; bounded map. Holds no
566 /// secrets, hence `#[zeroize(skip)]`.
567 #[zeroize(skip)]
568 reputation: Arc<ReputationTracker>,
569 /// Whether 0-RTT early-data is accepted (A2b). `true` by default. When `false`, the server
570 /// rejects every resuming client's early-data (`early_data_accepted = false`) so the
571 /// payload is only ever delivered 1-RTT — the simplest defence against 0-RTT replay for a
572 /// deployment that cannot guarantee a coherent/atomic resumption cache. Resumption itself
573 /// (the cookie/PoW bypass) is unaffected; it is replay-safe via the fresh hybrid KEM. Not
574 /// secret. Interior-mutable so the embedder can set it on the shared `Arc<HandshakeServer>`.
575 #[zeroize(skip)]
576 early_data_enabled: AtomicBool,
577 /// Optional distributed 0-RTT anti-replay store (A2b). `None` (default) ⇒ the single-node
578 /// `SessionCache` is the one-shot authority. `Some` ⇒ the consume is ALSO gated globally
579 /// via [`ZeroRttAntiReplay::check_and_set`]. Not secret; a `Mutex<Option<Arc<dyn ..>>>`
580 /// (ArcSwap can't hold an unsized `dyn`) so it is settable on the shared
581 /// `Arc<HandshakeServer>` — read once per resume (a brief, uncontended lock off the hot path).
582 #[zeroize(skip)]
583 anti_replay: parking_lot::Mutex<Option<Arc<dyn ZeroRttAntiReplay>>>,
584}
585
586impl HandshakeServer {
587 pub fn new() -> Result<Self, HandshakeError> {
588 // `bind()` without a persisted key mints the server's long-lived identity
589 // here — run the FIPS pairwise-consistency check on it (this is a
590 // per-process identity, not a per-handshake key, so the cost is paid
591 // once at startup).
592 let (signing_key, verifying_key) = HybridSigningKey::generate();
593 signing_key
594 .pairwise_consistency_check(&verifying_key)
595 .map_err(|e| {
596 HandshakeError::RngError(format!(
597 "server signing identity failed its pairwise-consistency test: {e:?}"
598 ))
599 })?;
600 Self::with_signing_key(signing_key)
601 }
602
603 /// Build a `HandshakeServer` from a caller-supplied long-lived
604 /// [`HybridSigningKey`] (Phase 7.4 follow-up).
605 ///
606 /// Used by embedders that persist the server's signing key across
607 /// restarts so client pinning material does not change on every
608 /// boot. The verifying key is derived from the supplied signing key,
609 /// the per-process master secret is freshly generated, and the
610 /// remaining state (PoW counters, session cache) initializes the
611 /// same way as [`Self::new`].
612 ///
613 /// The supplied `signing_key` is moved in and held under
614 /// `HandshakeServer`'s [`ZeroizeOnDrop`] — the same memory-hygiene
615 /// invariant as the auto-generated path.
616 pub fn with_signing_key(signing_key: HybridSigningKey) -> Result<Self, HandshakeError> {
617 let verifying_key = signing_key.verifying_key();
618
619 let mut master_secret = [0u8; 32];
620 getrandom::getrandom(&mut master_secret)
621 .map_err(|e| HandshakeError::RngError(e.to_string()))?;
622
623 let now_sec = SystemTime::now()
624 .duration_since(UNIX_EPOCH)
625 .map(|d| d.as_secs())
626 .unwrap_or(0);
627
628 Ok(Self {
629 signing_key,
630 verifying_key,
631 master_secret,
632 handshakes_this_minute: AtomicU64::new(0),
633 minute_start_unix_sec: AtomicU64::new(now_sec),
634 session_cache: Arc::new(parking_lot::Mutex::new(SessionCache::new())),
635 reputation: Arc::new(ReputationTracker::new()),
636 early_data_enabled: AtomicBool::new(true),
637 anti_replay: parking_lot::Mutex::new(None),
638 })
639 }
640
641 /// Enable or disable 0-RTT early-data acceptance (A2b). Default enabled. When disabled, the
642 /// server rejects every resuming client's early-data (`ServerHello.early_data_accepted =
643 /// false`) and completes a normal 1-RTT handshake, so the client resends the payload after
644 /// the handshake — the simplest, zero-infrastructure defence against 0-RTT replay for a
645 /// deployment that cannot guarantee a single coherent / atomically-consumed resumption
646 /// cache. Resumption (the cookie/PoW bypass) is unaffected. Settable on the shared
647 /// `Arc<HandshakeServer>` at any time.
648 pub fn set_early_data_enabled(&self, enabled: bool) {
649 self.early_data_enabled.store(enabled, Ordering::Relaxed);
650 }
651
652 /// Whether 0-RTT early-data acceptance is currently enabled (A2b).
653 pub fn early_data_enabled(&self) -> bool {
654 self.early_data_enabled.load(Ordering::Relaxed)
655 }
656
657 /// Install a distributed 0-RTT anti-replay store (A2b) — see [`ZeroRttAntiReplay`]. Once
658 /// installed, a resume is accepted only if the ticket id is first-use according to BOTH the
659 /// local `SessionCache` and this store, so a replay to a different node (whose local replica
660 /// still holds the ticket) is rejected globally. Required for replay-safe 0-RTT in a
661 /// horizontally-scaled deployment; the default (none) is correct for a single node / sticky
662 /// routing. Settable on the shared `Arc<HandshakeServer>`.
663 pub fn set_zero_rtt_anti_replay(&self, store: Arc<dyn ZeroRttAntiReplay>) {
664 *self.anti_replay.lock() = Some(store);
665 }
666
667 /// Increment the per-minute handshake-count counter and roll over the
668 /// minute window if necessary. Called at the start of every
669 /// `process_client_hello`.
670 fn record_handshake(&self) {
671 let now_sec = SystemTime::now()
672 .duration_since(UNIX_EPOCH)
673 .map(|d| d.as_secs())
674 .unwrap_or(0);
675 let start = self.minute_start_unix_sec.load(Ordering::Relaxed);
676 if now_sec.saturating_sub(start) >= 60 {
677 // Reset the bucket. Racing other threads here is acceptable —
678 // multiple resets within a single boundary just under-count by a
679 // few; the next minute is unaffected.
680 self.handshakes_this_minute.store(0, Ordering::Relaxed);
681 self.minute_start_unix_sec.store(now_sec, Ordering::Relaxed);
682 }
683 self.handshakes_this_minute.fetch_add(1, Ordering::Relaxed);
684 }
685
686 /// Recommended PoW difficulty for the current handshake load. Callers
687 /// (e.g. `PhantomListener::accept`) pass this into `process_client_hello`
688 /// so the cost imposed on each new client scales with server load.
689 ///
690 /// Difficulty tiers (handshakes-per-minute → difficulty):
691 /// ```text
692 /// <100 → 0 (no PoW)
693 /// 100..500 → 4 (~16 hash evaluations expected)
694 /// 500..2000 → 8 (~256 evaluations)
695 /// 2000..10000 → 12 (~4k evaluations)
696 /// >=10000 → 16 (~64k evaluations)
697 /// ```
698 /// These tiers err on the side of leniency: a healthy server doing a few
699 /// hundred handshakes per minute imposes no PoW work on clients. Only at
700 /// high load — where DoS protection matters most — does the cost ramp up.
701 pub fn adaptive_difficulty(&self) -> u8 {
702 let count = self.handshakes_this_minute.load(Ordering::Relaxed);
703 match count {
704 0..=99 => 0,
705 100..=499 => 4,
706 500..=1999 => 8,
707 2000..=9999 => 12,
708 _ => 16,
709 }
710 }
711
712 /// Per-IP PoW-difficulty escalation for `client_ip` (DOS-2) — 0 for clean
713 /// IPs and resumption-ticket holders, escalating for sources with recent
714 /// handshake violations. The server uses
715 /// `max(adaptive_difficulty(), reputation_difficulty(...))` so an abusive IP
716 /// is singled out even when the global load tier is idle, without penalizing
717 /// well-behaved clients.
718 pub(crate) fn reputation_difficulty(&self, client_ip: IpAddr, has_ticket: bool) -> u8 {
719 self.reputation.calculate_difficulty(client_ip, has_ticket)
720 }
721
722 /// Record a handshake violation for `client_ip` (DOS-2) — drives the
723 /// escalation. Called on a genuine protocol failure (bad/old/abusive client),
724 /// NOT on a normal first-contact cookie/PoW retry.
725 pub(crate) fn record_violation(&self, client_ip: IpAddr) {
726 self.reputation.record_violation(client_ip);
727 }
728
729 /// Clear `client_ip`'s violation record after a successful handshake (DOS-2).
730 pub(crate) fn reset_violations(&self, client_ip: IpAddr) {
731 self.reputation.reset_violations(client_ip);
732 }
733
734 /// Stateless UDP demux address-validation pre-gate (H-2). On the connectionless UDP
735 /// path the datagram source is unverified, so a per-connection slot (an `inflight`
736 /// permit + a demux route + a handshake task) must not be committed until the source
737 /// proves it can receive at its claimed address by echoing a stateless cookie. This
738 /// runs only the cookie half of [`Self::cookie_pow_gate`] — cheaply, on the demux
739 /// thread, with no KEM/signature work and no committed state — and hands back a cookie
740 /// demand otherwise. The PoW/handshake work stays in the per-connection task (post-slot,
741 /// for an already address-validated source). Resume tickets do NOT bypass this cookie:
742 /// unlike TCP (address-validated by its 3-way handshake), the UDP source is unproven, so
743 /// 0-RTT-over-UDP completes a cookie round first.
744 pub(crate) fn udp_admit(&self, client_hello: &ClientHello, client_ip: IpAddr) -> UdpAdmit {
745 let cookie_valid = match client_hello.cookie {
746 Some(c) => validate_cookie(&self.master_secret, client_ip, &c).unwrap_or(false),
747 None => false,
748 };
749 if cookie_valid {
750 return UdpAdmit::Admit;
751 }
752 match generate_cookie(&self.master_secret, client_ip) {
753 Ok(cookie) => UdpAdmit::Retry(HelloRetryRequest {
754 challenge: None,
755 cookie: Some(cookie),
756 }),
757 Err(_) => UdpAdmit::Drop,
758 }
759 }
760
761 /// Non-consuming check that `client_hello` carries a VALID 0-RTT resume — its
762 /// `resume_session_id` names a still-cached ticket AND the `resumption_binder` proves
763 /// possession of that ticket's secret (constant-time). Gates the per-IP PoW-difficulty
764 /// reduction on real ticket holders, so attaching a junk `resume_session_id` cannot
765 /// nullify the reputation escalation (M-5). Does NOT consume the ticket — the resume fast
766 /// path in [`Self::process_client_hello`] still peeks + consumes it.
767 pub(crate) fn has_valid_resume(&self, client_hello: &ClientHello) -> bool {
768 let Some(rid) = client_hello.resume_session_id else {
769 return false;
770 };
771 let Some((secret, _suite, _created_at, _expires_at)) = self.session_cache.lock().peek(&rid)
772 else {
773 return false;
774 };
775 let Some(presented) = client_hello.resumption_binder else {
776 return false;
777 };
778 let expected = derive_resumption_binder(&secret, &rid, &client_hello.nonce);
779 bool::from(presented.ct_eq(&expected))
780 }
781
782 /// Drop expired reputation entries (DOS-2) — driven periodically by the
783 /// listener's acceptor loop.
784 pub(crate) fn gc_reputation(&self) {
785 self.reputation.gc();
786 }
787
788 /// Current per-minute handshake count. Exposed for metrics
789 /// (`handshakes_per_minute`).
790 pub fn handshakes_this_minute(&self) -> u64 {
791 self.handshakes_this_minute.load(Ordering::Relaxed)
792 }
793
794 #[tracing::instrument(
795 name = "phantom.handshake.process_client_hello",
796 skip_all,
797 // No `client_ip` field: this span is always-on in the library, and the
798 // peer IP is correlatable PII. The DoS gate already has the IP in-band;
799 // it does not need to leak into every handshake trace.
800 fields(
801 difficulty = difficulty,
802 has_cookie = client_hello.cookie.is_some(),
803 has_pow = client_hello.pow_solution.is_some(),
804 resume = client_hello.resume_session_id.is_some(),
805 has_early_data = client_hello.early_data.is_some(),
806 ),
807 )]
808 pub fn process_client_hello(
809 &self,
810 client_hello: &ClientHello,
811 difficulty: u8,
812 client_ip: IpAddr,
813 ) -> HandshakeResponse {
814 // Tally this call before any work is done, so the load counter
815 // reflects attempts (including the rejected ones).
816 self.record_handshake();
817
818 // Protocol-variant gate. Fail loud (before any KEM / signature work)
819 // if the client and server disagree on the build-side
820 // `PROTOCOL_VARIANT` tag. The transcript also binds this constant, so
821 // an MITM rewrite of the cleartext field is caught on the client's
822 // signature check; this explicit field gives operators a clean
823 // diagnostic instead of "Signature check failed" (Invariant 10).
824 if client_hello.protocol_variant != PROTOCOL_VARIANT {
825 return HandshakeResponse::Fail(HandshakeError::ProtocolVariantMismatch {
826 expected: PROTOCOL_VARIANT.to_vec(),
827 received: client_hello.protocol_variant.clone(),
828 });
829 }
830
831 // Version pin. The protocol is not negotiated — `version` is a
832 // tamper-check anchor pinned to `PROTOCOL_VERSION` and borsh-serialized
833 // into the signed transcript, so a network rewrite forces a
834 // client-side signature mismatch. Anything else is rejected up front
835 // (Invariant 7). Instead of dropping silently we hand back a typed
836 // `ServerReject` advertising the version we speak, so a future client
837 // degrades gracefully (H9 forward-compat) — the client treats it as a
838 // hard error and does NOT auto-downgrade, preserving Invariant 7's
839 // transcript-bound downgrade resistance.
840 if client_hello.version != PROTOCOL_VERSION {
841 return HandshakeResponse::Reject(ServerReject::unsupported_version());
842 }
843
844 // 0-RTT resumption fast path with proof-of-possession (HS-03 + ZERORTT-2).
845 //
846 // If the client offered a `resume_session_id` AND the cache holds a
847 // still-valid ticket, a valid resume lets the client skip the cookie/PoW
848 // DoS gate and (with a sealed blob) deliver 0-RTT early-data.
849 //
850 // Before trusting the resume we require proof the client holds the
851 // ticket's `resumption_secret` — a `resumption_binder` MAC (HS-03). We
852 // PEEK the ticket (no consume) to recompute the expected binder and
853 // compare it constant-time; a missing/mismatched binder (e.g. a passive
854 // observer that only copied the cleartext `resume_session_id`) means NO
855 // resume — the ticket is left untouched and the client falls through to
856 // the normal cookie/PoW gate.
857 //
858 // On a valid binder we CONSUME the ticket eagerly (one-shot anti-replay,
859 // Invariant 9); `remove` returns `true` for exactly one of two racing
860 // duplicate resumes, so the same early-data can't be accepted twice. The
861 // consumed ticket is carried in `resumed` and re-inserted unchanged on
862 // any later handshake failure (ZERORTT-2), so a corrupted resuming
863 // `ClientHello` cannot burn a victim's ticket. The KEM round-trip still
864 // runs, so forward secrecy is preserved by the fresh hybrid-KEM secret
865 // (X25519+ML-KEM-768; ECDH-P-256+ML-KEM-768 under `--features fips`).
866 let resumed: Option<ConsumedTicket> = client_hello.resume_session_id.and_then(|rid| {
867 let (secret, suite, created_at, expires_at) = self.session_cache.lock().peek(&rid)?;
868 // Proof-of-possession: only a holder of `resumption_secret` can
869 // produce a binder that matches. `None` binder ⇒ no resume.
870 let expected = derive_resumption_binder(&secret, &rid, &client_hello.nonce);
871 let presented = client_hello.resumption_binder?;
872 if !bool::from(presented.ct_eq(&expected)) {
873 return None;
874 }
875 // Binder verified — consume now (race-free via `remove`'s bool).
876 if !self.session_cache.lock().remove(&rid) {
877 return None; // a concurrent resume already consumed it on THIS node
878 }
879 // A2b — when a distributed anti-replay store is installed, the consume must ALSO be
880 // first-use GLOBALLY across every node sharing the resumption cache. A replay to a
881 // different node, whose local replica still holds the ticket (so `remove` above
882 // returned `true`), is caught here and falls back to 1-RTT. (`check_and_set` is the
883 // authority; on a later handshake failure the local ticket is re-inserted as before,
884 // but the global one-shot is already recorded — a rare infra-failure retry then does
885 // 1-RTT, which is acceptable: the ticket is single-use either way.)
886 let store = self.anti_replay.lock().clone();
887 if let Some(store) = store {
888 if !store.check_and_set(&rid) {
889 return None; // already consumed on another node (replay) → no resume
890 }
891 }
892 Some(ConsumedTicket {
893 rid,
894 secret,
895 suite,
896 created_at,
897 expires_at,
898 })
899 });
900 let cookie_pow_bypass = resumed.is_some();
901
902 // Stateless DoS checks (Cookie & PoW). On the bypass path the gate
903 // returns Ok; a rare infra error there is still post-consume, so hand the
904 // ticket back if it fails (ZERORTT-2).
905 if let Err(resp) =
906 self.cookie_pow_gate(client_hello, difficulty, client_ip, cookie_pow_bypass)
907 {
908 return self.fail_and_reinsert(&resumed, resp);
909 }
910
911 // Best-effort 0-RTT early-data decryption. Only attempted when the
912 // client both presented a valid ticket AND carried a sealed blob; any
913 // failure (unknown/expired ticket, oversized blob, AEAD failure)
914 // leaves `early_data_accepted = false` and completes a normal 1-RTT
915 // handshake (Invariant 9). Forward secrecy of the post-handshake
916 // session is preserved by the fresh hybrid KEM regardless.
917 // A2b — when 0-RTT early-data is disabled by config, reject it unconditionally:
918 // `early_data_accepted = false`, the resuming client resends the payload 1-RTT. The
919 // resume itself (cookie/PoW bypass) still stands; only the early-data is dropped.
920 let early_data_plaintext: Option<Vec<u8>> =
921 if self.early_data_enabled.load(Ordering::Relaxed) {
922 match (&resumed, &client_hello.early_data) {
923 (Some(t), Some(blob)) => {
924 decrypt_early_data(&t.secret, &client_hello.nonce, &t.rid, blob)
925 }
926 _ => None,
927 }
928 } else {
929 None
930 };
931 let early_data_accepted = early_data_plaintext.is_some();
932
933 // Hybrid Key Exchange (PFS preserved — a fresh KEM secret even on the
934 // 0-RTT path).
935 let (shared_secret, ciphertext) = match client_hello.client_key_package.encapsulate() {
936 // T5.1 — zeroize the transient KEM master on scope exit (it is copied
937 // into the now-zeroized `traffic_secret` + `CryptoState` below).
938 Ok((ss, ct)) => (zeroize::Zeroizing::new(ss), ct),
939 Err(e) => {
940 return self.fail_and_reinsert(
941 &resumed,
942 HandshakeResponse::Fail(HandshakeError::KemFailed(e.to_string())),
943 );
944 }
945 };
946
947 // Server-contributed 32-byte nonce (T4.3). Bound into the transcript signature so
948 // the server commits to a session-specific value beyond `session_id` + the client
949 // nonce. Replaces the former discarded ephemeral KEM key package (~1.1 KB).
950 let mut server_nonce = [0u8; 32];
951 if let Err(e) = getrandom::getrandom(&mut server_nonce) {
952 return self.fail_and_reinsert(
953 &resumed,
954 HandshakeResponse::Fail(HandshakeError::RngError(e.to_string())),
955 );
956 }
957
958 let session_id_bytes = derive_session_id(&shared_secret, &client_hello.nonce);
959 let session_id = SessionId::from_bytes(session_id_bytes);
960
961 // Sign the transcript. It embeds the WHOLE `ClientHello` (early-data
962 // ciphertext included) plus `PROTOCOL_VARIANT` — a tampered or stripped
963 // blob breaks the client-side signature check (Invariants 7, 10).
964 let transcript = HandshakeTranscript {
965 protocol_variant: PROTOCOL_VARIANT,
966 client_hello,
967 server_nonce: &server_nonce,
968 ciphertext: &ciphertext,
969 server_verify_key: &self.verifying_key,
970 session_id: &session_id_bytes,
971 early_data_accepted,
972 };
973 let transcript_hash = match compute_transcript_hash(&transcript) {
974 Ok(h) => h,
975 Err(e) => return self.fail_and_reinsert(&resumed, HandshakeResponse::Fail(e)),
976 };
977 let signature = self.signing_key.sign(&transcript_hash);
978
979 let server_hello = ServerHello {
980 server_nonce,
981 ciphertext,
982 server_verify_key: self.verifying_key.clone(),
983 signature,
984 session_id: session_id_bytes,
985 early_data_accepted,
986 };
987
988 // Build + wire the Session, derive the resumption secret, and stash a
989 // fresh one-shot ticket for a future resume / 0-RTT.
990 let session = match self.finalize_session(&shared_secret, session_id, session_id_bytes) {
991 Ok(s) => s,
992 Err(resp) => return self.fail_and_reinsert(&resumed, resp),
993 };
994
995 HandshakeResponse::Success(server_hello, session, early_data_plaintext)
996 }
997
998 /// The cookie / Proof-of-Work DoS gate. Returns `Err(response)` —
999 /// a ready-to-send `Retry` or `Fail` — when the client must not
1000 /// yet proceed; `Ok(())` when it has cleared the gate (or `bypass`
1001 /// was set by a valid one-shot resumption ticket).
1002 // `HandshakeResponse` is intentionally large — boxing it would add a
1003 // heap allocation on every call, penalising the hot non-error path.
1004 // The type is internal and lives only on the handshake stack, so the
1005 // size is acceptable.
1006 #[allow(clippy::result_large_err)]
1007 fn cookie_pow_gate(
1008 &self,
1009 client_hello: &ClientHello,
1010 difficulty: u8,
1011 client_ip: IpAddr,
1012 bypass: bool,
1013 ) -> Result<(), HandshakeResponse> {
1014 // Cookie freshness (Phase 1.10): `validate_cookie` accepts the current
1015 // bucket OR the immediately-previous bucket (5-minute buckets, so
1016 // 5-10 min effective validity). Comparisons are constant-time.
1017 let cookie_valid = match client_hello.cookie {
1018 Some(c) => match validate_cookie(&self.master_secret, client_ip, &c) {
1019 Ok(v) => v,
1020 Err(e) => return Err(HandshakeResponse::Fail(e)),
1021 },
1022 None => false,
1023 };
1024 // Pre-compute a fresh cookie to hand to the client on a retry.
1025 let expected_cookie = match generate_cookie(&self.master_secret, client_ip) {
1026 Ok(c) => c,
1027 Err(e) => return Err(HandshakeResponse::Fail(e)),
1028 };
1029
1030 let mut pow_valid = true;
1031 let mut challenge = None;
1032 if difficulty > 0 {
1033 // PoW verification (Phase 1.11): the derived hour-bucketed secret
1034 // rotates every `SECRET_ROTATION_SECONDS`. Accept either the
1035 // current or the previous hour's derivation so a client that
1036 // computed a solution just before the rotation boundary doesn't
1037 // have to redo the work.
1038 let cur_hour = match current_secret_hour() {
1039 Ok(h) => h,
1040 Err(e) => return Err(HandshakeResponse::Fail(e)),
1041 };
1042 let prev_hour = cur_hour.saturating_sub(1);
1043 let hours: &[u64] = if cur_hour == prev_hour {
1044 &[cur_hour]
1045 } else {
1046 &[cur_hour, prev_hour]
1047 };
1048
1049 if let Some(sol) = &client_hello.pow_solution {
1050 let mut any_valid = false;
1051 for &h in hours {
1052 let derived = match derive_session_secret_for_hour(&self.master_secret, h) {
1053 Ok(s) => s,
1054 Err(e) => return Err(HandshakeResponse::Fail(e)),
1055 };
1056 let challenge_ref = PoWChallenge {
1057 nonce: sol.nonce,
1058 difficulty,
1059 };
1060 if challenge_ref.verify(sol, client_ip.to_string().as_bytes(), &derived) {
1061 any_valid = true;
1062 break;
1063 }
1064 }
1065 pow_valid = any_valid;
1066 } else {
1067 pow_valid = false;
1068 let derived = match derive_session_secret_for_hour(&self.master_secret, cur_hour) {
1069 Ok(s) => s,
1070 Err(e) => return Err(HandshakeResponse::Fail(e)),
1071 };
1072 challenge = Some(PoWChallenge::new_stateless(
1073 difficulty,
1074 client_ip.to_string().as_bytes(),
1075 &derived,
1076 ));
1077 }
1078 }
1079
1080 if !bypass && (!cookie_valid || !pow_valid) {
1081 return Err(HandshakeResponse::Retry(HelloRetryRequest {
1082 challenge,
1083 cookie: if !cookie_valid {
1084 Some(expected_cookie)
1085 } else {
1086 None
1087 },
1088 }));
1089 }
1090 Ok(())
1091 }
1092
1093 /// Build the post-handshake `Session` from the negotiated
1094 /// `shared_secret`: derive the AEAD `CryptoState`, derive + install the
1095 /// resumption secret, and stash a resumption ticket in the cache.
1096 #[allow(clippy::result_large_err)]
1097 fn finalize_session(
1098 &self,
1099 shared_secret: &[u8; 32],
1100 session_id: SessionId,
1101 session_id_bytes: [u8; 32],
1102 ) -> Result<Session, HandshakeResponse> {
1103 let crypto = CryptoState::new(shared_secret, true)
1104 .map_err(|e| HandshakeResponse::Fail(HandshakeError::KemFailed(e.to_string())))?;
1105
1106 // is_server=true and traffic_secret=shared_secret seed the rekey
1107 // chain (Phase 1.5) so the server can later derive forward.
1108 let session = Session::from_derived(
1109 session_id,
1110 crypto,
1111 SchedulerMode::LowLatency,
1112 *shared_secret,
1113 true,
1114 );
1115
1116 // Derive resumption secret and stash a one-shot ticket so a
1117 // future ClientHello carrying this session id can skip
1118 // cookie/PoW and carry 0-RTT early-data.
1119 let mut resumption_secret = [0u8; 32];
1120 let hk = hkdf::Hkdf::<Sha256>::new(None, shared_secret);
1121 if hk
1122 .expand(b"phantom-resumption-secret-v1", &mut resumption_secret)
1123 .is_ok()
1124 {
1125 session.set_resumption_secret(resumption_secret);
1126 self.session_cache.lock().store(
1127 session_id_bytes,
1128 &resumption_secret,
1129 CipherSuite::Aes256Gcm,
1130 );
1131 }
1132 Ok(session)
1133 }
1134
1135 /// Re-insert a ticket consumed by a resume attempt that then failed
1136 /// (ZERORTT-2), preserving its original lifetime, and return the failure
1137 /// response unchanged. A no-op when `resumed` is `None`. This keeps a
1138 /// corrupted/forged resuming `ClientHello` from burning a victim's one-shot
1139 /// ticket: the ticket is consumed eagerly (race-free) after the binder check,
1140 /// and handed back here on every post-consume failure path.
1141 #[allow(clippy::result_large_err)]
1142 fn fail_and_reinsert(
1143 &self,
1144 resumed: &Option<ConsumedTicket>,
1145 resp: HandshakeResponse,
1146 ) -> HandshakeResponse {
1147 if let Some(t) = resumed {
1148 self.session_cache.lock().reinsert_with_expiry(
1149 t.rid,
1150 &t.secret,
1151 t.suite,
1152 t.created_at,
1153 t.expires_at,
1154 );
1155 }
1156 resp
1157 }
1158
1159 pub fn verifying_key(&self) -> &HybridVerifyingKey {
1160 &self.verifying_key
1161 }
1162
1163 /// Number of tickets currently held in the resumption cache.
1164 /// Exposed for metrics / tests; not on the hot path. Phase 4.1.
1165 pub fn session_cache_len(&self) -> usize {
1166 self.session_cache.lock().len()
1167 }
1168}
1169
1170/// Handshake Client State Machine
1171///
1172/// `kem_secret` and `signing_key` are already `ZeroizeOnDrop` in their own
1173/// types. The remaining sensitive field is `nonce`, which is zeroed via the
1174/// derived `ZeroizeOnDrop`. `early_data` is application plaintext queued
1175/// before the secure channel is up — it lives in user-controlled storage and
1176/// is moved out by `take_early_data`.
1177#[derive(ZeroizeOnDrop)]
1178pub struct HandshakeClient {
1179 // SAFETY: each inner type has its own ZeroizeOnDrop / Drop that zeroes
1180 // sensitive bytes. Skipping at this layer avoids the derive trying to call
1181 // `Zeroize::zeroize` (which the inner types don't implement directly).
1182 #[zeroize(skip)]
1183 kem_secret: HybridSecretKey,
1184 #[zeroize(skip)]
1185 kem_public: HybridKeyPackage,
1186 #[zeroize(skip)]
1187 #[allow(dead_code)]
1188 signing_key: HybridSigningKey,
1189 #[zeroize(skip)]
1190 verifying_key: HybridVerifyingKey,
1191 nonce: [u8; 32],
1192 #[zeroize(skip)]
1193 early_data: RwLock<Vec<Vec<u8>>>,
1194 #[zeroize(skip)]
1195 stage: RwLock<HandshakeStage>,
1196}
1197
1198impl HandshakeClient {
1199 /// Construct a client handshake state. Allocates an ephemeral hybrid KEM
1200 /// keypair, an ephemeral hybrid signing keypair, and a 32-byte client
1201 /// nonce. Returns `Err` if the OS RNG cannot be read.
1202 pub fn new() -> Result<Self, HandshakeError> {
1203 let (kem_secret, kem_public) = HybridSecretKey::generate();
1204 let (signing_key, verifying_key) = HybridSigningKey::generate();
1205 let mut nonce = [0u8; 32];
1206 getrandom::getrandom(&mut nonce).map_err(|e| HandshakeError::RngError(e.to_string()))?;
1207
1208 Ok(Self {
1209 kem_secret,
1210 kem_public,
1211 signing_key,
1212 verifying_key,
1213 nonce,
1214 early_data: RwLock::new(Vec::new()),
1215 stage: RwLock::new(HandshakeStage::Initial),
1216 })
1217 }
1218
1219 /// Build the default `ClientHello` — pinned [`PROTOCOL_VERSION`], no
1220 /// resumption, no 0-RTT early-data. Downgrade resistance comes from the
1221 /// transcript signature, which binds both `version` and the build-side
1222 /// [`PROTOCOL_VARIANT`]; a network rewrite of either aborts the handshake
1223 /// at the client-side signature check.
1224 pub fn create_client_hello(&self) -> ClientHello {
1225 ClientHello {
1226 client_key_package: self.kem_public.clone(),
1227 client_verify_key: self.verifying_key.clone(),
1228 nonce: self.nonce,
1229 version: PROTOCOL_VERSION,
1230 cookie: None,
1231 pow_solution: None,
1232 resume_session_id: None,
1233 resumption_binder: None,
1234 protocol_variant: PROTOCOL_VARIANT.to_vec(),
1235 early_data: None,
1236 }
1237 }
1238
1239 /// Build a `ClientHello` that resumes a prior session, optionally carrying
1240 /// 0-RTT `early_data`.
1241 ///
1242 /// `resume_session_id` and `resumption_secret` are the two halves of a
1243 /// prior session's `Session::resumption_hint()`. The server checks its
1244 /// session cache; a known, still-valid ticket bypasses the cookie/PoW DoS
1245 /// gate. When `early_data` is `Some`, it is sealed (AES-256-GCM) under a
1246 /// key derived from `(resumption_secret, self.nonce)` and placed in
1247 /// `ClientHello.early_data`; the server decrypts it with the matching key
1248 /// (best-effort — see [`HandshakeServer::process_client_hello`]). The
1249 /// whole hello, early-data included, is transcript-bound (Invariant 7).
1250 ///
1251 /// The caller MUST ensure `early_data.len() <= EARLY_DATA_MAX_LEN`;
1252 /// `PhantomSession::connect_with_resumption` enforces this and returns an
1253 /// error for oversized payloads.
1254 pub fn create_client_hello_with_resume(
1255 &self,
1256 resume_session_id: [u8; 32],
1257 resumption_secret: &[u8; 32],
1258 early_data: Option<&[u8]>,
1259 ) -> ClientHello {
1260 let sealed = early_data
1261 .and_then(|pt| seal_early_data(resumption_secret, &self.nonce, &resume_session_id, pt));
1262 // HS-03: prove possession of `resumption_secret` so a passive observer of
1263 // the cleartext `resume_session_id` cannot consume the server's ticket.
1264 let resumption_binder =
1265 derive_resumption_binder(resumption_secret, &resume_session_id, &self.nonce);
1266 ClientHello {
1267 client_key_package: self.kem_public.clone(),
1268 client_verify_key: self.verifying_key.clone(),
1269 nonce: self.nonce,
1270 version: PROTOCOL_VERSION,
1271 cookie: None,
1272 pow_solution: None,
1273 resume_session_id: Some(resume_session_id),
1274 resumption_binder: Some(resumption_binder),
1275 protocol_variant: PROTOCOL_VARIANT.to_vec(),
1276 early_data: sealed,
1277 }
1278 }
1279
1280 /// Verify a `ServerHello` against the `ClientHello` we sent and establish
1281 /// the client-side `Session`.
1282 ///
1283 /// Pinning is mandatory in production — `expected_server_key` is
1284 /// `Some(&key)` (Invariant 1). The signature is checked over the whole
1285 /// transcript, which embeds the entire `ClientHello` (early-data
1286 /// ciphertext included) and the build-side `PROTOCOL_VARIANT` (Invariants
1287 /// 7, 10). Returns the established `Session` and the 0-RTT verdict:
1288 /// `Some(true/false)` when the client sent early-data (accepted / rejected
1289 /// per `server_hello.early_data_accepted`), `None` when it sent none.
1290 #[tracing::instrument(
1291 name = "phantom.handshake.process_server_hello",
1292 skip_all,
1293 fields(
1294 pinned = expected_server_key.is_some(),
1295 ),
1296 )]
1297 pub fn process_server_hello(
1298 &self,
1299 client_hello: &ClientHello,
1300 server_hello: &ServerHello,
1301 expected_server_key: Option<&HybridVerifyingKey>,
1302 ) -> Result<(Session, Option<bool>), HandshakeError> {
1303 // 1. Verify Identity (server pinning — Invariant 1).
1304 if let Some(expected) = expected_server_key {
1305 if expected != &server_hello.server_verify_key {
1306 return Err(HandshakeError::ServerIdentityMismatch);
1307 }
1308 }
1309
1310 // 2. Verify Signature over the transcript. It binds the whole
1311 // ClientHello (incl. early-data) and PROTOCOL_VARIANT — a fips↔non-fips
1312 // mismatch, a downgraded `version`, or a tampered/stripped early-data
1313 // blob fails this check rather than landing a wrong secret (Invariants
1314 // 7, 10).
1315 let transcript = HandshakeTranscript {
1316 protocol_variant: PROTOCOL_VARIANT,
1317 client_hello,
1318 server_nonce: &server_hello.server_nonce,
1319 ciphertext: &server_hello.ciphertext,
1320 server_verify_key: &server_hello.server_verify_key,
1321 session_id: &server_hello.session_id,
1322 // H2: recompute with the RECEIVED verdict — a flipped bit makes this
1323 // hash diverge from what the server signed, so verify() below fails.
1324 early_data_accepted: server_hello.early_data_accepted,
1325 };
1326 let transcript_hash = compute_transcript_hash(&transcript)?;
1327 server_hello
1328 .server_verify_key
1329 .verify(&transcript_hash, &server_hello.signature)
1330 .map_err(|e| HandshakeError::KemFailed(format!("Signature check failed: {:?}", e)))?;
1331
1332 // 3. Decapsulate
1333 // T5.1 — zeroize the transient KEM master on scope exit.
1334 let shared_secret = zeroize::Zeroizing::new(
1335 self.kem_secret
1336 .decapsulate(&server_hello.ciphertext)
1337 .map_err(|e| HandshakeError::KemFailed(e.to_string()))?,
1338 );
1339
1340 // 4. Create Session
1341 let session_id = SessionId::from_bytes(server_hello.session_id);
1342 let crypto = CryptoState::new(&shared_secret, false)
1343 .map_err(|e| HandshakeError::KemFailed(e.to_string()))?;
1344
1345 // is_server=false and traffic_secret=shared_secret seed the rekey
1346 // chain (Phase 1.5) so the client can later derive forward in lock-
1347 // step with the server.
1348 let session = Session::from_derived(
1349 session_id,
1350 crypto,
1351 SchedulerMode::LowLatency,
1352 *shared_secret,
1353 false,
1354 );
1355
1356 // 5. Derive resumption secret (seeds the NEXT resume / 0-RTT).
1357 let mut resumption_secret = [0u8; 32];
1358 let hk = hkdf::Hkdf::<Sha256>::new(None, &shared_secret[..]);
1359 if hk
1360 .expand(b"phantom-resumption-secret-v1", &mut resumption_secret)
1361 .is_ok()
1362 {
1363 session.set_resumption_secret(resumption_secret);
1364 }
1365
1366 *self.stage.write() = HandshakeStage::Established;
1367
1368 // 0-RTT verdict: only meaningful when the client actually sent
1369 // early-data on this connect (`None` otherwise — resolved decision 1 /
1370 // Invariant 9).
1371 let early_data_verdict = client_hello
1372 .early_data
1373 .as_ref()
1374 .map(|_| server_hello.early_data_accepted);
1375 Ok((session, early_data_verdict))
1376 }
1377
1378 /// Queue a plaintext payload to be sent as early-data once the secure
1379 /// channel is up.
1380 ///
1381 /// NOTE: Early-data is currently queued at the API layer (see
1382 /// `PhantomSession::send_queue`) and the data-pump flushes it through the
1383 /// regular AEAD path after the handshake completes. This per-handshake
1384 /// buffer is reserved for the future 0-RTT path (Phase 4.1).
1385 pub fn queue_early_data(&self, data: Vec<u8>) {
1386 self.early_data.write().push(data);
1387 }
1388
1389 /// Drain the queued early-data buffer. See [`Self::queue_early_data`] — the
1390 /// production `send_queue` path is currently used instead; this hook is
1391 /// reserved for 0-RTT.
1392 #[allow(dead_code)]
1393 pub fn take_early_data(&self) -> Vec<Vec<u8>> {
1394 std::mem::take(&mut self.early_data.write())
1395 }
1396
1397 pub fn stage(&self) -> HandshakeStage {
1398 *self.stage.read()
1399 }
1400}
1401
1402/// Internal helper for session ID derivation
1403fn derive_session_id(shared_secret: &[u8; 32], nonce: &[u8; 32]) -> [u8; 32] {
1404 let mut hasher = Sha256::new();
1405 hasher.update(b"phantom-session-id-v1");
1406 hasher.update(shared_secret);
1407 hasher.update(nonce);
1408 hasher.finalize().into()
1409}
1410
1411/// Best-effort decryption of a 0-RTT early-data blob.
1412///
1413/// Both peers derive the AEAD `(key, nonce)` from the prior session's
1414/// `resumption_secret` and *this* connect's `client_nonce` via
1415/// [`derive_early_data_keying`]. AAD binds the blob to its context:
1416/// `resume_session_id || client_nonce`.
1417///
1418/// Returns `None` — early-data rejected, the handshake simply
1419/// continues as 1-RTT — when:
1420/// - the sealed blob exceeds the [`EARLY_DATA_MAX_LEN`] cap (checked
1421/// before any crypto work — anti-DoS), or
1422/// - the AEAD tag fails to verify (tampered / wrong key).
1423fn decrypt_early_data(
1424 resumption_secret: &[u8; 32],
1425 client_nonce: &[u8; 32],
1426 resume_session_id: &[u8; 32],
1427 sealed: &[u8],
1428) -> Option<Vec<u8>> {
1429 // A sealed blob is `plaintext || 16-byte GCM tag`. Reject anything
1430 // whose plaintext would exceed the cap before doing crypto work.
1431 if sealed.len() > EARLY_DATA_MAX_LEN + 16 {
1432 return None;
1433 }
1434 let (key, nonce) = derive_early_data_keying(resumption_secret, client_nonce);
1435 // Server is the responder for the one-directional early-data
1436 // channel: `with_suite_peer` swaps send/recv so its `recv_key`
1437 // matches the client's `send_key`.
1438 let aead = CryptoSession::with_suite_peer(&key, CipherSuite::Aes256Gcm).ok()?;
1439 let mut aad = [0u8; 64];
1440 aad[..32].copy_from_slice(resume_session_id);
1441 aad[32..].copy_from_slice(client_nonce);
1442 aead.decrypt_with_nonce(nonce, &aad, sealed).ok()
1443}
1444
1445/// Seal a 0-RTT early-data plaintext for transport inside a
1446/// `ClientHello.early_data`. Mirror of [`decrypt_early_data`].
1447///
1448/// The client is the *initiator* of the one-directional early-data
1449/// channel — `with_suite` (no key swap) so its `send_key` matches the
1450/// server's `recv_key`. AAD is `resume_session_id || client_nonce`,
1451/// identical to the server side.
1452///
1453/// Returns `None` only on the structurally-improbable AEAD-key-init
1454/// failure; the caller treats that as "no early-data" and the
1455/// handshake proceeds 1-RTT.
1456fn seal_early_data(
1457 resumption_secret: &[u8; 32],
1458 client_nonce: &[u8; 32],
1459 resume_session_id: &[u8; 32],
1460 plaintext: &[u8],
1461) -> Option<Vec<u8>> {
1462 let (key, nonce) = derive_early_data_keying(resumption_secret, client_nonce);
1463 let aead = CryptoSession::with_suite(&key, CipherSuite::Aes256Gcm).ok()?;
1464 let mut aad = [0u8; 64];
1465 aad[..32].copy_from_slice(resume_session_id);
1466 aad[32..].copy_from_slice(client_nonce);
1467 aead.encrypt_with_nonce(nonce, &aad, plaintext).ok()
1468}
1469
1470/// Bucket size in seconds for the rolling cookie salt.
1471///
1472/// Cookies are valid for the current bucket and the previous bucket — so the
1473/// effective validity window is between `COOKIE_BUCKET_SECONDS` and
1474/// `2 * COOKIE_BUCKET_SECONDS` depending on when within the bucket the cookie
1475/// was minted.
1476const COOKIE_BUCKET_SECONDS: u64 = 300;
1477
1478/// Rotation interval in seconds for the derived per-hour PoW/cookie secret.
1479/// The master_secret in `HandshakeServer` only rotates on process restart;
1480/// this constant controls the cadence of the derived sub-secret.
1481const SECRET_ROTATION_SECONDS: u64 = 3600;
1482
1483fn current_cookie_bucket() -> Result<u64, HandshakeError> {
1484 Ok(SystemTime::now()
1485 .duration_since(UNIX_EPOCH)
1486 .map_err(|_| HandshakeError::ClockBackwards)?
1487 .as_secs()
1488 / COOKIE_BUCKET_SECONDS)
1489}
1490
1491fn current_secret_hour() -> Result<u64, HandshakeError> {
1492 Ok(SystemTime::now()
1493 .duration_since(UNIX_EPOCH)
1494 .map_err(|_| HandshakeError::ClockBackwards)?
1495 .as_secs()
1496 / SECRET_ROTATION_SECONDS)
1497}
1498
1499/// HKDF-derive a fresh sub-secret from `master` for the given hour bucket.
1500/// The same master + hour always produces the same derived secret, so this
1501/// is just a deterministic function of (master, hour) — no internal state.
1502pub(crate) fn derive_session_secret_for_hour(
1503 master: &[u8; 32],
1504 hour: u64,
1505) -> Result<[u8; 32], HandshakeError> {
1506 let hk = hkdf::Hkdf::<Sha256>::new(None, master);
1507 let mut out = [0u8; 32];
1508 let mut info = Vec::with_capacity(16 + 8);
1509 info.extend_from_slice(b"phantom-pow-cookie-v1");
1510 info.extend_from_slice(&hour.to_be_bytes());
1511 hk.expand(&info, &mut out)
1512 .map_err(|e| HandshakeError::InternalError(format!("HKDF expand: {}", e)))?;
1513 Ok(out)
1514}
1515
1516fn generate_cookie_for_bucket(
1517 derived_secret: &[u8; 32],
1518 ip: IpAddr,
1519 bucket: u64,
1520) -> Result<[u8; 32], HandshakeError> {
1521 let mut mac = Hmac::<Sha256>::new_from_slice(derived_secret)
1522 .map_err(|e| HandshakeError::InternalError(format!("HMAC init: {}", e)))?;
1523 mac.update(ip.to_string().as_bytes());
1524 mac.update(&bucket.to_be_bytes());
1525 let mut result = [0u8; 32];
1526 result.copy_from_slice(&mac.finalize().into_bytes());
1527 Ok(result)
1528}
1529
1530fn generate_cookie(master: &[u8; 32], ip: IpAddr) -> Result<[u8; 32], HandshakeError> {
1531 let hour = current_secret_hour()?;
1532 let derived = derive_session_secret_for_hour(master, hour)?;
1533 generate_cookie_for_bucket(&derived, ip, current_cookie_bucket()?)
1534}
1535
1536/// Validate a client-supplied cookie against the 2x2 combinations of
1537/// (current/previous hour) × (current/previous bucket). All comparisons are
1538/// constant-time via [`subtle::ConstantTimeEq`], and the accept signal is
1539/// accumulated as a [`subtle::Choice`] so the function never branches on
1540/// any individual comparison's outcome.
1541fn validate_cookie(
1542 master: &[u8; 32],
1543 ip: IpAddr,
1544 cookie: &[u8; 32],
1545) -> Result<bool, HandshakeError> {
1546 let bucket = current_cookie_bucket()?;
1547 let hour = current_secret_hour()?;
1548 let prev_bucket = bucket.saturating_sub(1);
1549 let prev_hour = hour.saturating_sub(1);
1550
1551 let bucket_candidates: [u64; 2] = if bucket == prev_bucket {
1552 [bucket, bucket]
1553 } else {
1554 [bucket, prev_bucket]
1555 };
1556 let hour_candidates: [u64; 2] = if hour == prev_hour {
1557 [hour, hour]
1558 } else {
1559 [hour, prev_hour]
1560 };
1561
1562 let mut accept = subtle::Choice::from(0u8);
1563 for h in hour_candidates {
1564 let derived = derive_session_secret_for_hour(master, h)?;
1565 for b in bucket_candidates {
1566 let expected = generate_cookie_for_bucket(&derived, ip, b)?;
1567 accept |= cookie.ct_eq(&expected);
1568 }
1569 }
1570 Ok(bool::from(accept))
1571}
1572
1573#[derive(Debug, Clone, thiserror::Error)]
1574pub enum HandshakeError {
1575 #[error("Unsupported version")]
1576 UnsupportedVersion,
1577 #[error("KEM failed: {0}")]
1578 KemFailed(String),
1579 #[error("Server identity mismatch")]
1580 ServerIdentityMismatch,
1581 #[error("RNG error: {0}")]
1582 RngError(String),
1583 #[error("serialization error during handshake: {0}")]
1584 SerializationError(String),
1585 #[error("system clock is before UNIX_EPOCH")]
1586 ClockBackwards,
1587 #[error("internal handshake error: {0}")]
1588 InternalError(String),
1589 /// The peer advertised a build-side [`PROTOCOL_VARIANT`] that does
1590 /// not match this build's. Today: a fips client meeting a non-fips
1591 /// server, or vice versa.
1592 #[error("protocol variant mismatch (expected {expected:?}, received {received:?})")]
1593 ProtocolVariantMismatch {
1594 expected: Vec<u8>,
1595 received: Vec<u8>,
1596 },
1597}
1598
1599impl From<HandshakeError> for CoreError {
1600 fn from(err: HandshakeError) -> Self {
1601 CoreError::InternalError(err.to_string())
1602 }
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607 use super::*;
1608
1609 /// Byte-exact freeze of the handshake transcript hash (Phase 6).
1610 ///
1611 /// Pins `SHA256(borsh(HandshakeTranscript))` over a fully-deterministic
1612 /// `ClientHello` + server fields. Unlike the public wire-codec vectors in
1613 /// `core/tests/wire_vectors.rs`, this exercises the *real* private
1614 /// `HandshakeTranscript` and `compute_transcript_hash`, so a reorder of the
1615 /// transcript fields or any change to the hash construction — the signing
1616 /// input, Invariants 7 & 10 — fails here. The crypto material is
1617 /// deterministic filler of the real field lengths; the hash needs no live
1618 /// keys. Default (non-fips) build only (the fips transcript embeds a
1619 /// different `PROTOCOL_VARIANT` and 65-byte classical key).
1620 ///
1621 /// Regenerate alongside the wire vectors with
1622 /// `PHANTOM_REGEN_WIRE_VECTORS=1 cargo test --manifest-path core/Cargo.toml --lib`.
1623 #[cfg(not(feature = "fips"))]
1624 #[test]
1625 fn transcript_hash_wire_vector() {
1626 fn pat(seed: u8, n: usize) -> Vec<u8> {
1627 (0..n).map(|i| seed.wrapping_add(i as u8)).collect()
1628 }
1629 fn arr32(seed: u8) -> [u8; 32] {
1630 pat(seed, 32).try_into().expect("pat(seed, 32) is 32 bytes")
1631 }
1632
1633 // Same deterministic filler as the `client_hello_full` / `server_hello`
1634 // vectors so the three freezes describe one consistent handshake.
1635 let key_package = HybridKeyPackage {
1636 classical_pk: arr32(0x10),
1637 ml_kem_pk: pat(0x20, 1184),
1638 };
1639 let verify_key = HybridVerifyingKey {
1640 ed25519_pk: arr32(0x50),
1641 ml_dsa_pk: pat(0x60, 1952),
1642 };
1643 let client_hello = ClientHello {
1644 client_key_package: key_package.clone(),
1645 client_verify_key: verify_key.clone(),
1646 nonce: arr32(0xA0),
1647 version: PROTOCOL_VERSION,
1648 cookie: Some(arr32(0xB0)),
1649 pow_solution: Some(PoWSolution {
1650 nonce: arr32(0x90),
1651 solution: 0x0123_4567_89AB_CDEF,
1652 }),
1653 resume_session_id: Some(arr32(0xC0)),
1654 resumption_binder: Some(arr32(0xC8)),
1655 protocol_variant: PROTOCOL_VARIANT.to_vec(),
1656 early_data: Some(pat(0xD0, 48)),
1657 };
1658 let ciphertext = HybridCiphertext {
1659 classical_pk: arr32(0x30),
1660 ml_kem_ct: pat(0x40, 1088),
1661 };
1662 let session_id = arr32(0xE0);
1663
1664 let transcript = HandshakeTranscript {
1665 protocol_variant: PROTOCOL_VARIANT,
1666 client_hello: &client_hello,
1667 server_nonce: &arr32(0x70),
1668 ciphertext: &ciphertext,
1669 server_verify_key: &verify_key,
1670 session_id: &session_id,
1671 early_data_accepted: true,
1672 };
1673 let hash = compute_transcript_hash(&transcript).expect("transcript hash");
1674
1675 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1676 .join("tests/wire_vectors/transcript_hash.bin");
1677 if std::env::var_os("PHANTOM_REGEN_WIRE_VECTORS").is_some() {
1678 std::fs::create_dir_all(path.parent().expect("fixtures dir parent"))
1679 .expect("create wire_vectors dir");
1680 std::fs::write(&path, hash).expect("write transcript_hash.bin");
1681 return;
1682 }
1683 let expected = std::fs::read(&path)
1684 .expect("read transcript_hash.bin; regenerate with PHANTOM_REGEN_WIRE_VECTORS=1");
1685 assert_eq!(
1686 hash.as_slice(),
1687 expected.as_slice(),
1688 "handshake transcript hash changed — the signing input (Invariants 7 & 10) is \
1689 wire-breaking. If intentional, bump PROTOCOL_VERSION and regenerate."
1690 );
1691 }
1692
1693 /// A `ClientHello` advertising a foreign `PROTOCOL_VARIANT`
1694 /// (simulating a fips/non-fips cross-mode connect) is rejected by
1695 /// the server with [`HandshakeError::ProtocolVariantMismatch`]
1696 /// before any KEM / signature work is done.
1697 #[tokio::test]
1698 async fn protocol_variant_mismatch_rejected() {
1699 let server = HandshakeServer::new().expect("HandshakeServer::new");
1700 let client = HandshakeClient::new().expect("HandshakeClient::new");
1701 let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1702
1703 let mut hello = client.create_client_hello();
1704 // Pretend the peer was compiled with a different feature set.
1705 hello.protocol_variant = b"phantom-some-other-mode-1".to_vec();
1706
1707 let response = server.process_client_hello(&hello, 0, client_ip);
1708 match response {
1709 HandshakeResponse::Fail(HandshakeError::ProtocolVariantMismatch {
1710 expected,
1711 received,
1712 }) => {
1713 assert_eq!(expected, PROTOCOL_VARIANT);
1714 assert_eq!(received, b"phantom-some-other-mode-1");
1715 }
1716 other => panic!("expected ProtocolVariantMismatch, got {other:?}"),
1717 }
1718 }
1719
1720 /// H9 forward-compat: a `ClientHello` advertising a `version` the server
1721 /// does not speak is answered with a typed [`HandshakeResponse::Reject`]
1722 /// (carrying the server's supported version), not a silent drop / generic
1723 /// `Fail`. The reject is produced before any KEM / signature work.
1724 #[tokio::test]
1725 async fn unsupported_version_yields_typed_reject() {
1726 let server = HandshakeServer::new().expect("HandshakeServer::new");
1727 let client = HandshakeClient::new().expect("HandshakeClient::new");
1728 let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1729
1730 let mut hello = client.create_client_hello();
1731 // A future client speaking a version this build doesn't know.
1732 hello.version = PROTOCOL_VERSION.wrapping_add(7);
1733
1734 match server.process_client_hello(&hello, 0, client_ip) {
1735 HandshakeResponse::Reject(reject) => {
1736 assert!(reject.has_marker(), "reject must carry the marker");
1737 assert_eq!(reject.code, REJECT_UNSUPPORTED_VERSION);
1738 assert_eq!(reject.supported_version, PROTOCOL_VERSION);
1739 }
1740 other => panic!("expected Reject, got {other:?}"),
1741 }
1742 }
1743
1744 /// T4.4: a server reply carries a leading discriminant byte, so the client dispatches
1745 /// the three kinds explicitly instead of trial-deserializing by size. Round-trip each
1746 /// kind, assert the discriminant byte, and reject empty / unknown-kind inputs (never a
1747 /// silent misparse).
1748 #[test]
1749 fn server_reply_discriminant_dispatches_explicitly() {
1750 // Reject (kind 2).
1751 let reject = ServerReject::unsupported_version();
1752 let wire = ServerReply::Reject(reject.clone())
1753 .to_wire()
1754 .expect("frame reject");
1755 assert_eq!(wire[0], 2, "reject discriminant byte");
1756 assert!(matches!(
1757 ServerReply::from_wire(&wire),
1758 Ok(ServerReply::Reject(r)) if r == reject
1759 ));
1760
1761 // Retry (kind 1).
1762 let hrr = HelloRetryRequest {
1763 challenge: None,
1764 cookie: Some([7u8; 32]),
1765 };
1766 let wire = ServerReply::Retry(hrr.clone())
1767 .to_wire()
1768 .expect("frame retry");
1769 assert_eq!(wire[0], 1, "retry discriminant byte");
1770 assert!(matches!(
1771 ServerReply::from_wire(&wire),
1772 Ok(ServerReply::Retry(r)) if r.cookie == hrr.cookie && r.challenge.is_none()
1773 ));
1774
1775 // Unknown discriminant and empty input are errors, not silent misparses.
1776 assert!(
1777 ServerReply::from_wire(&[0xFF, 0x00]).is_err(),
1778 "unknown kind rejected"
1779 );
1780 assert!(ServerReply::from_wire(&[]).is_err(), "empty reply rejected");
1781 }
1782
1783 /// The reject frame survives a borsh round-trip and is shape-distinct from
1784 /// a `HelloRetryRequest`: even though reply *kind* is now dispatched by the
1785 /// [`ServerReply`] discriminant byte, each body must still fail to decode as
1786 /// the other type — a reject must not be mistaken for a retry, nor vice versa.
1787 #[test]
1788 fn server_reject_roundtrips_and_is_shape_distinct() {
1789 let reject = ServerReject::unsupported_version();
1790 let bytes = borsh::to_vec(&reject).expect("encode reject");
1791 let decoded: ServerReject = borsh::from_slice(&bytes).expect("decode reject");
1792 assert_eq!(decoded, reject);
1793 assert!(decoded.has_marker());
1794
1795 // A (None, None) HelloRetryRequest must not decode as a reject…
1796 let hrr = HelloRetryRequest {
1797 challenge: None,
1798 cookie: None,
1799 };
1800 let hrr_bytes = borsh::to_vec(&hrr).expect("encode hrr");
1801 assert!(
1802 borsh::from_slice::<ServerReject>(&hrr_bytes).is_err(),
1803 "a HelloRetryRequest must not parse as a ServerReject"
1804 );
1805 // …and a reject must not decode as a HelloRetryRequest.
1806 assert!(
1807 borsh::from_slice::<HelloRetryRequest>(&bytes).is_err(),
1808 "a ServerReject must not parse as a HelloRetryRequest"
1809 );
1810 }
1811
1812 /// Tampering with the cleartext `protocol_variant` to match the
1813 /// server's value (an MITM bypass attempt) is caught by the
1814 /// transcript signature: the transcript still binds the *real*
1815 /// build-side `PROTOCOL_VARIANT` on each side, so a mixed-mode
1816 /// signature does not verify. This test exercises the matching
1817 /// path on the same build (cannot actually run mixed-mode in a
1818 /// single binary) — we just confirm a normal handshake works
1819 /// with the variant intact.
1820 #[tokio::test]
1821 async fn handshake_succeeds_with_matching_protocol_variant() {
1822 let server = HandshakeServer::new().expect("HandshakeServer::new");
1823 let client = HandshakeClient::new().expect("HandshakeClient::new");
1824 let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1825 let hello = client.create_client_hello();
1826 assert_eq!(hello.protocol_variant, PROTOCOL_VARIANT);
1827 // First round: server demands cookie.
1828 let response = server.process_client_hello(&hello, 0, client_ip);
1829 let cookie = match response {
1830 HandshakeResponse::Retry(r) => r.cookie.expect("cookie"),
1831 other => panic!("expected retry, got {other:?}"),
1832 };
1833 let mut hello_retry = hello.clone();
1834 hello_retry.cookie = Some(cookie);
1835 match server.process_client_hello(&hello_retry, 0, client_ip) {
1836 HandshakeResponse::Success(..) => {}
1837 other => panic!("expected success, got {other:?}"),
1838 }
1839 }
1840
1841 /// T4.3: `server_key_package` (a full ~1184 B ML-KEM key package whose KEM secret
1842 /// was discarded) is replaced by a 32-byte `server_nonce`. Its sole purpose is to be
1843 /// a server-contributed, transcript-bound, session-specific value — so flipping it on
1844 /// the wire must break the client's transcript-signature check (Invariants 7/10),
1845 /// exactly as the discarded key package did. The positive control rules out a broken
1846 /// setup masking the negative assertion.
1847 #[tokio::test]
1848 async fn server_nonce_is_transcript_bound() {
1849 let server = HandshakeServer::new().expect("HandshakeServer::new");
1850 let client = HandshakeClient::new().expect("HandshakeClient::new");
1851 let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1852
1853 let hello = client.create_client_hello();
1854 let cookie = match server.process_client_hello(&hello, 0, client_ip) {
1855 HandshakeResponse::Retry(r) => r.cookie.expect("cookie"),
1856 _ => panic!("expected retry"),
1857 };
1858 let mut hello_retry = hello.clone();
1859 hello_retry.cookie = Some(cookie);
1860 let server_hello = match server.process_client_hello(&hello_retry, 0, client_ip) {
1861 HandshakeResponse::Success(h, _s, _) => h,
1862 _ => panic!("expected success"),
1863 };
1864
1865 // Positive control: the untampered `server_nonce` verifies + the handshake completes.
1866 assert!(
1867 client
1868 .process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
1869 .is_ok(),
1870 "untampered server_nonce must verify"
1871 );
1872
1873 // Negative: a flipped `server_nonce` byte makes the recomputed transcript hash
1874 // diverge from what the server signed → signature check fails (before decapsulate).
1875 let mut tampered = server_hello.clone();
1876 tampered.server_nonce[0] ^= 0xFF;
1877 assert!(
1878 client
1879 .process_server_hello(&hello_retry, &tampered, Some(server.verifying_key()))
1880 .is_err(),
1881 "a flipped server_nonce byte must fail the transcript-signature check"
1882 );
1883 }
1884
1885 #[tokio::test]
1886 async fn test_unified_handshake() {
1887 let server = HandshakeServer::new().expect("HandshakeServer::new");
1888 let client = HandshakeClient::new().expect("HandshakeClient::new");
1889 let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1890
1891 // 1. Initial Hello
1892 let hello = client.create_client_hello();
1893
1894 // 2. Server Retry (Cookie)
1895 let response = server.process_client_hello(&hello, 0, client_ip);
1896 let cookie = match response {
1897 HandshakeResponse::Retry(r) => r.cookie.unwrap(),
1898 _ => panic!("Expected retry"),
1899 };
1900
1901 // 3. Retry with Cookie
1902 let mut hello_retry = hello.clone();
1903 hello_retry.cookie = Some(cookie);
1904 let response = server.process_client_hello(&hello_retry, 0, client_ip);
1905
1906 let (server_hello, _server_session) = match response {
1907 HandshakeResponse::Success(h, s, _) => (h, s),
1908 _ => panic!("Expected success"),
1909 };
1910
1911 // 4. Client Process
1912 let _client_session = client
1913 .process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
1914 .unwrap();
1915 assert_eq!(*client.stage.read(), HandshakeStage::Established);
1916 }
1917
1918 /// Phase 4.1 — after a successful handshake, the server caches a
1919 /// ticket keyed on the negotiated session id, and the resulting
1920 /// `Session` exposes a `resumption_hint` so the client can store
1921 /// it for a future connect.
1922 #[tokio::test]
1923 async fn first_handshake_caches_ticket_and_exposes_hint() {
1924 let server = HandshakeServer::new().expect("HandshakeServer::new");
1925 let client = HandshakeClient::new().expect("HandshakeClient::new");
1926 let client_ip = "127.0.0.1".parse().unwrap();
1927
1928 let hello = client.create_client_hello();
1929 let cookie = match server.process_client_hello(&hello, 0, client_ip) {
1930 HandshakeResponse::Retry(r) => r.cookie.unwrap(),
1931 _ => panic!("expected retry"),
1932 };
1933 let mut hello_retry = hello.clone();
1934 hello_retry.cookie = Some(cookie);
1935 let (server_hello, server_session) =
1936 match server.process_client_hello(&hello_retry, 0, client_ip) {
1937 HandshakeResponse::Success(h, s, _) => (h, s),
1938 _ => panic!("expected success"),
1939 };
1940 let (client_session, _) = client
1941 .process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
1942 .unwrap();
1943
1944 // Server now has exactly one ticket.
1945 assert_eq!(server.session_cache_len(), 1);
1946 // Both sides expose a `resumption_hint`. The session id and
1947 // resumption secret match between client and server.
1948 let s_hint = server_session.resumption_hint().expect("server hint");
1949 let c_hint = client_session.resumption_hint().expect("client hint");
1950 assert_eq!(s_hint.0, c_hint.0, "session id matches across sides");
1951 assert_eq!(s_hint.1, c_hint.1, "resumption secret matches");
1952 }
1953
1954 /// Phase 4.1 — a ClientHello carrying a cached `resume_session_id`
1955 /// bypasses the cookie/PoW DoS gate (it goes straight to success
1956 /// on the first call, with no Retry). The full KEM still runs so
1957 /// PFS is preserved.
1958 #[tokio::test]
1959 async fn cached_resume_session_id_skips_cookie_and_pow() {
1960 let server = HandshakeServer::new().expect("HandshakeServer::new");
1961 let client_ip = "127.0.0.1".parse().unwrap();
1962
1963 // Drive a full handshake to populate the cache.
1964 let first_client = HandshakeClient::new().unwrap();
1965 let first_hello = first_client.create_client_hello();
1966 let cookie = match server.process_client_hello(&first_hello, 0, client_ip) {
1967 HandshakeResponse::Retry(r) => r.cookie.unwrap(),
1968 _ => panic!("expected retry"),
1969 };
1970 let mut hello_retry = first_hello.clone();
1971 hello_retry.cookie = Some(cookie);
1972 let (_first_server_hello, first_server_session) =
1973 match server.process_client_hello(&hello_retry, 0, client_ip) {
1974 HandshakeResponse::Success(h, s, _) => (h, s),
1975 _ => panic!("expected success"),
1976 };
1977 let (resume_id, resume_secret) = first_server_session.resumption_hint().unwrap();
1978
1979 // Second client offers the resume_session_id WITHOUT a cookie.
1980 // Server should accept immediately (no Retry).
1981 let second_client = HandshakeClient::new().unwrap();
1982 let resume_hello =
1983 second_client.create_client_hello_with_resume(resume_id, &resume_secret, None);
1984 match server.process_client_hello(&resume_hello, 0, client_ip) {
1985 HandshakeResponse::Success(..) => {} // expected
1986 HandshakeResponse::Retry(_) => {
1987 panic!("resume_session_id should bypass cookie/PoW gate")
1988 }
1989 HandshakeResponse::Reject(r) => panic!("unexpected reject: {:?}", r),
1990 HandshakeResponse::Fail(e) => panic!("unexpected failure: {:?}", e),
1991 }
1992 }
1993
1994 /// Phase 4.1 — unknown `resume_session_id` does NOT bypass cookie.
1995 /// The server simply ignores the unknown id and falls through to
1996 /// the normal cookie/PoW path.
1997 #[tokio::test]
1998 async fn unknown_resume_session_id_does_not_bypass_cookie() {
1999 let server = HandshakeServer::new().unwrap();
2000 let client = HandshakeClient::new().unwrap();
2001 let client_ip = "127.0.0.1".parse().unwrap();
2002
2003 // An id the server has never seen.
2004 let bogus_id = [0xFFu8; 32];
2005 let hello = client.create_client_hello_with_resume(bogus_id, &[0u8; 32], None);
2006 match server.process_client_hello(&hello, 0, client_ip) {
2007 HandshakeResponse::Retry(_) => {} // expected — normal cookie flow
2008 other => panic!(
2009 "expected Retry for unknown resume id, got {:?}",
2010 matches!(other, HandshakeResponse::Success(..)),
2011 ),
2012 }
2013 }
2014
2015 // ── 0-RTT early-data ──
2016
2017 /// Drive a full handshake and return the resumption hint the server
2018 /// minted for it — the `(session_id, resumption_secret)` a resuming
2019 /// client needs.
2020 fn first_handshake_for_hint(
2021 server: &HandshakeServer,
2022 client_ip: std::net::IpAddr,
2023 ) -> ([u8; 32], [u8; 32]) {
2024 let client = HandshakeClient::new().unwrap();
2025 let hello = client.create_client_hello();
2026 let cookie = match server.process_client_hello(&hello, 0, client_ip) {
2027 HandshakeResponse::Retry(r) => r.cookie.unwrap(),
2028 _ => panic!("expected retry"),
2029 };
2030 let mut retry = hello.clone();
2031 retry.cookie = Some(cookie);
2032 match server.process_client_hello(&retry, 0, client_ip) {
2033 HandshakeResponse::Success(_, session, _) => session.resumption_hint().unwrap(),
2034 _ => panic!("expected success"),
2035 }
2036 }
2037
2038 #[tokio::test]
2039 async fn early_data_round_trip() {
2040 let server = HandshakeServer::new().unwrap();
2041 let client_ip = "127.0.0.1".parse().unwrap();
2042 let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
2043
2044 // Second connect: resume + a 0-RTT early-data payload folded into the
2045 // single ClientHello.
2046 let client = HandshakeClient::new().unwrap();
2047 let early_payload = b"zero-rtt application bytes";
2048 let hello =
2049 client.create_client_hello_with_resume(resume_id, &resume_secret, Some(early_payload));
2050
2051 match server.process_client_hello(&hello, 0, client_ip) {
2052 HandshakeResponse::Success(sh, _session, early_data) => {
2053 assert!(sh.early_data_accepted, "server accepted the early-data");
2054 assert_eq!(
2055 early_data.as_deref(),
2056 Some(&early_payload[..]),
2057 "server decrypted the exact payload the client sealed"
2058 );
2059 // The client verifies the ServerHello and learns the same
2060 // verdict.
2061 let (_session, accepted) = client
2062 .process_server_hello(&hello, &sh, Some(server.verifying_key()))
2063 .expect("client verifies the ServerHello");
2064 assert_eq!(accepted, Some(true), "client sees early-data accepted");
2065 }
2066 other => panic!(
2067 "expected Success with accepted early-data, got {}",
2068 match other {
2069 HandshakeResponse::Retry(_) => "Retry",
2070 HandshakeResponse::Reject(_) => "Reject",
2071 HandshakeResponse::Fail(_) => "Fail",
2072 HandshakeResponse::Success(..) => unreachable!(),
2073 }
2074 ),
2075 }
2076 }
2077
2078 #[tokio::test]
2079 async fn oversized_early_data_rejected_but_handshake_succeeds() {
2080 let server = HandshakeServer::new().unwrap();
2081 let client_ip = "127.0.0.1".parse().unwrap();
2082 let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
2083
2084 // A blob whose sealed length exceeds EARLY_DATA_MAX_LEN + tag.
2085 let huge = vec![0u8; EARLY_DATA_MAX_LEN + 1];
2086 let client = HandshakeClient::new().unwrap();
2087 let hello = client.create_client_hello_with_resume(resume_id, &resume_secret, Some(&huge));
2088
2089 match server.process_client_hello(&hello, 0, client_ip) {
2090 HandshakeResponse::Success(sh, _session, early_data) => {
2091 assert!(!sh.early_data_accepted, "oversized blob rejected");
2092 assert!(early_data.is_none(), "no plaintext surfaces");
2093 }
2094 _ => panic!("handshake must still succeed as 1-RTT"),
2095 }
2096 }
2097
2098 #[tokio::test]
2099 async fn corrupted_early_data_rejected_but_handshake_succeeds() {
2100 let server = HandshakeServer::new().unwrap();
2101 let client_ip = "127.0.0.1".parse().unwrap();
2102 let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
2103
2104 // Build a resume ClientHello, then replace the sealed blob with
2105 // in-range garbage — AEAD verification must fail.
2106 let client = HandshakeClient::new().unwrap();
2107 let mut hello = client.create_client_hello_with_resume(resume_id, &resume_secret, None);
2108 hello.early_data = Some(vec![0xFFu8; 128]);
2109
2110 match server.process_client_hello(&hello, 0, client_ip) {
2111 HandshakeResponse::Success(sh, _session, early_data) => {
2112 assert!(!sh.early_data_accepted, "AEAD failure → rejected");
2113 assert!(early_data.is_none());
2114 }
2115 _ => panic!("handshake must still succeed as 1-RTT"),
2116 }
2117 }
2118
2119 #[tokio::test]
2120 async fn unknown_ticket_with_early_data_falls_back_to_cookie_retry() {
2121 // A ClientHello whose resume_session_id the server has never seen
2122 // gets no cookie/PoW bypass — and with no cookie attached, the server
2123 // demands one via Retry. The undecryptable early-data is ignored.
2124 let server = HandshakeServer::new().unwrap();
2125 let client_ip = "127.0.0.1".parse().unwrap();
2126 let client = HandshakeClient::new().unwrap();
2127 let hello = client.create_client_hello_with_resume([0xAB; 32], &[0xCD; 32], Some(b"hi"));
2128 assert!(
2129 matches!(
2130 server.process_client_hello(&hello, 0, client_ip),
2131 HandshakeResponse::Retry(_)
2132 ),
2133 "unknown ticket → no bypass → cookie Retry"
2134 );
2135 }
2136
2137 /// **DOS-2.** The `HandshakeServer` wires per-IP reputation: a clean IP (and
2138 /// a ticket holder) adds no PoW difficulty, repeated violations escalate it,
2139 /// and a successful-handshake reset clears it.
2140 #[test]
2141 fn reputation_wiring_escalates_and_resets_per_ip() {
2142 let server = HandshakeServer::new().unwrap();
2143 let ip: std::net::IpAddr = "203.0.113.7".parse().unwrap();
2144 assert_eq!(
2145 server.reputation_difficulty(ip, false),
2146 0,
2147 "clean IP adds nothing"
2148 );
2149 assert_eq!(
2150 server.reputation_difficulty(ip, true),
2151 0,
2152 "ticket holder skips PoW"
2153 );
2154 server.record_violation(ip);
2155 let d1 = server.reputation_difficulty(ip, false);
2156 assert!(
2157 d1 >= 8,
2158 "a violation escalates the per-IP difficulty, got {d1}"
2159 );
2160 server.record_violation(ip);
2161 assert!(
2162 server.reputation_difficulty(ip, false) >= d1,
2163 "more violations escalate further"
2164 );
2165 server.reset_violations(ip);
2166 assert_eq!(
2167 server.reputation_difficulty(ip, false),
2168 0,
2169 "reset clears it"
2170 );
2171 }
2172
2173 /// M-7: `client_hello_lengths_within_bounds` must accept a real first-flight ClientHello
2174 /// but reject a frame whose `ml_kem_pk` length prefix is forged to a huge value — so a
2175 /// malformed Initial is dropped before `borsh::from_slice` performs its
2176 /// `vec![0u8; len.min(1 MiB)]` eager allocation (the ~45-byte → 1 MiB amplifier on the
2177 /// UDP demux / TCP handshake recv).
2178 #[test]
2179 fn client_hello_length_validator_rejects_a_forged_vector_length() {
2180 let hello = HandshakeClient::new()
2181 .expect("client")
2182 .create_client_hello();
2183 let bytes = borsh::to_vec(&hello).expect("serialize");
2184 assert!(
2185 client_hello_lengths_within_bounds(&bytes),
2186 "a real ClientHello must pass the structural length pre-check"
2187 );
2188
2189 // Forge the ml_kem_pk length prefix (u32 LE at offset CLASSICAL_PK_BYTES) to 0xFFFFFFFF.
2190 let mut forged = bytes.clone();
2191 let off = crate::crypto::hybrid_kem::CLASSICAL_PK_BYTES;
2192 forged[off..off + 4].copy_from_slice(&u32::MAX.to_le_bytes());
2193 assert!(
2194 !client_hello_lengths_within_bounds(&forged),
2195 "a forged ml_kem_pk length must be rejected before borsh allocates"
2196 );
2197
2198 // Short garbage / empty frames (what a spoofed Initial carries) are rejected.
2199 assert!(!client_hello_lengths_within_bounds(b"not-a-clienthello"));
2200 assert!(!client_hello_lengths_within_bounds(&[]));
2201 }
2202
2203 /// M-5 (audit 2026-06-11): the per-IP PoW-difficulty reduction for "ticket holders" must
2204 /// key on a VALID resume — a cached ticket whose binder verifies — not on mere presence of
2205 /// a `resume_session_id`. Otherwise a flagged abuser attaches 32 random bytes and pays zero
2206 /// PoW, nullifying the reputation escalation (DOS-2). `has_valid_resume` is the gate.
2207 #[test]
2208 fn has_valid_resume_requires_a_real_ticket_and_binder() {
2209 let server = HandshakeServer::new().expect("server");
2210 let server_pk = server.verifying_key().clone();
2211 let client = HandshakeClient::new().expect("client");
2212 let ip: IpAddr = "203.0.113.7".parse().unwrap();
2213
2214 // Mint a REAL ticket via a first handshake (answering the one cookie retry).
2215 let hello1 = client.create_client_hello();
2216 let (effective, sh) = match server.process_client_hello(&hello1, 0, ip) {
2217 HandshakeResponse::Retry(r) => {
2218 let mut h = hello1.clone();
2219 h.cookie = r.cookie;
2220 match server.process_client_hello(&h, 0, ip) {
2221 HandshakeResponse::Success(sh, _, _) => (h, sh),
2222 o => panic!("unexpected after retry: {o:?}"),
2223 }
2224 }
2225 HandshakeResponse::Success(sh, _, _) => (hello1.clone(), sh),
2226 o => panic!("unexpected first response: {o:?}"),
2227 };
2228 let (session, _) = client
2229 .process_server_hello(&effective, &sh, Some(&server_pk))
2230 .expect("client establishes a session");
2231 let secret = session
2232 .resumption_secret()
2233 .expect("resumption secret installed");
2234
2235 // No resume id, and a junk resume id (no cached ticket) are both NOT valid tickets.
2236 assert!(!server.has_valid_resume(&client.create_client_hello()));
2237 let mut junk = client.create_client_hello();
2238 junk.resume_session_id = Some([0x55u8; 32]);
2239 junk.resumption_binder = Some([0xAAu8; 32]);
2240 assert!(
2241 !server.has_valid_resume(&junk),
2242 "a junk resume_session_id must not count as a valid ticket (M-5)"
2243 );
2244
2245 // A flagged IP keeps its elevated PoW difficulty when the resume is junk.
2246 for _ in 0..5 {
2247 server.record_violation(ip);
2248 }
2249 let flagged = server.reputation_difficulty(ip, false);
2250 assert!(flagged > 0, "precondition: a flagged IP has difficulty > 0");
2251 assert_eq!(
2252 server.reputation_difficulty(ip, server.has_valid_resume(&junk)),
2253 flagged,
2254 "a junk resume must not zero a flagged IP's PoW difficulty (M-5)"
2255 );
2256
2257 // The real ticket with a valid binder DOES count as a valid resume.
2258 let resume = client.create_client_hello_with_resume(sh.session_id, &secret, None);
2259 assert!(
2260 server.has_valid_resume(&resume),
2261 "a real cached ticket with a valid binder must count as a valid resume"
2262 );
2263 }
2264}