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