Skip to main content

phantom_protocol/transport/
handshake.rs

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