Skip to main content

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}