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}