Skip to main content

phantom_protocol/transport/
handshake.rs

1//! Unified Phantom Handshake Protocol
2//!
3//! Combines PQC security (Hybrid KEM/Sign) with Staged state machine
4//! for optimistic start, Early Data, and 0-RTT resumption.
5
6use borsh::{BorshDeserialize, BorshSerialize};
7use hmac::{Hmac, KeyInit, Mac};
8use parking_lot::RwLock;
9use sha2::{Digest, Sha256};
10use std::net::IpAddr;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::{SystemTime, UNIX_EPOCH};
13use subtle::ConstantTimeEq;
14use zeroize::ZeroizeOnDrop;
15
16use crate::crypto::adaptive_crypto::{CipherSuite, CryptoSession};
17use crate::crypto::hybrid_kem::{HybridCiphertext, HybridKeyPackage, HybridSecretKey};
18use crate::crypto::hybrid_sign::{HybridSignature, HybridSigningKey, HybridVerifyingKey};
19use crate::crypto::kdf::derive_early_data_keying;
20use crate::crypto::pow::{PoWChallenge, PoWSolution};
21use crate::errors::CoreError;
22use crate::transport::reputation::ReputationTracker;
23use crate::transport::session::{CryptoState, Session};
24use crate::transport::session_cache::SessionCache;
25use crate::transport::types::{SchedulerMode, SessionId};
26use std::sync::Arc;
27
28/// Maximum 0-RTT early-data plaintext, in bytes.
29/// The client constructor rejects a larger payload; the server drops
30/// an oversized blob and continues as a normal 1-RTT handshake. Caps
31/// the work an unauthenticated peer can force before the handshake
32/// completes.
33pub const EARLY_DATA_MAX_LEN: usize = 16 * 1024;
34
35/// Handshake processing stages
36/// Compile-time protocol-variant tag, baked into every `ClientHello`
37/// (cleartext field) **and** the signed handshake transcript. Peers
38/// reject mismatched variants up front with
39/// [`HandshakeError::ProtocolVariantMismatch`]; even an attacker who
40/// rewrites the cleartext field cannot escape detection because the
41/// transcript signature is computed over the build's own variant.
42///
43/// The `--features fips` build advertises `phantom-fips-1` so a
44/// fips client and a non-fips server (or vice versa) fail loudly at
45/// handshake time rather than producing a silently-wrong shared
46/// secret across mismatched primitive sets.
47#[cfg(not(feature = "fips"))]
48pub const PROTOCOL_VARIANT: &[u8] = b"phantom-default-1";
49#[cfg(feature = "fips")]
50pub const PROTOCOL_VARIANT: &[u8] = b"phantom-fips-1";
51
52/// The sole protocol version carried in `ClientHello.version` and bound into the
53/// handshake transcript. Pinned to one value — the protocol is not negotiated
54/// (pre-1.0, no users). It is a tamper-check anchor and a hook for a future,
55/// deliberate version increment.
56pub const PROTOCOL_VERSION: u8 = 2;
57
58/// Marker leading a [`ServerReject`] frame. The client disambiguates the three
59/// possible server replies by trial-deserialization; the marker (plus the
60/// fixed, tiny size of a reject vs. the multi-KiB `ServerHello`) makes a reject
61/// unmistakable and immune to a false-positive parse as a `HelloRetryRequest`.
62pub const SERVER_REJECT_MARKER: [u8; 4] = *b"PRJ1";
63
64/// [`ServerReject::code`]: the client's `ClientHello.version` is one this server
65/// does not speak. `supported_version` carries the version it *does* speak.
66pub const REJECT_UNSUPPORTED_VERSION: u8 = 1;
67
68/// Typed handshake rejection the server returns *instead of* silently dropping
69/// the connection when it structurally cannot satisfy a `ClientHello` — today,
70/// an unknown `version`. It gives a forward/backward-incompatible peer an
71/// actionable signal (the version the server speaks) rather than a bare
72/// connection reset.
73///
74/// **Downgrade safety.** The client surfaces a reject as a hard error and does
75/// **not** auto-retry at the advertised version. Auto-downgrading on an
76/// attacker-injected reject would defeat the transcript-bound downgrade
77/// resistance of Invariant 7 (the version is signed into the transcript). The
78/// reject is diagnostic only; protocol selection stays pinned.
79#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq, Eq)]
80pub struct ServerReject {
81    /// Always [`SERVER_REJECT_MARKER`]; lets the client identify the frame.
82    pub marker: [u8; 4],
83    /// Reject reason — see [`REJECT_UNSUPPORTED_VERSION`].
84    pub code: u8,
85    /// The `PROTOCOL_VERSION` this server speaks.
86    pub supported_version: u8,
87}
88
89impl ServerReject {
90    /// Build the unsupported-version reject advertising this build's
91    /// [`PROTOCOL_VERSION`].
92    pub fn unsupported_version() -> Self {
93        Self {
94            marker: SERVER_REJECT_MARKER,
95            code: REJECT_UNSUPPORTED_VERSION,
96            supported_version: PROTOCOL_VERSION,
97        }
98    }
99
100    /// True iff the frame carries the reject marker — the client's guard
101    /// against treating a same-sized non-reject blob as a reject.
102    pub fn has_marker(&self) -> bool {
103        self.marker == SERVER_REJECT_MARKER
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum HandshakeStage {
109    /// Initial state, no messages exchanged
110    Initial,
111    /// Classical DH established, data can flow (Optimistic Start)
112    ClassicalReady,
113    /// Hybrid (PQC) established, session fully secure
114    Established,
115    /// Handshake failed
116    Failed,
117}
118
119/// Client hello message (initiates handshake).
120///
121/// Carries the client's hybrid key material, the pinned [`PROTOCOL_VERSION`]
122/// (transcript-bound), the DoS-gate fields (cookie / PoW), an optional
123/// resumption id, the build-side [`PROTOCOL_VARIANT`] tag, and an optional
124/// AEAD-sealed 0-RTT `early_data` blob.
125#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
126pub struct ClientHello {
127    /// hybrid public key for key exchange
128    pub client_key_package: HybridKeyPackage,
129    /// hybrid verifying key for signatures
130    pub client_verify_key: HybridVerifyingKey,
131    /// Random nonce (32 bytes) for replay protection
132    pub nonce: [u8; 32],
133    /// Protocol version. Pinned to [`PROTOCOL_VERSION`] and bound into the
134    /// signed handshake transcript; the server rejects any other value with
135    /// [`HandshakeError::UnsupportedVersion`].
136    pub version: u8,
137    /// Stateless generic cookie to prove IP ownership
138    pub cookie: Option<[u8; 32]>,
139    /// Proof-of-Work solution (if required by server)
140    pub pow_solution: Option<PoWSolution>,
141    /// Optional session ID for 0-RTT resumption
142    pub resume_session_id: Option<[u8; 32]>,
143    /// Resumption proof-of-possession binder (HS-03). Present iff
144    /// `resume_session_id` is — a keyed PRF over `resumption_secret ||
145    /// resume_session_id || nonce` (see `derive_resumption_binder`). The server
146    /// verifies it (constant-time) against the cached ticket's secret *before*
147    /// consuming the one-shot ticket, so a passive observer that copied the
148    /// cleartext `resume_session_id` cannot burn a victim's ticket. Bound into
149    /// the transcript (the whole `ClientHello` is signed), so it is also
150    /// tamper-evident. Placed after `resume_session_id` and before
151    /// `protocol_variant` — borsh field order is wire-load-bearing.
152    pub resumption_binder: Option<[u8; 32]>,
153    /// Cleartext copy of [`PROTOCOL_VARIANT`]. Lets the server reject
154    /// a mismatched-mode client up front (before signature
155    /// verification); the same value is bound into the handshake
156    /// transcript so an attacker rewriting this field on the wire is
157    /// still caught by the signature check.
158    pub protocol_variant: Vec<u8>,
159    /// Optional AEAD-sealed 0-RTT early-data — AES-256-GCM under a key both
160    /// peers derive from the prior session's `resumption_secret` via
161    /// [`derive_early_data_keying`]. `None` means no 0-RTT data on this
162    /// connect. The whole `ClientHello` (this field included) is covered by
163    /// the transcript signature, so a tampered or stripped blob breaks the
164    /// server's signature check (Invariant 7).
165    pub early_data: Option<Vec<u8>>,
166}
167
168/// Server response to ClientHello
169//
170// Intentionally large — the `Success` variant carries a full `Session`.
171// Boxing it would add a heap allocation on every successful handshake
172// (the hot path); the type is internal and lives only on the handshake
173// stack, so the size is acceptable. Same rationale as the
174// `result_large_err` allow on the gate/finalize helpers below.
175#[allow(clippy::large_enum_variant)]
176#[derive(Debug)]
177pub enum HandshakeResponse {
178    /// Success: the `ServerHello` to send back, the established `Session`,
179    /// and the decrypted 0-RTT early-data plaintext (`None` when the client
180    /// sent none or it was rejected — `ServerHello.early_data_accepted`
181    /// carries the verdict the client sees).
182    Success(ServerHello, Session, Option<Vec<u8>>),
183    /// Retry: Demand PoW or Cookie
184    Retry(HelloRetryRequest),
185    /// Reject: the server structurally cannot speak this `ClientHello` (e.g. an
186    /// unknown `version`). Unlike `Fail`, the listener serialises the carried
187    /// [`ServerReject`] back to the client before closing, so the peer gets a
188    /// typed downgrade signal instead of a bare connection error.
189    Reject(ServerReject),
190    /// Fail: Handshake aborted
191    Fail(HandshakeError),
192}
193
194/// Hello Retry Request (Server demands PoW or Cookie)
195#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
196pub struct HelloRetryRequest {
197    pub challenge: Option<PoWChallenge>,
198    pub cookie: Option<[u8; 32]>,
199}
200
201/// Server hello message (response to ClientHello)
202#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
203pub struct ServerHello {
204    /// Server's hybrid public key
205    pub server_key_package: HybridKeyPackage,
206    /// Encapsulated secret (ciphertext for client)
207    pub ciphertext: HybridCiphertext,
208    /// Server's hybrid verifying key
209    pub server_verify_key: HybridVerifyingKey,
210    /// Signature over handshake transcript
211    pub signature: HybridSignature,
212    /// Session ID assigned by server
213    pub session_id: [u8; 32],
214    /// `true` iff the server decrypted and accepted the client's 0-RTT
215    /// early-data. `false` when there was none, the resumption ticket was
216    /// unknown/expired, the blob exceeded the size cap, or AEAD decryption
217    /// failed — in every `false` case the handshake still completes as a
218    /// normal 1-RTT exchange.
219    pub early_data_accepted: bool,
220}
221
222/// Handshake transcript for signing.
223///
224/// Embeds the whole `ClientHello` by reference — including the optional
225/// 0-RTT `early_data` ciphertext — so the server's signature covers it and a
226/// tampered or stripped blob breaks the client-side signature check
227/// (Invariant 7). The transcript leads with the build-side
228/// [`PROTOCOL_VARIANT`] tag, so a cross-mode (fips ↔ non-fips) attempt fails
229/// the signature check rather than landing a wrong shared secret. Both peers
230/// MUST plumb the same byte string for the signature to verify.
231#[derive(BorshSerialize)]
232struct HandshakeTranscript<'a> {
233    protocol_variant: &'a [u8],
234    client_hello: &'a ClientHello,
235    server_key_package: &'a HybridKeyPackage,
236    ciphertext: &'a HybridCiphertext,
237    server_verify_key: &'a HybridVerifyingKey,
238    session_id: &'a [u8; 32],
239    /// The server's 0-RTT verdict (H2). Signing it stops an on-path attacker
240    /// from flipping `ServerHello.early_data_accepted` to make the client
241    /// duplicate or silently drop early-data (Invariant 9). Placed LAST so
242    /// `protocol_variant` stays the leading transcript field (Invariant 10).
243    early_data_accepted: bool,
244}
245
246/// Hash a borsh-serializable transcript. The transcript leads with the
247/// `protocol_variant` tag, so the hash binds the build-side variant.
248fn compute_transcript_hash<T: BorshSerialize>(transcript: &T) -> Result<[u8; 32], HandshakeError> {
249    let mut hasher = Sha256::new();
250    let bytes =
251        borsh::to_vec(transcript).map_err(|e| HandshakeError::SerializationError(e.to_string()))?;
252    hasher.update(&bytes);
253    Ok(hasher.finalize().into())
254}
255
256/// A resumption ticket that the resume fast-path has eagerly consumed after a
257/// successful binder check (HS-03 / ZERORTT-2). Carries everything needed to
258/// re-insert the ticket **unchanged** (preserving its original lifetime) if a
259/// later handshake step fails, so a corrupted resuming `ClientHello` cannot burn
260/// a victim's one-shot ticket.
261struct ConsumedTicket {
262    rid: [u8; 32],
263    secret: [u8; 32],
264    suite: CipherSuite,
265    created_at: std::time::Instant,
266    expires_at: std::time::Instant,
267}
268
269/// Derive the HS-03 resumption proof-of-possession binder: a keyed PRF over
270/// `resumption_secret || resume_session_id || client_nonce` via
271/// [`crate::crypto::kdf::derive_key_32`] (blake3 on default builds, HKDF-SHA256
272/// under `--features fips`). Binding the per-connect `client_nonce` makes the
273/// binder connect-specific; keying it on the secret means only a client that
274/// actually holds `resumption_secret` — not a passive observer of the cleartext
275/// `resume_session_id` — can produce a value the server will accept.
276fn derive_resumption_binder(
277    resumption_secret: &[u8; 32],
278    resume_session_id: &[u8; 32],
279    client_nonce: &[u8; 32],
280) -> [u8; 32] {
281    // The IKM holds the resumption secret; wipe it on every exit path.
282    let mut ikm = zeroize::Zeroizing::new([0u8; 96]);
283    ikm[..32].copy_from_slice(resumption_secret);
284    ikm[32..64].copy_from_slice(resume_session_id);
285    ikm[64..].copy_from_slice(client_nonce);
286    crate::crypto::kdf::derive_key_32("phantom-resume-binder-v1", &*ikm)
287}
288
289/// Handshake Server State Machine
290///
291/// Holds the server's long-lived signing key (via [`HybridSigningKey`], which
292/// itself zeroes on drop) and a *master* secret from which the actually-used
293/// per-hour PoW/cookie secret is derived on each call (see
294/// `derive_session_secret_for_hour`). On drop the master is zeroed via the
295/// derived `ZeroizeOnDrop`.
296///
297/// Rotation (Phase 1.11): the master itself rotates only on process restart,
298/// but the derived hour-bucketed secret rotates every hour. Validation
299/// accepts the current hour and the immediately-previous hour, so a cookie
300/// or PoW solution captured at minute 59 of one hour is still honored at
301/// minute 5 of the next.
302#[derive(ZeroizeOnDrop)]
303pub struct HandshakeServer {
304    // SAFETY: `HybridSigningKey` has its own ZeroizeOnDrop. The wrapping field
305    // is skipped here so the derive doesn't try to call `Zeroize::zeroize`
306    // (which the inner type does not implement).
307    #[zeroize(skip)]
308    signing_key: HybridSigningKey,
309    // Public material — never sensitive.
310    #[zeroize(skip)]
311    verifying_key: HybridVerifyingKey,
312    master_secret: [u8; 32],
313    /// Adaptive-difficulty counters (Phase 1.14). Atomics so they are
314    /// thread-safe for the concurrent `accept()` path; not secret, hence
315    /// `#[zeroize(skip)]`.
316    #[zeroize(skip)]
317    handshakes_this_minute: AtomicU64,
318    #[zeroize(skip)]
319    minute_start_unix_sec: AtomicU64,
320    /// Server-side resumption cache (Phase 4.1). Stores
321    /// `ResumptionTicket` keyed on the session id derived at handshake
322    /// success. Bounded LRU with a 1-hour ticket lifetime by default;
323    /// `try_resume` returns a forward-secret derived secret per call.
324    /// `Arc<Mutex<>>` so all handshake threads share one cache.
325    #[zeroize(skip)]
326    session_cache: Arc<parking_lot::Mutex<SessionCache>>,
327    /// Per-IP reputation tracker (DOS-2). Drives a PoW-difficulty escalation for
328    /// abusive sources on top of the global load tier; bounded map. Holds no
329    /// secrets, hence `#[zeroize(skip)]`.
330    #[zeroize(skip)]
331    reputation: Arc<ReputationTracker>,
332}
333
334impl HandshakeServer {
335    pub fn new() -> Result<Self, HandshakeError> {
336        // `bind()` without a persisted key mints the server's long-lived identity
337        // here — run the FIPS pairwise-consistency check on it (this is a
338        // per-process identity, not a per-handshake key, so the cost is paid
339        // once at startup).
340        let (signing_key, verifying_key) = HybridSigningKey::generate();
341        signing_key
342            .pairwise_consistency_check(&verifying_key)
343            .map_err(|e| {
344                HandshakeError::RngError(format!(
345                    "server signing identity failed its pairwise-consistency test: {e:?}"
346                ))
347            })?;
348        Self::with_signing_key(signing_key)
349    }
350
351    /// Build a `HandshakeServer` from a caller-supplied long-lived
352    /// [`HybridSigningKey`] (Phase 7.4 follow-up).
353    ///
354    /// Used by embedders that persist the server's signing key across
355    /// restarts so client pinning material does not change on every
356    /// boot. The verifying key is derived from the supplied signing key,
357    /// the per-process master secret is freshly generated, and the
358    /// remaining state (PoW counters, session cache) initializes the
359    /// same way as [`Self::new`].
360    ///
361    /// The supplied `signing_key` is moved in and held under
362    /// `HandshakeServer`'s [`ZeroizeOnDrop`] — the same memory-hygiene
363    /// invariant as the auto-generated path.
364    pub fn with_signing_key(signing_key: HybridSigningKey) -> Result<Self, HandshakeError> {
365        let verifying_key = signing_key.verifying_key();
366
367        let mut master_secret = [0u8; 32];
368        getrandom::getrandom(&mut master_secret)
369            .map_err(|e| HandshakeError::RngError(e.to_string()))?;
370
371        let now_sec = SystemTime::now()
372            .duration_since(UNIX_EPOCH)
373            .map(|d| d.as_secs())
374            .unwrap_or(0);
375
376        Ok(Self {
377            signing_key,
378            verifying_key,
379            master_secret,
380            handshakes_this_minute: AtomicU64::new(0),
381            minute_start_unix_sec: AtomicU64::new(now_sec),
382            session_cache: Arc::new(parking_lot::Mutex::new(SessionCache::new())),
383            reputation: Arc::new(ReputationTracker::new()),
384        })
385    }
386
387    /// Increment the per-minute handshake-count counter and roll over the
388    /// minute window if necessary. Called at the start of every
389    /// `process_client_hello`.
390    fn record_handshake(&self) {
391        let now_sec = SystemTime::now()
392            .duration_since(UNIX_EPOCH)
393            .map(|d| d.as_secs())
394            .unwrap_or(0);
395        let start = self.minute_start_unix_sec.load(Ordering::Relaxed);
396        if now_sec.saturating_sub(start) >= 60 {
397            // Reset the bucket. Racing other threads here is acceptable —
398            // multiple resets within a single boundary just under-count by a
399            // few; the next minute is unaffected.
400            self.handshakes_this_minute.store(0, Ordering::Relaxed);
401            self.minute_start_unix_sec.store(now_sec, Ordering::Relaxed);
402        }
403        self.handshakes_this_minute.fetch_add(1, Ordering::Relaxed);
404    }
405
406    /// Recommended PoW difficulty for the current handshake load. Callers
407    /// (e.g. `PhantomListener::accept`) pass this into `process_client_hello`
408    /// so the cost imposed on each new client scales with server load.
409    ///
410    /// Difficulty tiers (handshakes-per-minute → difficulty):
411    /// ```text
412    ///   <100         → 0   (no PoW)
413    ///   100..500     → 4   (~16 hash evaluations expected)
414    ///   500..2000    → 8   (~256 evaluations)
415    ///   2000..10000  → 12  (~4k evaluations)
416    ///   >=10000      → 16  (~64k evaluations)
417    /// ```
418    /// These tiers err on the side of leniency: a healthy server doing a few
419    /// hundred handshakes per minute imposes no PoW work on clients. Only at
420    /// high load — where DoS protection matters most — does the cost ramp up.
421    pub fn adaptive_difficulty(&self) -> u8 {
422        let count = self.handshakes_this_minute.load(Ordering::Relaxed);
423        match count {
424            0..=99 => 0,
425            100..=499 => 4,
426            500..=1999 => 8,
427            2000..=9999 => 12,
428            _ => 16,
429        }
430    }
431
432    /// Per-IP PoW-difficulty escalation for `client_ip` (DOS-2) — 0 for clean
433    /// IPs and resumption-ticket holders, escalating for sources with recent
434    /// handshake violations. The server uses
435    /// `max(adaptive_difficulty(), reputation_difficulty(...))` so an abusive IP
436    /// is singled out even when the global load tier is idle, without penalizing
437    /// well-behaved clients.
438    pub(crate) fn reputation_difficulty(&self, client_ip: IpAddr, has_ticket: bool) -> u8 {
439        self.reputation.calculate_difficulty(client_ip, has_ticket)
440    }
441
442    /// Record a handshake violation for `client_ip` (DOS-2) — drives the
443    /// escalation. Called on a genuine protocol failure (bad/old/abusive client),
444    /// NOT on a normal first-contact cookie/PoW retry.
445    pub(crate) fn record_violation(&self, client_ip: IpAddr) {
446        self.reputation.record_violation(client_ip);
447    }
448
449    /// Clear `client_ip`'s violation record after a successful handshake (DOS-2).
450    pub(crate) fn reset_violations(&self, client_ip: IpAddr) {
451        self.reputation.reset_violations(client_ip);
452    }
453
454    /// Drop expired reputation entries (DOS-2) — driven periodically by the
455    /// listener's acceptor loop.
456    pub(crate) fn gc_reputation(&self) {
457        self.reputation.gc();
458    }
459
460    /// Current per-minute handshake count. Exposed for metrics
461    /// (`handshakes_per_minute`).
462    pub fn handshakes_this_minute(&self) -> u64 {
463        self.handshakes_this_minute.load(Ordering::Relaxed)
464    }
465
466    #[tracing::instrument(
467        name = "phantom.handshake.process_client_hello",
468        skip_all,
469        // No `client_ip` field: this span is always-on in the library, and the
470        // peer IP is correlatable PII. The DoS gate already has the IP in-band;
471        // it does not need to leak into every handshake trace.
472        fields(
473            difficulty = difficulty,
474            has_cookie = client_hello.cookie.is_some(),
475            has_pow = client_hello.pow_solution.is_some(),
476            resume = client_hello.resume_session_id.is_some(),
477            has_early_data = client_hello.early_data.is_some(),
478        ),
479    )]
480    pub fn process_client_hello(
481        &self,
482        client_hello: &ClientHello,
483        difficulty: u8,
484        client_ip: IpAddr,
485    ) -> HandshakeResponse {
486        // Tally this call before any work is done, so the load counter
487        // reflects attempts (including the rejected ones).
488        self.record_handshake();
489
490        // Protocol-variant gate. Fail loud (before any KEM / signature work)
491        // if the client and server disagree on the build-side
492        // `PROTOCOL_VARIANT` tag. The transcript also binds this constant, so
493        // an MITM rewrite of the cleartext field is caught on the client's
494        // signature check; this explicit field gives operators a clean
495        // diagnostic instead of "Signature check failed" (Invariant 10).
496        if client_hello.protocol_variant != PROTOCOL_VARIANT {
497            return HandshakeResponse::Fail(HandshakeError::ProtocolVariantMismatch {
498                expected: PROTOCOL_VARIANT.to_vec(),
499                received: client_hello.protocol_variant.clone(),
500            });
501        }
502
503        // Version pin. The protocol is not negotiated — `version` is a
504        // tamper-check anchor pinned to `PROTOCOL_VERSION` and borsh-serialized
505        // into the signed transcript, so a network rewrite forces a
506        // client-side signature mismatch. Anything else is rejected up front
507        // (Invariant 7). Instead of dropping silently we hand back a typed
508        // `ServerReject` advertising the version we speak, so a future client
509        // degrades gracefully (H9 forward-compat) — the client treats it as a
510        // hard error and does NOT auto-downgrade, preserving Invariant 7's
511        // transcript-bound downgrade resistance.
512        if client_hello.version != PROTOCOL_VERSION {
513            return HandshakeResponse::Reject(ServerReject::unsupported_version());
514        }
515
516        // 0-RTT resumption fast path with proof-of-possession (HS-03 + ZERORTT-2).
517        //
518        // If the client offered a `resume_session_id` AND the cache holds a
519        // still-valid ticket, a valid resume lets the client skip the cookie/PoW
520        // DoS gate and (with a sealed blob) deliver 0-RTT early-data.
521        //
522        // Before trusting the resume we require proof the client holds the
523        // ticket's `resumption_secret` — a `resumption_binder` MAC (HS-03). We
524        // PEEK the ticket (no consume) to recompute the expected binder and
525        // compare it constant-time; a missing/mismatched binder (e.g. a passive
526        // observer that only copied the cleartext `resume_session_id`) means NO
527        // resume — the ticket is left untouched and the client falls through to
528        // the normal cookie/PoW gate.
529        //
530        // On a valid binder we CONSUME the ticket eagerly (one-shot anti-replay,
531        // Invariant 9); `remove` returns `true` for exactly one of two racing
532        // duplicate resumes, so the same early-data can't be accepted twice. The
533        // consumed ticket is carried in `resumed` and re-inserted unchanged on
534        // any later handshake failure (ZERORTT-2), so a corrupted resuming
535        // `ClientHello` cannot burn a victim's ticket. The KEM round-trip still
536        // runs, so forward secrecy is preserved by the fresh X25519+ML-KEM secret.
537        let resumed: Option<ConsumedTicket> = client_hello.resume_session_id.and_then(|rid| {
538            let (secret, suite, created_at, expires_at) = self.session_cache.lock().peek(&rid)?;
539            // Proof-of-possession: only a holder of `resumption_secret` can
540            // produce a binder that matches. `None` binder ⇒ no resume.
541            let expected = derive_resumption_binder(&secret, &rid, &client_hello.nonce);
542            let presented = client_hello.resumption_binder?;
543            if !bool::from(presented.ct_eq(&expected)) {
544                return None;
545            }
546            // Binder verified — consume now (race-free via `remove`'s bool).
547            if !self.session_cache.lock().remove(&rid) {
548                return None; // a concurrent resume already consumed it
549            }
550            Some(ConsumedTicket {
551                rid,
552                secret,
553                suite,
554                created_at,
555                expires_at,
556            })
557        });
558        let cookie_pow_bypass = resumed.is_some();
559
560        // Stateless DoS checks (Cookie & PoW). On the bypass path the gate
561        // returns Ok; a rare infra error there is still post-consume, so hand the
562        // ticket back if it fails (ZERORTT-2).
563        if let Err(resp) =
564            self.cookie_pow_gate(client_hello, difficulty, client_ip, cookie_pow_bypass)
565        {
566            return self.fail_and_reinsert(&resumed, resp);
567        }
568
569        // Best-effort 0-RTT early-data decryption. Only attempted when the
570        // client both presented a valid ticket AND carried a sealed blob; any
571        // failure (unknown/expired ticket, oversized blob, AEAD failure)
572        // leaves `early_data_accepted = false` and completes a normal 1-RTT
573        // handshake (Invariant 9). Forward secrecy of the post-handshake
574        // session is preserved by the fresh hybrid KEM regardless.
575        let early_data_plaintext: Option<Vec<u8>> = match (&resumed, &client_hello.early_data) {
576            (Some(t), Some(blob)) => {
577                decrypt_early_data(&t.secret, &client_hello.nonce, &t.rid, blob)
578            }
579            _ => None,
580        };
581        let early_data_accepted = early_data_plaintext.is_some();
582
583        // Hybrid Key Exchange (PFS preserved — a fresh KEM secret even on the
584        // 0-RTT path).
585        let (shared_secret, ciphertext) = match client_hello.client_key_package.encapsulate() {
586            Ok(res) => res,
587            Err(e) => {
588                return self.fail_and_reinsert(
589                    &resumed,
590                    HandshakeResponse::Fail(HandshakeError::KemFailed(e.to_string())),
591                );
592            }
593        };
594
595        // Generate a per-session ephemeral hybrid KEM keypair. The public half
596        // is bound into the transcript signature (defense-in-depth: commits
597        // the server to a session-specific value beyond `session_id` and the
598        // client's nonce). The secret half is intentionally discarded — the
599        // current protocol does not perform a second KEM round trip using it.
600        let (_ephemeral_kem_secret, ephemeral_kem_public) = HybridSecretKey::generate();
601
602        let session_id_bytes = derive_session_id(&shared_secret, &client_hello.nonce);
603        let session_id = SessionId::from_bytes(session_id_bytes);
604
605        // Sign the transcript. It embeds the WHOLE `ClientHello` (early-data
606        // ciphertext included) plus `PROTOCOL_VARIANT` — a tampered or stripped
607        // blob breaks the client-side signature check (Invariants 7, 10).
608        let transcript = HandshakeTranscript {
609            protocol_variant: PROTOCOL_VARIANT,
610            client_hello,
611            server_key_package: &ephemeral_kem_public,
612            ciphertext: &ciphertext,
613            server_verify_key: &self.verifying_key,
614            session_id: &session_id_bytes,
615            early_data_accepted,
616        };
617        let transcript_hash = match compute_transcript_hash(&transcript) {
618            Ok(h) => h,
619            Err(e) => return self.fail_and_reinsert(&resumed, HandshakeResponse::Fail(e)),
620        };
621        let signature = self.signing_key.sign(&transcript_hash);
622
623        let server_hello = ServerHello {
624            server_key_package: ephemeral_kem_public,
625            ciphertext,
626            server_verify_key: self.verifying_key.clone(),
627            signature,
628            session_id: session_id_bytes,
629            early_data_accepted,
630        };
631
632        // Build + wire the Session, derive the resumption secret, and stash a
633        // fresh one-shot ticket for a future resume / 0-RTT.
634        let session = match self.finalize_session(&shared_secret, session_id, session_id_bytes) {
635            Ok(s) => s,
636            Err(resp) => return self.fail_and_reinsert(&resumed, resp),
637        };
638
639        HandshakeResponse::Success(server_hello, session, early_data_plaintext)
640    }
641
642    /// The cookie / Proof-of-Work DoS gate. Returns `Err(response)` —
643    /// a ready-to-send `Retry` or `Fail` — when the client must not
644    /// yet proceed; `Ok(())` when it has cleared the gate (or `bypass`
645    /// was set by a valid one-shot resumption ticket).
646    // `HandshakeResponse` is intentionally large — boxing it would add a
647    // heap allocation on every call, penalising the hot non-error path.
648    // The type is internal and lives only on the handshake stack, so the
649    // size is acceptable.
650    #[allow(clippy::result_large_err)]
651    fn cookie_pow_gate(
652        &self,
653        client_hello: &ClientHello,
654        difficulty: u8,
655        client_ip: IpAddr,
656        bypass: bool,
657    ) -> Result<(), HandshakeResponse> {
658        // Cookie freshness (Phase 1.10): `validate_cookie` accepts the current
659        // bucket OR the immediately-previous bucket (5-minute buckets, so
660        // 5-10 min effective validity). Comparisons are constant-time.
661        let cookie_valid = match client_hello.cookie {
662            Some(c) => match validate_cookie(&self.master_secret, client_ip, &c) {
663                Ok(v) => v,
664                Err(e) => return Err(HandshakeResponse::Fail(e)),
665            },
666            None => false,
667        };
668        // Pre-compute a fresh cookie to hand to the client on a retry.
669        let expected_cookie = match generate_cookie(&self.master_secret, client_ip) {
670            Ok(c) => c,
671            Err(e) => return Err(HandshakeResponse::Fail(e)),
672        };
673
674        let mut pow_valid = true;
675        let mut challenge = None;
676        if difficulty > 0 {
677            // PoW verification (Phase 1.11): the derived hour-bucketed secret
678            // rotates every `SECRET_ROTATION_SECONDS`. Accept either the
679            // current or the previous hour's derivation so a client that
680            // computed a solution just before the rotation boundary doesn't
681            // have to redo the work.
682            let cur_hour = match current_secret_hour() {
683                Ok(h) => h,
684                Err(e) => return Err(HandshakeResponse::Fail(e)),
685            };
686            let prev_hour = cur_hour.saturating_sub(1);
687            let hours: &[u64] = if cur_hour == prev_hour {
688                &[cur_hour]
689            } else {
690                &[cur_hour, prev_hour]
691            };
692
693            if let Some(sol) = &client_hello.pow_solution {
694                let mut any_valid = false;
695                for &h in hours {
696                    let derived = match derive_session_secret_for_hour(&self.master_secret, h) {
697                        Ok(s) => s,
698                        Err(e) => return Err(HandshakeResponse::Fail(e)),
699                    };
700                    let challenge_ref = PoWChallenge {
701                        nonce: sol.nonce,
702                        difficulty,
703                    };
704                    if challenge_ref.verify(sol, client_ip.to_string().as_bytes(), &derived) {
705                        any_valid = true;
706                        break;
707                    }
708                }
709                pow_valid = any_valid;
710            } else {
711                pow_valid = false;
712                let derived = match derive_session_secret_for_hour(&self.master_secret, cur_hour) {
713                    Ok(s) => s,
714                    Err(e) => return Err(HandshakeResponse::Fail(e)),
715                };
716                challenge = Some(PoWChallenge::new_stateless(
717                    difficulty,
718                    client_ip.to_string().as_bytes(),
719                    &derived,
720                ));
721            }
722        }
723
724        if !bypass && (!cookie_valid || !pow_valid) {
725            return Err(HandshakeResponse::Retry(HelloRetryRequest {
726                challenge,
727                cookie: if !cookie_valid {
728                    Some(expected_cookie)
729                } else {
730                    None
731                },
732            }));
733        }
734        Ok(())
735    }
736
737    /// Build the post-handshake `Session` from the negotiated
738    /// `shared_secret`: derive the AEAD `CryptoState`, derive + install the
739    /// resumption secret, and stash a resumption ticket in the cache.
740    #[allow(clippy::result_large_err)]
741    fn finalize_session(
742        &self,
743        shared_secret: &[u8; 32],
744        session_id: SessionId,
745        session_id_bytes: [u8; 32],
746    ) -> Result<Session, HandshakeResponse> {
747        let crypto = CryptoState::new(shared_secret, true)
748            .map_err(|e| HandshakeResponse::Fail(HandshakeError::KemFailed(e.to_string())))?;
749
750        // is_server=true and traffic_secret=shared_secret seed the rekey
751        // chain (Phase 1.5) so the server can later derive forward.
752        let session = Session::from_derived(
753            session_id,
754            crypto,
755            SchedulerMode::LowLatency,
756            *shared_secret,
757            true,
758        );
759
760        // Derive resumption secret and stash a one-shot ticket so a
761        // future ClientHello carrying this session id can skip
762        // cookie/PoW and carry 0-RTT early-data.
763        let mut resumption_secret = [0u8; 32];
764        let hk = hkdf::Hkdf::<Sha256>::new(None, shared_secret);
765        if hk
766            .expand(b"phantom-resumption-secret-v1", &mut resumption_secret)
767            .is_ok()
768        {
769            session.set_resumption_secret(resumption_secret);
770            self.session_cache.lock().store(
771                session_id_bytes,
772                &resumption_secret,
773                CipherSuite::Aes256Gcm,
774            );
775        }
776        Ok(session)
777    }
778
779    /// Re-insert a ticket consumed by a resume attempt that then failed
780    /// (ZERORTT-2), preserving its original lifetime, and return the failure
781    /// response unchanged. A no-op when `resumed` is `None`. This keeps a
782    /// corrupted/forged resuming `ClientHello` from burning a victim's one-shot
783    /// ticket: the ticket is consumed eagerly (race-free) after the binder check,
784    /// and handed back here on every post-consume failure path.
785    #[allow(clippy::result_large_err)]
786    fn fail_and_reinsert(
787        &self,
788        resumed: &Option<ConsumedTicket>,
789        resp: HandshakeResponse,
790    ) -> HandshakeResponse {
791        if let Some(t) = resumed {
792            self.session_cache.lock().reinsert_with_expiry(
793                t.rid,
794                &t.secret,
795                t.suite,
796                t.created_at,
797                t.expires_at,
798            );
799        }
800        resp
801    }
802
803    pub fn verifying_key(&self) -> &HybridVerifyingKey {
804        &self.verifying_key
805    }
806
807    /// Number of tickets currently held in the resumption cache.
808    /// Exposed for metrics / tests; not on the hot path. Phase 4.1.
809    pub fn session_cache_len(&self) -> usize {
810        self.session_cache.lock().len()
811    }
812}
813
814/// Handshake Client State Machine
815///
816/// `kem_secret` and `signing_key` are already `ZeroizeOnDrop` in their own
817/// types. The remaining sensitive field is `nonce`, which is zeroed via the
818/// derived `ZeroizeOnDrop`. `early_data` is application plaintext queued
819/// before the secure channel is up — it lives in user-controlled storage and
820/// is moved out by `take_early_data`.
821#[derive(ZeroizeOnDrop)]
822pub struct HandshakeClient {
823    // SAFETY: each inner type has its own ZeroizeOnDrop / Drop that zeroes
824    // sensitive bytes. Skipping at this layer avoids the derive trying to call
825    // `Zeroize::zeroize` (which the inner types don't implement directly).
826    #[zeroize(skip)]
827    kem_secret: HybridSecretKey,
828    #[zeroize(skip)]
829    kem_public: HybridKeyPackage,
830    #[zeroize(skip)]
831    #[allow(dead_code)]
832    signing_key: HybridSigningKey,
833    #[zeroize(skip)]
834    verifying_key: HybridVerifyingKey,
835    nonce: [u8; 32],
836    #[zeroize(skip)]
837    early_data: RwLock<Vec<Vec<u8>>>,
838    #[zeroize(skip)]
839    stage: RwLock<HandshakeStage>,
840}
841
842impl HandshakeClient {
843    /// Construct a client handshake state. Allocates an ephemeral hybrid KEM
844    /// keypair, an ephemeral hybrid signing keypair, and a 32-byte client
845    /// nonce. Returns `Err` if the OS RNG cannot be read.
846    pub fn new() -> Result<Self, HandshakeError> {
847        let (kem_secret, kem_public) = HybridSecretKey::generate();
848        let (signing_key, verifying_key) = HybridSigningKey::generate();
849        let mut nonce = [0u8; 32];
850        getrandom::getrandom(&mut nonce).map_err(|e| HandshakeError::RngError(e.to_string()))?;
851
852        Ok(Self {
853            kem_secret,
854            kem_public,
855            signing_key,
856            verifying_key,
857            nonce,
858            early_data: RwLock::new(Vec::new()),
859            stage: RwLock::new(HandshakeStage::Initial),
860        })
861    }
862
863    /// Build the default `ClientHello` — pinned [`PROTOCOL_VERSION`], no
864    /// resumption, no 0-RTT early-data. Downgrade resistance comes from the
865    /// transcript signature, which binds both `version` and the build-side
866    /// [`PROTOCOL_VARIANT`]; a network rewrite of either aborts the handshake
867    /// at the client-side signature check.
868    pub fn create_client_hello(&self) -> ClientHello {
869        ClientHello {
870            client_key_package: self.kem_public.clone(),
871            client_verify_key: self.verifying_key.clone(),
872            nonce: self.nonce,
873            version: PROTOCOL_VERSION,
874            cookie: None,
875            pow_solution: None,
876            resume_session_id: None,
877            resumption_binder: None,
878            protocol_variant: PROTOCOL_VARIANT.to_vec(),
879            early_data: None,
880        }
881    }
882
883    /// Build a `ClientHello` that resumes a prior session, optionally carrying
884    /// 0-RTT `early_data`.
885    ///
886    /// `resume_session_id` and `resumption_secret` are the two halves of a
887    /// prior session's `Session::resumption_hint()`. The server checks its
888    /// session cache; a known, still-valid ticket bypasses the cookie/PoW DoS
889    /// gate. When `early_data` is `Some`, it is sealed (AES-256-GCM) under a
890    /// key derived from `(resumption_secret, self.nonce)` and placed in
891    /// `ClientHello.early_data`; the server decrypts it with the matching key
892    /// (best-effort — see [`HandshakeServer::process_client_hello`]). The
893    /// whole hello, early-data included, is transcript-bound (Invariant 7).
894    ///
895    /// The caller MUST ensure `early_data.len() <= EARLY_DATA_MAX_LEN`;
896    /// `PhantomSession::connect_with_resumption` enforces this and returns an
897    /// error for oversized payloads.
898    pub fn create_client_hello_with_resume(
899        &self,
900        resume_session_id: [u8; 32],
901        resumption_secret: &[u8; 32],
902        early_data: Option<&[u8]>,
903    ) -> ClientHello {
904        let sealed = early_data
905            .and_then(|pt| seal_early_data(resumption_secret, &self.nonce, &resume_session_id, pt));
906        // HS-03: prove possession of `resumption_secret` so a passive observer of
907        // the cleartext `resume_session_id` cannot consume the server's ticket.
908        let resumption_binder =
909            derive_resumption_binder(resumption_secret, &resume_session_id, &self.nonce);
910        ClientHello {
911            client_key_package: self.kem_public.clone(),
912            client_verify_key: self.verifying_key.clone(),
913            nonce: self.nonce,
914            version: PROTOCOL_VERSION,
915            cookie: None,
916            pow_solution: None,
917            resume_session_id: Some(resume_session_id),
918            resumption_binder: Some(resumption_binder),
919            protocol_variant: PROTOCOL_VARIANT.to_vec(),
920            early_data: sealed,
921        }
922    }
923
924    /// Verify a `ServerHello` against the `ClientHello` we sent and establish
925    /// the client-side `Session`.
926    ///
927    /// Pinning is mandatory in production — `expected_server_key` is
928    /// `Some(&key)` (Invariant 1). The signature is checked over the whole
929    /// transcript, which embeds the entire `ClientHello` (early-data
930    /// ciphertext included) and the build-side `PROTOCOL_VARIANT` (Invariants
931    /// 7, 10). Returns the established `Session` and the 0-RTT verdict:
932    /// `Some(true/false)` when the client sent early-data (accepted / rejected
933    /// per `server_hello.early_data_accepted`), `None` when it sent none.
934    #[tracing::instrument(
935        name = "phantom.handshake.process_server_hello",
936        skip_all,
937        fields(
938            pinned = expected_server_key.is_some(),
939        ),
940    )]
941    pub fn process_server_hello(
942        &self,
943        client_hello: &ClientHello,
944        server_hello: &ServerHello,
945        expected_server_key: Option<&HybridVerifyingKey>,
946    ) -> Result<(Session, Option<bool>), HandshakeError> {
947        // 1. Verify Identity (server pinning — Invariant 1).
948        if let Some(expected) = expected_server_key {
949            if expected != &server_hello.server_verify_key {
950                return Err(HandshakeError::ServerIdentityMismatch);
951            }
952        }
953
954        // 2. Verify Signature over the transcript. It binds the whole
955        // ClientHello (incl. early-data) and PROTOCOL_VARIANT — a fips↔non-fips
956        // mismatch, a downgraded `version`, or a tampered/stripped early-data
957        // blob fails this check rather than landing a wrong secret (Invariants
958        // 7, 10).
959        let transcript = HandshakeTranscript {
960            protocol_variant: PROTOCOL_VARIANT,
961            client_hello,
962            server_key_package: &server_hello.server_key_package,
963            ciphertext: &server_hello.ciphertext,
964            server_verify_key: &server_hello.server_verify_key,
965            session_id: &server_hello.session_id,
966            // H2: recompute with the RECEIVED verdict — a flipped bit makes this
967            // hash diverge from what the server signed, so verify() below fails.
968            early_data_accepted: server_hello.early_data_accepted,
969        };
970        let transcript_hash = compute_transcript_hash(&transcript)?;
971        server_hello
972            .server_verify_key
973            .verify(&transcript_hash, &server_hello.signature)
974            .map_err(|e| HandshakeError::KemFailed(format!("Signature check failed: {:?}", e)))?;
975
976        // 3. Decapsulate
977        let shared_secret = self
978            .kem_secret
979            .decapsulate(&server_hello.ciphertext)
980            .map_err(|e| HandshakeError::KemFailed(e.to_string()))?;
981
982        // 4. Create Session
983        let session_id = SessionId::from_bytes(server_hello.session_id);
984        let crypto = CryptoState::new(&shared_secret, false)
985            .map_err(|e| HandshakeError::KemFailed(e.to_string()))?;
986
987        // is_server=false and traffic_secret=shared_secret seed the rekey
988        // chain (Phase 1.5) so the client can later derive forward in lock-
989        // step with the server.
990        let session = Session::from_derived(
991            session_id,
992            crypto,
993            SchedulerMode::LowLatency,
994            shared_secret,
995            false,
996        );
997
998        // 5. Derive resumption secret (seeds the NEXT resume / 0-RTT).
999        let mut resumption_secret = [0u8; 32];
1000        let hk = hkdf::Hkdf::<Sha256>::new(None, &shared_secret);
1001        if hk
1002            .expand(b"phantom-resumption-secret-v1", &mut resumption_secret)
1003            .is_ok()
1004        {
1005            session.set_resumption_secret(resumption_secret);
1006        }
1007
1008        *self.stage.write() = HandshakeStage::Established;
1009
1010        // 0-RTT verdict: only meaningful when the client actually sent
1011        // early-data on this connect (`None` otherwise — resolved decision 1 /
1012        // Invariant 9).
1013        let early_data_verdict = client_hello
1014            .early_data
1015            .as_ref()
1016            .map(|_| server_hello.early_data_accepted);
1017        Ok((session, early_data_verdict))
1018    }
1019
1020    /// Queue a plaintext payload to be sent as early-data once the secure
1021    /// channel is up.
1022    ///
1023    /// NOTE: Early-data is currently queued at the API layer (see
1024    /// `PhantomSession::send_queue`) and the data-pump flushes it through the
1025    /// regular AEAD path after the handshake completes. This per-handshake
1026    /// buffer is reserved for the future 0-RTT path (Phase 4.1).
1027    pub fn queue_early_data(&self, data: Vec<u8>) {
1028        self.early_data.write().push(data);
1029    }
1030
1031    /// Drain the queued early-data buffer. See [`Self::queue_early_data`] — the
1032    /// production `send_queue` path is currently used instead; this hook is
1033    /// reserved for 0-RTT.
1034    #[allow(dead_code)]
1035    pub fn take_early_data(&self) -> Vec<Vec<u8>> {
1036        std::mem::take(&mut self.early_data.write())
1037    }
1038
1039    pub fn stage(&self) -> HandshakeStage {
1040        *self.stage.read()
1041    }
1042}
1043
1044/// Internal helper for session ID derivation
1045fn derive_session_id(shared_secret: &[u8; 32], nonce: &[u8; 32]) -> [u8; 32] {
1046    let mut hasher = Sha256::new();
1047    hasher.update(b"phantom-session-id-v1");
1048    hasher.update(shared_secret);
1049    hasher.update(nonce);
1050    hasher.finalize().into()
1051}
1052
1053/// Best-effort decryption of a 0-RTT early-data blob.
1054///
1055/// Both peers derive the AEAD `(key, nonce)` from the prior session's
1056/// `resumption_secret` and *this* connect's `client_nonce` via
1057/// [`derive_early_data_keying`]. AAD binds the blob to its context:
1058/// `resume_session_id || client_nonce`.
1059///
1060/// Returns `None` — early-data rejected, the handshake simply
1061/// continues as 1-RTT — when:
1062/// - the sealed blob exceeds the [`EARLY_DATA_MAX_LEN`] cap (checked
1063///   before any crypto work — anti-DoS), or
1064/// - the AEAD tag fails to verify (tampered / wrong key).
1065fn decrypt_early_data(
1066    resumption_secret: &[u8; 32],
1067    client_nonce: &[u8; 32],
1068    resume_session_id: &[u8; 32],
1069    sealed: &[u8],
1070) -> Option<Vec<u8>> {
1071    // A sealed blob is `plaintext || 16-byte GCM tag`. Reject anything
1072    // whose plaintext would exceed the cap before doing crypto work.
1073    if sealed.len() > EARLY_DATA_MAX_LEN + 16 {
1074        return None;
1075    }
1076    let (key, nonce) = derive_early_data_keying(resumption_secret, client_nonce);
1077    // Server is the responder for the one-directional early-data
1078    // channel: `with_suite_peer` swaps send/recv so its `recv_key`
1079    // matches the client's `send_key`.
1080    let aead = CryptoSession::with_suite_peer(&key, CipherSuite::Aes256Gcm).ok()?;
1081    let mut aad = [0u8; 64];
1082    aad[..32].copy_from_slice(resume_session_id);
1083    aad[32..].copy_from_slice(client_nonce);
1084    aead.decrypt_with_nonce(nonce, &aad, sealed).ok()
1085}
1086
1087/// Seal a 0-RTT early-data plaintext for transport inside a
1088/// `ClientHello.early_data`. Mirror of [`decrypt_early_data`].
1089///
1090/// The client is the *initiator* of the one-directional early-data
1091/// channel — `with_suite` (no key swap) so its `send_key` matches the
1092/// server's `recv_key`. AAD is `resume_session_id || client_nonce`,
1093/// identical to the server side.
1094///
1095/// Returns `None` only on the structurally-improbable AEAD-key-init
1096/// failure; the caller treats that as "no early-data" and the
1097/// handshake proceeds 1-RTT.
1098fn seal_early_data(
1099    resumption_secret: &[u8; 32],
1100    client_nonce: &[u8; 32],
1101    resume_session_id: &[u8; 32],
1102    plaintext: &[u8],
1103) -> Option<Vec<u8>> {
1104    let (key, nonce) = derive_early_data_keying(resumption_secret, client_nonce);
1105    let aead = CryptoSession::with_suite(&key, CipherSuite::Aes256Gcm).ok()?;
1106    let mut aad = [0u8; 64];
1107    aad[..32].copy_from_slice(resume_session_id);
1108    aad[32..].copy_from_slice(client_nonce);
1109    aead.encrypt_with_nonce(nonce, &aad, plaintext).ok()
1110}
1111
1112/// Bucket size in seconds for the rolling cookie salt.
1113///
1114/// Cookies are valid for the current bucket and the previous bucket — so the
1115/// effective validity window is between `COOKIE_BUCKET_SECONDS` and
1116/// `2 * COOKIE_BUCKET_SECONDS` depending on when within the bucket the cookie
1117/// was minted.
1118const COOKIE_BUCKET_SECONDS: u64 = 300;
1119
1120/// Rotation interval in seconds for the derived per-hour PoW/cookie secret.
1121/// The master_secret in `HandshakeServer` only rotates on process restart;
1122/// this constant controls the cadence of the derived sub-secret.
1123const SECRET_ROTATION_SECONDS: u64 = 3600;
1124
1125fn current_cookie_bucket() -> Result<u64, HandshakeError> {
1126    Ok(SystemTime::now()
1127        .duration_since(UNIX_EPOCH)
1128        .map_err(|_| HandshakeError::ClockBackwards)?
1129        .as_secs()
1130        / COOKIE_BUCKET_SECONDS)
1131}
1132
1133fn current_secret_hour() -> Result<u64, HandshakeError> {
1134    Ok(SystemTime::now()
1135        .duration_since(UNIX_EPOCH)
1136        .map_err(|_| HandshakeError::ClockBackwards)?
1137        .as_secs()
1138        / SECRET_ROTATION_SECONDS)
1139}
1140
1141/// HKDF-derive a fresh sub-secret from `master` for the given hour bucket.
1142/// The same master + hour always produces the same derived secret, so this
1143/// is just a deterministic function of (master, hour) — no internal state.
1144pub(crate) fn derive_session_secret_for_hour(
1145    master: &[u8; 32],
1146    hour: u64,
1147) -> Result<[u8; 32], HandshakeError> {
1148    let hk = hkdf::Hkdf::<Sha256>::new(None, master);
1149    let mut out = [0u8; 32];
1150    let mut info = Vec::with_capacity(16 + 8);
1151    info.extend_from_slice(b"phantom-pow-cookie-v1");
1152    info.extend_from_slice(&hour.to_be_bytes());
1153    hk.expand(&info, &mut out)
1154        .map_err(|e| HandshakeError::InternalError(format!("HKDF expand: {}", e)))?;
1155    Ok(out)
1156}
1157
1158fn generate_cookie_for_bucket(
1159    derived_secret: &[u8; 32],
1160    ip: IpAddr,
1161    bucket: u64,
1162) -> Result<[u8; 32], HandshakeError> {
1163    let mut mac = Hmac::<Sha256>::new_from_slice(derived_secret)
1164        .map_err(|e| HandshakeError::InternalError(format!("HMAC init: {}", e)))?;
1165    mac.update(ip.to_string().as_bytes());
1166    mac.update(&bucket.to_be_bytes());
1167    let mut result = [0u8; 32];
1168    result.copy_from_slice(&mac.finalize().into_bytes());
1169    Ok(result)
1170}
1171
1172fn generate_cookie(master: &[u8; 32], ip: IpAddr) -> Result<[u8; 32], HandshakeError> {
1173    let hour = current_secret_hour()?;
1174    let derived = derive_session_secret_for_hour(master, hour)?;
1175    generate_cookie_for_bucket(&derived, ip, current_cookie_bucket()?)
1176}
1177
1178/// Validate a client-supplied cookie against the 2x2 combinations of
1179/// (current/previous hour) × (current/previous bucket). All comparisons are
1180/// constant-time via [`subtle::ConstantTimeEq`], and the accept signal is
1181/// accumulated as a [`subtle::Choice`] so the function never branches on
1182/// any individual comparison's outcome.
1183fn validate_cookie(
1184    master: &[u8; 32],
1185    ip: IpAddr,
1186    cookie: &[u8; 32],
1187) -> Result<bool, HandshakeError> {
1188    let bucket = current_cookie_bucket()?;
1189    let hour = current_secret_hour()?;
1190    let prev_bucket = bucket.saturating_sub(1);
1191    let prev_hour = hour.saturating_sub(1);
1192
1193    let bucket_candidates: [u64; 2] = if bucket == prev_bucket {
1194        [bucket, bucket]
1195    } else {
1196        [bucket, prev_bucket]
1197    };
1198    let hour_candidates: [u64; 2] = if hour == prev_hour {
1199        [hour, hour]
1200    } else {
1201        [hour, prev_hour]
1202    };
1203
1204    let mut accept = subtle::Choice::from(0u8);
1205    for h in hour_candidates {
1206        let derived = derive_session_secret_for_hour(master, h)?;
1207        for b in bucket_candidates {
1208            let expected = generate_cookie_for_bucket(&derived, ip, b)?;
1209            accept |= cookie.ct_eq(&expected);
1210        }
1211    }
1212    Ok(bool::from(accept))
1213}
1214
1215#[derive(Debug, Clone, thiserror::Error)]
1216pub enum HandshakeError {
1217    #[error("Unsupported version")]
1218    UnsupportedVersion,
1219    #[error("KEM failed: {0}")]
1220    KemFailed(String),
1221    #[error("Server identity mismatch")]
1222    ServerIdentityMismatch,
1223    #[error("RNG error: {0}")]
1224    RngError(String),
1225    #[error("serialization error during handshake: {0}")]
1226    SerializationError(String),
1227    #[error("system clock is before UNIX_EPOCH")]
1228    ClockBackwards,
1229    #[error("internal handshake error: {0}")]
1230    InternalError(String),
1231    /// The peer advertised a build-side [`PROTOCOL_VARIANT`] that does
1232    /// not match this build's. Today: a fips client meeting a non-fips
1233    /// server, or vice versa.
1234    #[error("protocol variant mismatch (expected {expected:?}, received {received:?})")]
1235    ProtocolVariantMismatch {
1236        expected: Vec<u8>,
1237        received: Vec<u8>,
1238    },
1239}
1240
1241impl From<HandshakeError> for CoreError {
1242    fn from(err: HandshakeError) -> Self {
1243        CoreError::InternalError(err.to_string())
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    /// Byte-exact freeze of the handshake transcript hash (Phase 6).
1252    ///
1253    /// Pins `SHA256(borsh(HandshakeTranscript))` over a fully-deterministic
1254    /// `ClientHello` + server fields. Unlike the public wire-codec vectors in
1255    /// `core/tests/wire_vectors.rs`, this exercises the *real* private
1256    /// `HandshakeTranscript` and `compute_transcript_hash`, so a reorder of the
1257    /// transcript fields or any change to the hash construction — the signing
1258    /// input, Invariants 7 & 10 — fails here. The crypto material is
1259    /// deterministic filler of the real field lengths; the hash needs no live
1260    /// keys. Default (non-fips) build only (the fips transcript embeds a
1261    /// different `PROTOCOL_VARIANT` and 65-byte classical key).
1262    ///
1263    /// Regenerate alongside the wire vectors with
1264    /// `PHANTOM_REGEN_WIRE_VECTORS=1 cargo test --manifest-path core/Cargo.toml --lib`.
1265    #[cfg(not(feature = "fips"))]
1266    #[test]
1267    fn transcript_hash_wire_vector() {
1268        fn pat(seed: u8, n: usize) -> Vec<u8> {
1269            (0..n).map(|i| seed.wrapping_add(i as u8)).collect()
1270        }
1271        fn arr32(seed: u8) -> [u8; 32] {
1272            pat(seed, 32).try_into().expect("pat(seed, 32) is 32 bytes")
1273        }
1274
1275        // Same deterministic filler as the `client_hello_full` / `server_hello`
1276        // vectors so the three freezes describe one consistent handshake.
1277        let key_package = HybridKeyPackage {
1278            classical_pk: arr32(0x10),
1279            ml_kem_pk: pat(0x20, 1184),
1280        };
1281        let verify_key = HybridVerifyingKey {
1282            ed25519_pk: arr32(0x50),
1283            ml_dsa_pk: pat(0x60, 1952),
1284        };
1285        let client_hello = ClientHello {
1286            client_key_package: key_package.clone(),
1287            client_verify_key: verify_key.clone(),
1288            nonce: arr32(0xA0),
1289            version: PROTOCOL_VERSION,
1290            cookie: Some(arr32(0xB0)),
1291            pow_solution: Some(PoWSolution {
1292                nonce: arr32(0x90),
1293                solution: 0x0123_4567_89AB_CDEF,
1294            }),
1295            resume_session_id: Some(arr32(0xC0)),
1296            resumption_binder: Some(arr32(0xC8)),
1297            protocol_variant: PROTOCOL_VARIANT.to_vec(),
1298            early_data: Some(pat(0xD0, 48)),
1299        };
1300        let ciphertext = HybridCiphertext {
1301            classical_pk: arr32(0x30),
1302            ml_kem_ct: pat(0x40, 1088),
1303        };
1304        let session_id = arr32(0xE0);
1305
1306        let transcript = HandshakeTranscript {
1307            protocol_variant: PROTOCOL_VARIANT,
1308            client_hello: &client_hello,
1309            server_key_package: &key_package,
1310            ciphertext: &ciphertext,
1311            server_verify_key: &verify_key,
1312            session_id: &session_id,
1313            early_data_accepted: true,
1314        };
1315        let hash = compute_transcript_hash(&transcript).expect("transcript hash");
1316
1317        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1318            .join("tests/wire_vectors/transcript_hash.bin");
1319        if std::env::var_os("PHANTOM_REGEN_WIRE_VECTORS").is_some() {
1320            std::fs::create_dir_all(path.parent().expect("fixtures dir parent"))
1321                .expect("create wire_vectors dir");
1322            std::fs::write(&path, hash).expect("write transcript_hash.bin");
1323            return;
1324        }
1325        let expected = std::fs::read(&path)
1326            .expect("read transcript_hash.bin; regenerate with PHANTOM_REGEN_WIRE_VECTORS=1");
1327        assert_eq!(
1328            hash.as_slice(),
1329            expected.as_slice(),
1330            "handshake transcript hash changed — the signing input (Invariants 7 & 10) is \
1331             wire-breaking. If intentional, bump PROTOCOL_VERSION and regenerate."
1332        );
1333    }
1334
1335    /// A `ClientHello` advertising a foreign `PROTOCOL_VARIANT`
1336    /// (simulating a fips/non-fips cross-mode connect) is rejected by
1337    /// the server with [`HandshakeError::ProtocolVariantMismatch`]
1338    /// before any KEM / signature work is done.
1339    #[tokio::test]
1340    async fn protocol_variant_mismatch_rejected() {
1341        let server = HandshakeServer::new().expect("HandshakeServer::new");
1342        let client = HandshakeClient::new().expect("HandshakeClient::new");
1343        let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1344
1345        let mut hello = client.create_client_hello();
1346        // Pretend the peer was compiled with a different feature set.
1347        hello.protocol_variant = b"phantom-some-other-mode-1".to_vec();
1348
1349        let response = server.process_client_hello(&hello, 0, client_ip);
1350        match response {
1351            HandshakeResponse::Fail(HandshakeError::ProtocolVariantMismatch {
1352                expected,
1353                received,
1354            }) => {
1355                assert_eq!(expected, PROTOCOL_VARIANT);
1356                assert_eq!(received, b"phantom-some-other-mode-1");
1357            }
1358            other => panic!("expected ProtocolVariantMismatch, got {other:?}"),
1359        }
1360    }
1361
1362    /// H9 forward-compat: a `ClientHello` advertising a `version` the server
1363    /// does not speak is answered with a typed [`HandshakeResponse::Reject`]
1364    /// (carrying the server's supported version), not a silent drop / generic
1365    /// `Fail`. The reject is produced before any KEM / signature work.
1366    #[tokio::test]
1367    async fn unsupported_version_yields_typed_reject() {
1368        let server = HandshakeServer::new().expect("HandshakeServer::new");
1369        let client = HandshakeClient::new().expect("HandshakeClient::new");
1370        let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1371
1372        let mut hello = client.create_client_hello();
1373        // A future client speaking a version this build doesn't know.
1374        hello.version = PROTOCOL_VERSION.wrapping_add(7);
1375
1376        match server.process_client_hello(&hello, 0, client_ip) {
1377            HandshakeResponse::Reject(reject) => {
1378                assert!(reject.has_marker(), "reject must carry the marker");
1379                assert_eq!(reject.code, REJECT_UNSUPPORTED_VERSION);
1380                assert_eq!(reject.supported_version, PROTOCOL_VERSION);
1381            }
1382            other => panic!("expected Reject, got {other:?}"),
1383        }
1384    }
1385
1386    /// The reject frame survives a borsh round-trip and is shape-distinct from
1387    /// a `HelloRetryRequest` (the client's trial-deserialization order relies
1388    /// on this — a reject must not be mistaken for a retry, nor vice versa).
1389    #[test]
1390    fn server_reject_roundtrips_and_is_shape_distinct() {
1391        let reject = ServerReject::unsupported_version();
1392        let bytes = borsh::to_vec(&reject).expect("encode reject");
1393        let decoded: ServerReject = borsh::from_slice(&bytes).expect("decode reject");
1394        assert_eq!(decoded, reject);
1395        assert!(decoded.has_marker());
1396
1397        // A (None, None) HelloRetryRequest must not decode as a reject…
1398        let hrr = HelloRetryRequest {
1399            challenge: None,
1400            cookie: None,
1401        };
1402        let hrr_bytes = borsh::to_vec(&hrr).expect("encode hrr");
1403        assert!(
1404            borsh::from_slice::<ServerReject>(&hrr_bytes).is_err(),
1405            "a HelloRetryRequest must not parse as a ServerReject"
1406        );
1407        // …and a reject must not decode as a HelloRetryRequest.
1408        assert!(
1409            borsh::from_slice::<HelloRetryRequest>(&bytes).is_err(),
1410            "a ServerReject must not parse as a HelloRetryRequest"
1411        );
1412    }
1413
1414    /// Tampering with the cleartext `protocol_variant` to match the
1415    /// server's value (an MITM bypass attempt) is caught by the
1416    /// transcript signature: the transcript still binds the *real*
1417    /// build-side `PROTOCOL_VARIANT` on each side, so a mixed-mode
1418    /// signature does not verify. This test exercises the matching
1419    /// path on the same build (cannot actually run mixed-mode in a
1420    /// single binary) — we just confirm a normal handshake works
1421    /// with the variant intact.
1422    #[tokio::test]
1423    async fn handshake_succeeds_with_matching_protocol_variant() {
1424        let server = HandshakeServer::new().expect("HandshakeServer::new");
1425        let client = HandshakeClient::new().expect("HandshakeClient::new");
1426        let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1427        let hello = client.create_client_hello();
1428        assert_eq!(hello.protocol_variant, PROTOCOL_VARIANT);
1429        // First round: server demands cookie.
1430        let response = server.process_client_hello(&hello, 0, client_ip);
1431        let cookie = match response {
1432            HandshakeResponse::Retry(r) => r.cookie.expect("cookie"),
1433            other => panic!("expected retry, got {other:?}"),
1434        };
1435        let mut hello_retry = hello.clone();
1436        hello_retry.cookie = Some(cookie);
1437        match server.process_client_hello(&hello_retry, 0, client_ip) {
1438            HandshakeResponse::Success(..) => {}
1439            other => panic!("expected success, got {other:?}"),
1440        }
1441    }
1442
1443    #[tokio::test]
1444    async fn test_unified_handshake() {
1445        let server = HandshakeServer::new().expect("HandshakeServer::new");
1446        let client = HandshakeClient::new().expect("HandshakeClient::new");
1447        let client_ip = "127.0.0.1".parse().expect("parse client_ip");
1448
1449        // 1. Initial Hello
1450        let hello = client.create_client_hello();
1451
1452        // 2. Server Retry (Cookie)
1453        let response = server.process_client_hello(&hello, 0, client_ip);
1454        let cookie = match response {
1455            HandshakeResponse::Retry(r) => r.cookie.unwrap(),
1456            _ => panic!("Expected retry"),
1457        };
1458
1459        // 3. Retry with Cookie
1460        let mut hello_retry = hello.clone();
1461        hello_retry.cookie = Some(cookie);
1462        let response = server.process_client_hello(&hello_retry, 0, client_ip);
1463
1464        let (server_hello, _server_session) = match response {
1465            HandshakeResponse::Success(h, s, _) => (h, s),
1466            _ => panic!("Expected success"),
1467        };
1468
1469        // 4. Client Process
1470        let _client_session = client
1471            .process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
1472            .unwrap();
1473        assert_eq!(*client.stage.read(), HandshakeStage::Established);
1474    }
1475
1476    /// Phase 4.1 — after a successful handshake, the server caches a
1477    /// ticket keyed on the negotiated session id, and the resulting
1478    /// `Session` exposes a `resumption_hint` so the client can store
1479    /// it for a future connect.
1480    #[tokio::test]
1481    async fn first_handshake_caches_ticket_and_exposes_hint() {
1482        let server = HandshakeServer::new().expect("HandshakeServer::new");
1483        let client = HandshakeClient::new().expect("HandshakeClient::new");
1484        let client_ip = "127.0.0.1".parse().unwrap();
1485
1486        let hello = client.create_client_hello();
1487        let cookie = match server.process_client_hello(&hello, 0, client_ip) {
1488            HandshakeResponse::Retry(r) => r.cookie.unwrap(),
1489            _ => panic!("expected retry"),
1490        };
1491        let mut hello_retry = hello.clone();
1492        hello_retry.cookie = Some(cookie);
1493        let (server_hello, server_session) =
1494            match server.process_client_hello(&hello_retry, 0, client_ip) {
1495                HandshakeResponse::Success(h, s, _) => (h, s),
1496                _ => panic!("expected success"),
1497            };
1498        let (client_session, _) = client
1499            .process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
1500            .unwrap();
1501
1502        // Server now has exactly one ticket.
1503        assert_eq!(server.session_cache_len(), 1);
1504        // Both sides expose a `resumption_hint`. The session id and
1505        // resumption secret match between client and server.
1506        let s_hint = server_session.resumption_hint().expect("server hint");
1507        let c_hint = client_session.resumption_hint().expect("client hint");
1508        assert_eq!(s_hint.0, c_hint.0, "session id matches across sides");
1509        assert_eq!(s_hint.1, c_hint.1, "resumption secret matches");
1510    }
1511
1512    /// Phase 4.1 — a ClientHello carrying a cached `resume_session_id`
1513    /// bypasses the cookie/PoW DoS gate (it goes straight to success
1514    /// on the first call, with no Retry). The full KEM still runs so
1515    /// PFS is preserved.
1516    #[tokio::test]
1517    async fn cached_resume_session_id_skips_cookie_and_pow() {
1518        let server = HandshakeServer::new().expect("HandshakeServer::new");
1519        let client_ip = "127.0.0.1".parse().unwrap();
1520
1521        // Drive a full handshake to populate the cache.
1522        let first_client = HandshakeClient::new().unwrap();
1523        let first_hello = first_client.create_client_hello();
1524        let cookie = match server.process_client_hello(&first_hello, 0, client_ip) {
1525            HandshakeResponse::Retry(r) => r.cookie.unwrap(),
1526            _ => panic!("expected retry"),
1527        };
1528        let mut hello_retry = first_hello.clone();
1529        hello_retry.cookie = Some(cookie);
1530        let (_first_server_hello, first_server_session) =
1531            match server.process_client_hello(&hello_retry, 0, client_ip) {
1532                HandshakeResponse::Success(h, s, _) => (h, s),
1533                _ => panic!("expected success"),
1534            };
1535        let (resume_id, resume_secret) = first_server_session.resumption_hint().unwrap();
1536
1537        // Second client offers the resume_session_id WITHOUT a cookie.
1538        // Server should accept immediately (no Retry).
1539        let second_client = HandshakeClient::new().unwrap();
1540        let resume_hello =
1541            second_client.create_client_hello_with_resume(resume_id, &resume_secret, None);
1542        match server.process_client_hello(&resume_hello, 0, client_ip) {
1543            HandshakeResponse::Success(..) => {} // expected
1544            HandshakeResponse::Retry(_) => {
1545                panic!("resume_session_id should bypass cookie/PoW gate")
1546            }
1547            HandshakeResponse::Reject(r) => panic!("unexpected reject: {:?}", r),
1548            HandshakeResponse::Fail(e) => panic!("unexpected failure: {:?}", e),
1549        }
1550    }
1551
1552    /// Phase 4.1 — unknown `resume_session_id` does NOT bypass cookie.
1553    /// The server simply ignores the unknown id and falls through to
1554    /// the normal cookie/PoW path.
1555    #[tokio::test]
1556    async fn unknown_resume_session_id_does_not_bypass_cookie() {
1557        let server = HandshakeServer::new().unwrap();
1558        let client = HandshakeClient::new().unwrap();
1559        let client_ip = "127.0.0.1".parse().unwrap();
1560
1561        // An id the server has never seen.
1562        let bogus_id = [0xFFu8; 32];
1563        let hello = client.create_client_hello_with_resume(bogus_id, &[0u8; 32], None);
1564        match server.process_client_hello(&hello, 0, client_ip) {
1565            HandshakeResponse::Retry(_) => {} // expected — normal cookie flow
1566            other => panic!(
1567                "expected Retry for unknown resume id, got {:?}",
1568                matches!(other, HandshakeResponse::Success(..)),
1569            ),
1570        }
1571    }
1572
1573    // ── 0-RTT early-data ──
1574
1575    /// Drive a full handshake and return the resumption hint the server
1576    /// minted for it — the `(session_id, resumption_secret)` a resuming
1577    /// client needs.
1578    fn first_handshake_for_hint(
1579        server: &HandshakeServer,
1580        client_ip: std::net::IpAddr,
1581    ) -> ([u8; 32], [u8; 32]) {
1582        let client = HandshakeClient::new().unwrap();
1583        let hello = client.create_client_hello();
1584        let cookie = match server.process_client_hello(&hello, 0, client_ip) {
1585            HandshakeResponse::Retry(r) => r.cookie.unwrap(),
1586            _ => panic!("expected retry"),
1587        };
1588        let mut retry = hello.clone();
1589        retry.cookie = Some(cookie);
1590        match server.process_client_hello(&retry, 0, client_ip) {
1591            HandshakeResponse::Success(_, session, _) => session.resumption_hint().unwrap(),
1592            _ => panic!("expected success"),
1593        }
1594    }
1595
1596    #[tokio::test]
1597    async fn early_data_round_trip() {
1598        let server = HandshakeServer::new().unwrap();
1599        let client_ip = "127.0.0.1".parse().unwrap();
1600        let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
1601
1602        // Second connect: resume + a 0-RTT early-data payload folded into the
1603        // single ClientHello.
1604        let client = HandshakeClient::new().unwrap();
1605        let early_payload = b"zero-rtt application bytes";
1606        let hello =
1607            client.create_client_hello_with_resume(resume_id, &resume_secret, Some(early_payload));
1608
1609        match server.process_client_hello(&hello, 0, client_ip) {
1610            HandshakeResponse::Success(sh, _session, early_data) => {
1611                assert!(sh.early_data_accepted, "server accepted the early-data");
1612                assert_eq!(
1613                    early_data.as_deref(),
1614                    Some(&early_payload[..]),
1615                    "server decrypted the exact payload the client sealed"
1616                );
1617                // The client verifies the ServerHello and learns the same
1618                // verdict.
1619                let (_session, accepted) = client
1620                    .process_server_hello(&hello, &sh, Some(server.verifying_key()))
1621                    .expect("client verifies the ServerHello");
1622                assert_eq!(accepted, Some(true), "client sees early-data accepted");
1623            }
1624            other => panic!(
1625                "expected Success with accepted early-data, got {}",
1626                match other {
1627                    HandshakeResponse::Retry(_) => "Retry",
1628                    HandshakeResponse::Reject(_) => "Reject",
1629                    HandshakeResponse::Fail(_) => "Fail",
1630                    HandshakeResponse::Success(..) => unreachable!(),
1631                }
1632            ),
1633        }
1634    }
1635
1636    #[tokio::test]
1637    async fn oversized_early_data_rejected_but_handshake_succeeds() {
1638        let server = HandshakeServer::new().unwrap();
1639        let client_ip = "127.0.0.1".parse().unwrap();
1640        let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
1641
1642        // A blob whose sealed length exceeds EARLY_DATA_MAX_LEN + tag.
1643        let huge = vec![0u8; EARLY_DATA_MAX_LEN + 1];
1644        let client = HandshakeClient::new().unwrap();
1645        let hello = client.create_client_hello_with_resume(resume_id, &resume_secret, Some(&huge));
1646
1647        match server.process_client_hello(&hello, 0, client_ip) {
1648            HandshakeResponse::Success(sh, _session, early_data) => {
1649                assert!(!sh.early_data_accepted, "oversized blob rejected");
1650                assert!(early_data.is_none(), "no plaintext surfaces");
1651            }
1652            _ => panic!("handshake must still succeed as 1-RTT"),
1653        }
1654    }
1655
1656    #[tokio::test]
1657    async fn corrupted_early_data_rejected_but_handshake_succeeds() {
1658        let server = HandshakeServer::new().unwrap();
1659        let client_ip = "127.0.0.1".parse().unwrap();
1660        let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
1661
1662        // Build a resume ClientHello, then replace the sealed blob with
1663        // in-range garbage — AEAD verification must fail.
1664        let client = HandshakeClient::new().unwrap();
1665        let mut hello = client.create_client_hello_with_resume(resume_id, &resume_secret, None);
1666        hello.early_data = Some(vec![0xFFu8; 128]);
1667
1668        match server.process_client_hello(&hello, 0, client_ip) {
1669            HandshakeResponse::Success(sh, _session, early_data) => {
1670                assert!(!sh.early_data_accepted, "AEAD failure → rejected");
1671                assert!(early_data.is_none());
1672            }
1673            _ => panic!("handshake must still succeed as 1-RTT"),
1674        }
1675    }
1676
1677    #[tokio::test]
1678    async fn unknown_ticket_with_early_data_falls_back_to_cookie_retry() {
1679        // A ClientHello whose resume_session_id the server has never seen
1680        // gets no cookie/PoW bypass — and with no cookie attached, the server
1681        // demands one via Retry. The undecryptable early-data is ignored.
1682        let server = HandshakeServer::new().unwrap();
1683        let client_ip = "127.0.0.1".parse().unwrap();
1684        let client = HandshakeClient::new().unwrap();
1685        let hello = client.create_client_hello_with_resume([0xAB; 32], &[0xCD; 32], Some(b"hi"));
1686        assert!(
1687            matches!(
1688                server.process_client_hello(&hello, 0, client_ip),
1689                HandshakeResponse::Retry(_)
1690            ),
1691            "unknown ticket → no bypass → cookie Retry"
1692        );
1693    }
1694
1695    /// **DOS-2.** The `HandshakeServer` wires per-IP reputation: a clean IP (and
1696    /// a ticket holder) adds no PoW difficulty, repeated violations escalate it,
1697    /// and a successful-handshake reset clears it.
1698    #[test]
1699    fn reputation_wiring_escalates_and_resets_per_ip() {
1700        let server = HandshakeServer::new().unwrap();
1701        let ip: std::net::IpAddr = "203.0.113.7".parse().unwrap();
1702        assert_eq!(
1703            server.reputation_difficulty(ip, false),
1704            0,
1705            "clean IP adds nothing"
1706        );
1707        assert_eq!(
1708            server.reputation_difficulty(ip, true),
1709            0,
1710            "ticket holder skips PoW"
1711        );
1712        server.record_violation(ip);
1713        let d1 = server.reputation_difficulty(ip, false);
1714        assert!(
1715            d1 >= 8,
1716            "a violation escalates the per-IP difficulty, got {d1}"
1717        );
1718        server.record_violation(ip);
1719        assert!(
1720            server.reputation_difficulty(ip, false) >= d1,
1721            "more violations escalate further"
1722        );
1723        server.reset_violations(ip);
1724        assert_eq!(
1725            server.reputation_difficulty(ip, false),
1726            0,
1727            "reset clears it"
1728        );
1729    }
1730}