From ae47d7eb635219f84fa655058da8442de7e27c17 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 17:57:54 -0400 Subject: [PATCH 1/7] =?UTF-8?q?docs(reality.5b):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20byte-matching=20ServerHello=20emission=20+=20server?= =?UTF-8?q?=20key=20schedule=20(4588+X25519)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7-17-reality-5b-serverhello-emit-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-reality-5b-serverhello-emit-design.md diff --git a/docs/superpowers/specs/2026-07-17-reality-5b-serverhello-emit-design.md b/docs/superpowers/specs/2026-07-17-reality-5b-serverhello-emit-design.md new file mode 100644 index 0000000..1b1b224 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-reality-5b-serverhello-emit-design.md @@ -0,0 +1,94 @@ +# REALITY.5b — byte-matching ServerHello emission + server key schedule — design spec + +**Date:** 2026-07-17 +**Status:** design (pending user review) +**Parent:** [`2026-07-15-reality-tls-milestone-design.md`](2026-07-15-reality-tls-milestone-design.md) — REALITY.5 (#76). +**Depends on:** REALITY.5a (`ServerFlightTemplate` capture + cache — **stacks on 5a / #83**), REALITY.2 (`yip-utls` key schedule / ML-KEM / combiner / `HelloWriter`). +**Scope:** `yip-utls` (server KEX + ServerHello emission + key derivation) + `yip-rendezvous` (extract the client's ML-KEM ek from the ClientHello). PR 2 of REALITY.5 (5a/5b/5c/5d). + +## Goal + +Emit a **byte-matching ServerHello** for an authed connection — structurally identical to the borrowed `dest`'s (captured in 5a) but keyed on the **relay's own** ephemeral (so the relay holds the session keys) — and derive the server-side TLS 1.3 handshake keys. This is the server-side counterpart to REALITY.2's client: 5b builds the ServerHello + key schedule; 5c emits the encrypted flight; 5d wires it into `tls_front`. + +## Why the relay's own key_share (not dest's) + +A passive DPI byte-matches the cleartext ServerHello structure. The relay CANNOT relay dest's ServerHello verbatim — dest's `key_share` value is dest's ephemeral, whose private key only dest holds, so the relay couldn't derive the session keys. So the relay emits dest's **structure** (cipher, ordered extensions incl. GREASE, chosen group) with **its own** `key_share` value and a fresh random — indistinguishable to a DPI (dest's `key_share`/random are per-connection random too), while the relay holds the keys. + +## Scope: key_share groups 4588 + X25519 only + +The relay keys the group `dest` selected. Our Chrome-faithful client sends `key_share`s only for **X25519MLKEM768 (4588)** + **X25519 (29)** (offering P256/P384 without key_shares), so a dest selecting 4588 or X25519 replies in 1-RTT. A dest selecting **P256/P384** would require a **HelloRetryRequest** (client sent no key_share for them) — **deferred to #84** (evaluate whether any real dest needs it). 5b supports 4588 + X25519; an SNI whose `dest` selects any other group degrades to splice-only (5d — fail-safe, no fidelity claim). + +## Design + +### 1. `yip-utls` — server KEX (`server.rs`, new module) + +```rust +/// The relay's server-side key exchange for a REALITY authed connection: given +/// the group `dest` selected and the client's key_share(s), generate the relay's +/// own ephemeral, and return the relay's `key_share` bytes (for the ServerHello) +/// + the ECDHE shared secret (for `derive_handshake_keys`). +pub fn server_key_share( + group: u16, + client_x25519: &[u8; 32], + client_mlkem_ek: Option<&[u8]>, // required for group 4588 + rng: &mut dyn RandomSource, +) -> Result<(Vec, Vec), Error> // (server_key_share_bytes, shared_secret) +``` + +- **Group 4588 (X25519MLKEM768):** generate an X25519 ephemeral; **ML-KEM Encapsulate** against `client_mlkem_ek` (`ml_kem::kem::Encapsulate` — the mirror of REALITY.2's client `Decapsulate`) → `(ct(1088), mlkem_ss)`; `x25519_ss = X25519(server_eph_priv, client_x25519)`. `server_key_share = ct ‖ x25519_pub(32)`; `shared = mlkem_ss ‖ x25519_ss` (the **same combiner order** REALITY.2's client uses — `combined = mlkem_ss ‖ x25519_ss`). Fail-closed on a wrong-length/undecodable `client_mlkem_ek`. +- **Group 29 (X25519):** generate an X25519 ephemeral; `server_key_share = x25519_pub(32)`; `shared = X25519(server_eph_priv, client_x25519)`. +- **Any other group:** `Err(Error::UnsupportedGroup)` (5d → splice-only). + +### 2. `yip-utls` — ServerHello emission + +```rust +/// Emit a byte-matching ServerHello handshake message from the captured +/// `ServerHelloShape`, substituting the per-connection values, and derive the +/// server-side handshake keys. Returns the ServerHello wire bytes (handshake +/// message: `0x02 ‖ u24 len ‖ body`) + the `HandshakeKeys`. +pub fn emit_server_hello( + shape: &ServerHelloShape, + client_hello_msg: &[u8], // the raw ClientHello handshake message (for the transcript) + client_legacy_session_id: &[u8], // echoed into the ServerHello + client_x25519: &[u8; 32], + client_mlkem_ek: Option<&[u8]>, + rng: &mut dyn RandomSource, +) -> Result<(Vec, HandshakeKeys), Error> +``` + +- Compute the server key_share via `server_key_share(shape.key_share_group, …)`. +- Rebuild the ServerHello body via `HelloWriter`, **verbatim from `shape`** except: + - **`random`**: a fresh 32 bytes from `rng`. + - **`legacy_session_id_echo`**: `client_legacy_session_id` (per-connection, from *this* ClientHello — NOT the template's). + - **`cipher_suite`** = `shape.cipher_suite`; **compression** = `shape.legacy_compression_method`. + - **extensions**: emit `shape.extensions` in order, verbatim, EXCEPT the `key_share` extension (id 0x0033) — replace its body with the relay's `server_key_share` bytes (`group(2) ‖ len(2) ‖ key_share_bytes`). All other extensions (supported_versions, GREASE, etc.) are copied byte-for-byte. + - Wrap as a handshake message (`0x02 ‖ u24 len ‖ body`). +- Derive keys: `transcript_ch_sh = transcript_hash(client_hello_msg ‖ server_hello_msg, shape.cipher_suite)`; `derive_handshake_keys(&shared, &transcript_ch_sh, shape.cipher_suite)` (reused as-is). The server seals its flight with `server_key/server_iv` and opens the client's records with `client_key/client_iv`. +- Return `(server_hello_msg, handshake_keys)`. + +### 3. `yip-rendezvous` — extract the client's ML-KEM ek + +`bin/yip-rendezvous/src/reality.rs`'s `ClientHelloInfo` has `key_share_x25519` but not the ML-KEM ek (needed for group-4588 encapsulation). Add `pub key_share_mlkem_ek: Option>` and extract it in `parse_client_hello` (the group-4588 `key_share` entry is `mlkem_ek(1184) ‖ x25519(32)` — REALITY.2's `hello::key_share_body` documents this layout). Fail-closed on wrong length (→ `None`). This is what 5d feeds to `emit_server_hello`; 5b adds the field + a parse test. + +## Testing / adversary + +- **Unit (KEX):** `server_key_share` for 4588 returns a `ct(1088)‖x25519(32)` key_share + a 64-byte combined secret; for 29 a 32-byte key_share + 32-byte secret; an unsupported group → `Err`. +- **Round-trip (the key proof):** the relay `emit_server_hello`s from a fixture `ServerHelloShape` (both a 4588 and a 29 fixture); a test **client** — REALITY.2's own `parse_server_hello` + the client KEX (decapsulate/DH) + `derive_handshake_keys` — parses the emitted ServerHello and derives the **same** `HandshakeKeys` (`server_key`/`client_key` byte-equal both sides). This proves the ServerHello is well-formed AND both sides agree on the handshake keys (the actual goal). +- **Byte-match:** the emitted ServerHello's cipher/compression/extension-order (incl. GREASE) equal the `shape`'s; only `random`, `legacy_session_id_echo`, and the `key_share` value differ. Assert by re-parsing with `parse_server_hello_shape` and diffing against the source shape (modulo the substituted fields). +- **Unit (parse):** `parse_client_hello` extracts `key_share_mlkem_ek` from a fixture group-4588 ClientHello; wrong length → `None`. +- Existing REALITY.2 client tests + JA4 diff unchanged (5b is server-side, additive). + +## Risks +- **New server-side crypto** (ML-KEM Encapsulate, server key schedule) — security-sensitive. Mitigation: reuse REALITY.2's audited key schedule + combiner unchanged; the round-trip test against REALITY.2's own client is the correctness gate (both sides must agree or no test passes). `forbid-unsafe`, no new crypto primitives. +- **Template drift:** if `dest`'s ServerHello structure changes between capture and emit, the emitted ServerHello matches the last captured template, not dest's live one (accepted; refresh mitigates — 5a). +- **Group mismatch:** dest selected a group outside {4588, 29} → `Err(UnsupportedGroup)` → 5d degrades to splice (fail-safe; tracked by #84). + +## Non-goals (later REALITY.5 sub-milestones) +- Emitting the encrypted flight (EncryptedExtensions/Certificate[padded]/CertificateVerify/Finished) + the server stream (5c); the middlebox-compat CCS emission (5c, per the 5a hand-off note); wiring into `tls_front` (5d). +- P256/P384 server KEX + HelloRetryRequest (#84). + +## Success criteria +1. `yip_utls::server_key_share` keys groups 4588 + X25519 (ML-KEM Encapsulate + X25519), producing the relay's key_share + the ECDHE shared secret with the same combiner as REALITY.2's client; unsupported group → `Err`. +2. `yip_utls::emit_server_hello` emits a ServerHello byte-matching the captured `ServerHelloShape` (verbatim cipher/extensions/GREASE; only random/session_id-echo/key_share substituted) and derives `HandshakeKeys`; a REALITY.2 client parsing it derives the **identical** keys (round-trip proven). +3. `yip-rendezvous`'s `ClientHelloInfo` extracts the client's ML-KEM ek (for 5d's group-4588 wiring); fail-closed on malformed. +4. `forbid-unsafe` (outside yip-io/yip-device); no `as` casts; clippy clean. No emission of the encrypted flight (5c) and no `tls_front` wiring (5d) here. From c2d9099a81e280b00cc93c5e1ce829c31bb23438 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 18:01:09 -0400 Subject: [PATCH 2/7] docs(reality.5b): implementation plan (3 tasks, subagent-driven) --- .../2026-07-17-reality-5b-serverhello-emit.md | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-reality-5b-serverhello-emit.md diff --git a/docs/superpowers/plans/2026-07-17-reality-5b-serverhello-emit.md b/docs/superpowers/plans/2026-07-17-reality-5b-serverhello-emit.md new file mode 100644 index 0000000..64421b3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-reality-5b-serverhello-emit.md @@ -0,0 +1,425 @@ +# REALITY.5b — ServerHello Emission + Server Key Schedule Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Emit a byte-matching ServerHello for an authed REALITY connection (dest's structure, the relay's own key_share) and derive the server-side TLS 1.3 handshake keys — for groups X25519MLKEM768 (4588) + X25519 (29). + +**Architecture:** A new `yip_utls::server` module: `server_key_share` does the relay's server-side KEX (ML-KEM Encapsulate + X25519, reusing REALITY.2's exact hybrid combiner), and `emit_server_hello` rebuilds the ServerHello verbatim from the captured `ServerHelloShape` (substituting a fresh random, the client's session_id echo, and the relay's key_share value) then derives keys via the reused `derive_handshake_keys`. `yip-rendezvous`'s ClientHello parser gains the client's ML-KEM ek (needed for 4588 encapsulation). + +**Tech Stack:** Rust; `yip_utls` (reuses `handshake::derive_handshake_keys`/`transcript_hash`/`parse_server_hello`, `wire::HelloWriter`, the `x25519-dalek`/`ml-kem` primitives + combiner); `yip-rendezvous` (ClientHello parse). + +## Global Constraints + +- `#![forbid(unsafe_code)]` everywhere except `yip-io`/`yip-device` — NO `unsafe`. +- NO `as` numeric casts — use `try_from`/`to_be_bytes`/`from_be_bytes`/`usize::from`. +- NO bare `#[allow(...)]` — use `#[expect(reason = "...")]`. +- **Reuse REALITY.2's key schedule + hybrid combiner UNCHANGED.** The combiner is `shared = mlkem_ss ‖ x25519_ss` (ML-KEM first, 64 bytes for 4588; 32 bytes for X25519). `derive_handshake_keys(ecdhe, transcript_hash_ch_sh, suite)` is reused as-is. +- **Scope: groups 4588 + X25519 ONLY.** Any other group → `Error::UnsupportedGroup`. P256/P384 + HelloRetryRequest are deferred to #84. +- **No encrypted-flight emission (5c) and no `tls_front` wiring (5d)** in this milestone. 5b is the ServerHello + key-schedule primitive + the ClientHello ek extraction. +- The client's key_share values that `emit_server_hello`/`server_key_share` consume are passed in as params; 5d feeds the real parsed client values. +- Every task ends green: relevant `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt`. +- REALITY.2 client tests + JA4 diff stay green (5b is additive/server-side). +- Stacked on 5a. **Never merge the PR** — open it, leave it for the user. + +**Spec:** `docs/superpowers/specs/2026-07-17-reality-5b-serverhello-emit-design.md`. Read it before starting. + +**Ground-truth constants (in `crates/yip-utls/src/handshake.rs`):** `GROUP_X25519 = 0x001d`, `GROUP_X25519MLKEM768 = 4588`, `MLKEM768_CIPHERTEXT_LEN = 1088`, `EXT_KEY_SHARE = 51`; `MLKEM768_EK_LEN = 1184` (in `hello.rs`). The X25519 public/shared are 32 bytes. + +--- + +### Task 1: Server KEX — `server_key_share` (`yip-utls::server`) + +The relay's server-side key exchange: generate its ephemeral, return its key_share bytes + the ECDHE shared secret. + +**Files:** +- Create: `crates/yip-utls/src/server.rs` +- Modify: `crates/yip-utls/src/lib.rs` (`pub mod server;` + re-export) +- Modify: `crates/yip-utls/src/error.rs` (add `UnsupportedGroup`) +- Modify: `crates/yip-utls/src/handshake.rs` (make `GROUP_X25519`/`GROUP_X25519MLKEM768`/`MLKEM768_CIPHERTEXT_LEN` `pub(crate)` if not already, so `server.rs` can use them — they're already `pub`/`const`; confirm) + +**Interfaces:** +- Produces: `pub fn server_key_share(group: u16, client_x25519: &[u8; 32], client_mlkem_ek: Option<&[u8]>, rng: &mut dyn RandomSource) -> Result<(Vec, Vec), Error>` — returns `(server_key_share_bytes, shared_secret)`. +- Produces: `Error::UnsupportedGroup`. + +- [ ] **Step 1: Add `Error::UnsupportedGroup`** + +In `crates/yip-utls/src/error.rs`, add to the enum: `UnsupportedGroup(u16)`, its `Display` arm (`Error::UnsupportedGroup(g) => write!(f, "REALITY server cannot key TLS group {g}")`), and `None` in `source()`. + +- [ ] **Step 2: Write the failing KEX tests** + +Create `crates/yip-utls/src/server.rs` with a test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::hello::RandomSource; // or wherever RandomSource lives — match the crate + + // A deterministic test RNG (mirror the one hello.rs/stream.rs tests use). + struct SeqRng(u8); + impl RandomSource for SeqRng { + fn fill(&mut self, buf: &mut [u8]) { for b in buf.iter_mut() { *b = self.0; self.0 = self.0.wrapping_add(1); } } + } + + #[test] + fn server_key_share_x25519_shapes() { + let client_x = [7u8; 32]; + let (ks, ss) = server_key_share(0x001d, &client_x, None, &mut SeqRng(1)).unwrap(); + assert_eq!(ks.len(), 32); // server x25519 public + assert_eq!(ss.len(), 32); // x25519 shared + } + + #[test] + fn server_key_share_4588_shapes() { + // A real client ML-KEM ek (generate one, as the client does). + use ml_kem::{KemCore, MlKem768, EncodedSizeUser}; + let mut r = crate::stream::MlKemRng::default(); // reuse the crate's ml-kem RNG bridge if present; else a test RNG + let (_dk, ek) = MlKem768::generate(&mut r); + let ek_bytes = ek.as_bytes().to_vec(); + let client_x = [9u8; 32]; + let (ks, ss) = server_key_share(4588, &client_x, Some(&ek_bytes), &mut SeqRng(1)).unwrap(); + assert_eq!(ks.len(), 1088 + 32); // ct ‖ x25519 public + assert_eq!(ss.len(), 64); // mlkem_ss(32) ‖ x25519_ss(32) + } + + #[test] + fn server_key_share_rejects_unsupported_group_and_missing_ek() { + assert!(matches!(server_key_share(23, &[0;32], None, &mut SeqRng(1)), Err(Error::UnsupportedGroup(23)))); + assert!(server_key_share(4588, &[0;32], None, &mut SeqRng(1)).is_err()); // 4588 needs ek + } +} +``` + +Adjust the `RandomSource`/`MlKemRng` imports to the crate's actual paths (read `hello.rs`/`stream.rs` for the exact `RandomSource` trait + how the client builds the ML-KEM keypair). + +- [ ] **Step 3: Run to verify they fail** + +Run: `cargo test -p yip-utls server::tests` +Expected: FAIL — `cannot find function server_key_share`. + +- [ ] **Step 4: Implement `server_key_share`** + +```rust +//! REALITY.5b: the relay's server-side TLS 1.3 key exchange for an authed +//! connection — the mirror of `yip_utls`'s client KEX (`stream.rs`). Generates +//! the relay's own ephemeral so the relay holds the session keys, while the +//! ServerHello it goes into (see `emit_server_hello`) byte-matches dest's. +use crate::error::Error; +use crate::handshake::{GROUP_X25519, GROUP_X25519MLKEM768, MLKEM768_CIPHERTEXT_LEN}; +use crate::hello::RandomSource; // the crate's RandomSource trait +use ml_kem::kem::Encapsulate; +use ml_kem::{EncodedSizeUser, KemCore, MlKem768}; + +/// The relay's server-side KEX for `group`. Returns `(server_key_share_bytes, +/// shared_secret)`: the key_share to put in the ServerHello, and the ECDHE +/// secret for `derive_handshake_keys`. `shared_secret` uses REALITY.2's exact +/// combiner (`mlkem_ss ‖ x25519_ss` for 4588). Groups other than 4588/29 → +/// `Err(UnsupportedGroup)` (#84 defers those). +pub fn server_key_share( + group: u16, + client_x25519: &[u8; 32], + client_mlkem_ek: Option<&[u8]>, + rng: &mut dyn RandomSource, +) -> Result<(Vec, Vec), Error> { + // Fresh X25519 ephemeral (raw bytes → x25519-dalek, as the client does). + let mut eph = [0u8; 32]; + rng.fill(&mut eph); + let server_secret = x25519_dalek::StaticSecret::from(eph); + let server_x25519_pub = x25519_dalek::PublicKey::from(&server_secret).to_bytes(); + let x25519_ss = server_secret + .diffie_hellman(&x25519_dalek::PublicKey::from(*client_x25519)) + .to_bytes(); + + match group { + GROUP_X25519 => Ok((server_x25519_pub.to_vec(), x25519_ss.to_vec())), + GROUP_X25519MLKEM768 => { + let ek_bytes = client_mlkem_ek + .ok_or(Error::Protocol("group 4588 requires the client's ML-KEM ek"))?; + // Decode the client's encapsulation key, encapsulate against it. + let encoded = ml_kem::Encoded::< + ::EncapsulationKey, + >::try_from(ek_bytes) + .map_err(|_| Error::Protocol("client ML-KEM ek is the wrong length"))?; + let ek = ::EncapsulationKey::from_bytes(&encoded); + // ml-kem's Encapsulate needs an RNG; bridge our RandomSource (reuse + // the crate's MlKemRng adapter from stream.rs, or a small local one). + let mut kem_rng = /* the crate's RandomSource→RngCore bridge */; + let (ct, mlkem_ss) = ek + .encapsulate(&mut kem_rng) + .map_err(|_| Error::Protocol("ML-KEM encapsulation failed"))?; + // server key_share = ct(1088) ‖ x25519_pub(32). + let mut ks = Vec::with_capacity(MLKEM768_CIPHERTEXT_LEN + 32); + ks.extend_from_slice(ct.as_slice()); + ks.extend_from_slice(&server_x25519_pub); + // shared = mlkem_ss ‖ x25519_ss (REALITY.2's combiner order). + let mut ss = Vec::with_capacity(64); + ss.extend_from_slice(mlkem_ss.as_slice()); + ss.extend_from_slice(&x25519_ss); + Ok((ks, ss)) + } + other => Err(Error::UnsupportedGroup(other)), + } +} +``` + +IMPLEMENTER: the ML-KEM `Encapsulate` RNG bridge — reuse the exact `RandomSource`→`RngCore` adapter `stream.rs` already uses for `MlKem768::generate` (find it; it's the `MlKemRng`/latched-error bridge). Confirm the `Encoded`/`from_bytes`/`encapsulate` call shapes against `ml-kem` 0.2.3 (the client `decapsulate` side in `stream.rs` is the mirror — match its types). The three tests (shapes + reject) are the gate; make them pass for real. + +- [ ] **Step 5: Run to verify they pass + clippy + fmt + commit** + +Run: `cargo test -p yip-utls server` → PASS. + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt -p yip-utls +git add crates/yip-utls/src/server.rs crates/yip-utls/src/lib.rs crates/yip-utls/src/error.rs +git commit -m "feat(reality.5b): server_key_share — server-side KEX (ML-KEM Encapsulate + X25519, 4588/29)" +``` + +--- + +### Task 2: `emit_server_hello` + round-trip proof (`yip-utls::server`) + +Rebuild the ServerHello from the captured shape and derive server keys; prove a REALITY.2 client derives the same keys. + +**Files:** +- Modify: `crates/yip-utls/src/server.rs` (`emit_server_hello`) + +**Interfaces:** +- Consumes: `server_key_share` (Task 1); `ServerHelloShape` (5a); `wire::HelloWriter`; `handshake::{transcript_hash, derive_handshake_keys, HandshakeKeys}`. +- Produces: `pub fn emit_server_hello(shape, client_hello_msg, client_legacy_session_id, client_x25519, client_mlkem_ek, rng) -> Result<(Vec, HandshakeKeys), Error>`. + +- [ ] **Step 1: Write the failing round-trip + byte-match tests** + +Add to `server.rs` tests. Build a `ServerHelloShape` fixture (both a 4588 and a 29 variant — reuse Task-1-style helpers / the 5a `build_test_server_hello` shape helpers), a client ML-KEM keypair + X25519 keypair, and a stand-in `client_hello_msg`: + +```rust + #[test] + fn emit_server_hello_roundtrips_x25519() { roundtrip(0x001d); } + #[test] + fn emit_server_hello_roundtrips_4588() { roundtrip(4588); } + + fn roundtrip(group: u16) { + // Client keypairs (as connect generates them). + let mut mlkem_rng = /* crate MlKemRng */; + let (client_dk, client_ek) = MlKem768::generate(&mut mlkem_rng); + let client_ek_bytes = client_ek.as_bytes().to_vec(); + let mut cx = [0u8; 32]; SeqRng(3).fill(&mut cx); + let client_secret = x25519_dalek::StaticSecret::from(cx); + let client_x_pub = x25519_dalek::PublicKey::from(&client_secret).to_bytes(); + + let shape = shape_fixture(group); // cipher 0x1301, ordered exts incl. key_share(group)+GREASE + let client_hello_msg = vec![0x01, 0x00, 0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF]; // any fixed bytes + let sid = vec![0x11; 32]; + + let mek = if group == 4588 { Some(client_ek_bytes.as_slice()) } else { None }; + let (sh_msg, server_keys) = + emit_server_hello(&shape, &client_hello_msg, &sid, &client_x_pub, mek, &mut SeqRng(1)).unwrap(); + + // CLIENT side (the round-trip proof): parse the emitted ServerHello, + // run the client KEX (decapsulate/DH), combine, derive — must match. + let shi = crate::handshake::parse_server_hello(&sh_msg[..]).unwrap(); // suite/group/server_key_share + let ecdhe: Vec = if group == 4588 { + let (ct, sx) = shi.server_key_share.split_at(1088); + let mlkem_ss = client_dk.decapsulate(&ct.try_into().unwrap()).unwrap(); + let sxp: [u8;32] = sx.try_into().unwrap(); + let x_ss = client_secret.diffie_hellman(&x25519_dalek::PublicKey::from(sxp)).to_bytes(); + [&mlkem_ss[..], &x_ss[..]].concat() + } else { + let sxp: [u8;32] = shi.server_key_share.as_slice().try_into().unwrap(); + client_secret.diffie_hellman(&x25519_dalek::PublicKey::from(sxp)).to_bytes().to_vec() + }; + let mut transcript = client_hello_msg.clone(); transcript.extend_from_slice(&sh_msg); + let th = crate::handshake::transcript_hash(&transcript, shi.suite); + let client_keys = crate::handshake::derive_handshake_keys(&ecdhe, &th, shi.suite); + + // Both sides derive the IDENTICAL handshake keys. + assert_eq!(server_keys.server_key, client_keys.server_key); + assert_eq!(server_keys.client_key, client_keys.client_key); + assert_eq!(server_keys.server_iv, client_keys.server_iv); + assert_eq!(server_keys.client_iv, client_keys.client_iv); + } + + #[test] + fn emit_server_hello_byte_matches_shape_except_substituted_fields() { + // Re-parse the emitted ServerHello via parse_server_hello_shape; assert + // cipher/compression/extension-order (incl. GREASE) equal the source + // shape; the key_share ext body differs (relay's value) and the echoed + // session_id equals the client's. + } +``` + +(Match the exact `MlKemRng`/`RandomSource` names + the `parse_server_hello` return type from the crate.) + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p yip-utls server::tests::emit_server_hello` +Expected: FAIL — `cannot find function emit_server_hello`. + +- [ ] **Step 3: Implement `emit_server_hello`** + +```rust +use crate::template::ServerHelloShape; +use crate::handshake::{derive_handshake_keys, transcript_hash, HandshakeKeys}; +use crate::wire::HelloWriter; + +const EXT_KEY_SHARE: u16 = 0x0033; +const LEGACY_VERSION_TLS12: u16 = 0x0303; +const HANDSHAKE_TYPE_SERVER_HELLO: u8 = 0x02; + +/// Emit a byte-matching ServerHello from `shape` (dest's captured structure) +/// with the relay's own key_share + a fresh random + the client's session_id +/// echo, and derive the server-side handshake keys. Returns the ServerHello +/// handshake message + `HandshakeKeys` (server seals with `server_key`, opens +/// the client with `client_key`). +pub fn emit_server_hello( + shape: &ServerHelloShape, + client_hello_msg: &[u8], + client_legacy_session_id: &[u8], + client_x25519: &[u8; 32], + client_mlkem_ek: Option<&[u8]>, + rng: &mut dyn RandomSource, +) -> Result<(Vec, HandshakeKeys), Error> { + let (server_ks, shared) = + server_key_share(shape.key_share_group, client_x25519, client_mlkem_ek, rng)?; + + let mut body = HelloWriter::new(); + body.u16(LEGACY_VERSION_TLS12); + let mut random = [0u8; 32]; + rng.fill(&mut random); + body.bytes(&random); + // legacy_session_id_echo: echo the client's, per RFC 8446. + let sid_len = u8::try_from(client_legacy_session_id.len()) + .map_err(|_| Error::Protocol("client legacy_session_id exceeds 255 bytes"))?; + body.u8_prefixed(|w| w.bytes(client_legacy_session_id)); + let _ = sid_len; // (u8_prefixed writes the length; keep the bound check above) + body.u16(shape.cipher_suite); + // ServerHello has a single legacy_compression_method byte. + body.bytes(&[shape.legacy_compression_method]); + // Extensions, verbatim from the shape EXCEPT key_share (relay's value). + body.u16_prefixed(|w| { + for (id, ext_body) in &shape.extensions { + w.u16(*id); + if *id == EXT_KEY_SHARE { + // ServerHello key_share ext body = group(2) ‖ u16 len ‖ key_share. + w.u16_prefixed(|w| { + w.u16(shape.key_share_group); + w.u16_prefixed(|w| w.bytes(&server_ks)); + }); + } else { + w.u16_prefixed(|w| w.bytes(ext_body)); + } + } + }); + let body = body.into_bytes(); + + // Wrap as a handshake message: 0x02 ‖ u24 len ‖ body. + let mut sh_msg = Vec::with_capacity(4 + body.len()); + sh_msg.push(HANDSHAKE_TYPE_SERVER_HELLO); + let len = u32::try_from(body.len()).map_err(|_| Error::Protocol("ServerHello body exceeds u24"))?; + sh_msg.extend_from_slice(&len.to_be_bytes()[1..]); + sh_msg.extend_from_slice(&body); + + // Derive keys over transcript = ClientHello ‖ ServerHello. + let mut transcript = Vec::with_capacity(client_hello_msg.len() + sh_msg.len()); + transcript.extend_from_slice(client_hello_msg); + transcript.extend_from_slice(&sh_msg); + let th = transcript_hash(&transcript, shape.cipher_suite); + let keys = derive_handshake_keys(&shared, &th, shape.cipher_suite); + Ok((sh_msg, keys)) +} +``` + +IMPLEMENTER: verify the ServerHello key_share extension body layout against `parse_server_hello` (it reads `group ‖ len ‖ key_exchange` — mirror that exactly so the round-trip parse succeeds). Confirm `HelloWriter`'s `u8_prefixed`/`u16_prefixed`/`bytes`/`u16` signatures. The round-trip test failing means a layout mismatch — fix the emission, not the test. + +- [ ] **Step 4: Run to verify they pass** + +Run: `cargo test -p yip-utls server` → PASS (both round-trips + byte-match). Then `cargo test -p yip-utls` (JA4 diff + all REALITY.2/4b tests green — 5b is additive). + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt -p yip-utls +git add crates/yip-utls/src/server.rs +git commit -m "feat(reality.5b): emit_server_hello — byte-matching ServerHello + server key schedule (round-trip proven)" +``` + +--- + +### Task 3: Extract the client's ML-KEM ek (`yip-rendezvous`) + +The server needs the client's ML-KEM ek (for 4588 encapsulation) from the ClientHello. Add it to `ClientHelloInfo`. + +**Files:** +- Modify: `bin/yip-rendezvous/src/reality.rs` (`ClientHelloInfo.key_share_mlkem_ek` + extraction in `parse_client_hello`) + +**Interfaces:** +- Produces: `ClientHelloInfo.key_share_mlkem_ek: Option>` (the client's group-4588 encapsulation key, 1184 bytes). + +- [ ] **Step 1: Write the failing parse test** + +Add to `reality.rs` tests. Build a fixture ClientHello with a key_share extension containing a group-4588 entry (`mlkem_ek(1184) ‖ x25519(32)`) and assert extraction: + +```rust + #[test] + fn parse_client_hello_extracts_mlkem_ek() { + // Reuse the existing ClientHello test builder; add a 4588 key_share entry + // of exactly 1184+32 bytes (mlkem_ek ‖ x25519). + let mlkem_ek = vec![0xAB; 1184]; + let x25519 = [0xCD; 32]; + let ch = build_test_client_hello_with_4588_key_share(&mlkem_ek, &x25519); + let info = parse_client_hello(&ch).expect("parse"); + assert_eq!(info.key_share_mlkem_ek.as_deref(), Some(&mlkem_ek[..])); + assert_eq!(info.key_share_x25519, Some(x25519)); + } + + #[test] + fn parse_client_hello_mlkem_ek_wrong_length_is_none() { + // A 4588 entry that isn't 1184+32 bytes → key_share_mlkem_ek == None. + } +``` + +Add/extend the ClientHello test builder to include a 4588 key_share entry. If the existing `reality.rs` tests already build ClientHellos, extend that helper. + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p yip-rendezvous-bin reality::tests::parse_client_hello_extracts_mlkem_ek` +Expected: FAIL — no field `key_share_mlkem_ek`. + +- [ ] **Step 3: Implement the extraction** + +Add `pub key_share_mlkem_ek: Option>` to `ClientHelloInfo`. In `parse_client_hello`, where the `key_share` extension's entries are walked (it already extracts `key_share_x25519` for group 0x001d), also handle group `4588` (`GROUP_X25519MLKEM768`): its `key_exchange` is `mlkem_ek(1184) ‖ x25519(32)`; if the entry length is exactly `1184 + 32`, set `key_share_mlkem_ek = Some(first 1184 bytes)` (and the x25519 could also be taken here, but keep the existing `key_share_x25519` logic for group 0x001d). Fail-closed: wrong length → leave `None` (no panic; bounded `.get()`). Initialize the new field to `None` at every `ClientHelloInfo { .. }` construction site. + +- [ ] **Step 4: Run to verify it passes + full suite** + +Run: `cargo test -p yip-rendezvous-bin reality` → PASS. Then full `cargo test -p yip-rendezvous-bin` (existing reality/auth tests green — the new field is additive; anti-replay/4b untouched). + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-rendezvous-bin --all-targets -- -D warnings +cargo fmt +git add bin/yip-rendezvous/src/reality.rs +git commit -m "feat(reality.5b): extract client ML-KEM ek from ClientHello (for group-4588 server KEX)" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- §1 server KEX (4588 ML-KEM Encapsulate + X25519, combiner reuse, unsupported→Err) → Task 1. ✓ +- §2 emit_server_hello (verbatim shape + fresh random + client session_id echo + relay key_share; derive keys) + round-trip + byte-match → Task 2. ✓ +- §3 ClientHelloInfo ML-KEM ek extraction → Task 3. ✓ +- Testing (KEX shapes, round-trip both groups, byte-match, parse) → Tasks 1–3. ✓ +- Scope 4588+29 only; P256/P384+HRR = #84 (Err for others) → Task 1. ✓ +- Non-goals (5c encrypted flight, 5d wiring) — no task emits the flight or touches tls_front. ✓ +- Reuse key schedule/combiner unchanged → Tasks 1–2 (derive_handshake_keys/transcript_hash reused; combiner replicated in the same order). ✓ + +**2. Placeholder scan:** Two IMPLEMENTER-confirm points — the ML-KEM `Encapsulate` RNG bridge (reuse `stream.rs`'s existing `MlKemRng` adapter) and the exact `ml-kem`/`HelloWriter` call shapes — are flagged with the concrete source to mirror (the client `decapsulate` side / `parse_server_hello`) and the gating test (the round-trip fails on any layout mismatch). No `unimplemented!`/TODO in shipped code. + +**3. Type consistency:** `server_key_share(group, client_x25519, client_mlkem_ek, rng) -> Result<(Vec, Vec), Error>` (Task 1) is consumed by `emit_server_hello` (Task 2) with those exact params; `emit_server_hello`'s return `(Vec, HandshakeKeys)` and `ServerHelloShape`/`HandshakeKeys` fields (`server_key`/`client_key`/`server_iv`/`client_iv`/`suite`) match REALITY.2/5a. `key_share_mlkem_ek: Option>` (Task 3) is what 5d will feed to `emit_server_hello`'s `client_mlkem_ek`. + +**Flags for the user at handoff:** +1. **The ML-KEM `Encapsulate` RNG bridge** — reuse `stream.rs`'s existing `RandomSource`→`RngCore` adapter; the round-trip test (client `decapsulate` must recover the same secret the server `encapsulate`d) is the correctness gate. +2. **The round-trip test replicates the client KEX inline** (rather than call `connect`, which does a full handshake) — a faithful stand-in for the client side; it proves both sides derive identical `HandshakeKeys`. From f78548e3968a3b4ddf0ce2fc9e3a99984d215493 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 18:08:26 -0400 Subject: [PATCH 3/7] =?UTF-8?q?feat(reality.5b):=20server=5Fkey=5Fshare=20?= =?UTF-8?q?=E2=80=94=20server-side=20KEX=20(ML-KEM=20Encapsulate=20+=20X25?= =?UTF-8?q?519,=204588/29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/yip-utls/src/error.rs | 10 ++- crates/yip-utls/src/handshake.rs | 7 +- crates/yip-utls/src/lib.rs | 2 + crates/yip-utls/src/server.rs | 144 +++++++++++++++++++++++++++++++ crates/yip-utls/src/stream.rs | 5 +- 5 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 crates/yip-utls/src/server.rs diff --git a/crates/yip-utls/src/error.rs b/crates/yip-utls/src/error.rs index 4d76bd6..531ff8d 100644 --- a/crates/yip-utls/src/error.rs +++ b/crates/yip-utls/src/error.rs @@ -31,6 +31,10 @@ pub enum Error { /// CertificateVerify, wrong scheme, or a missing/malformed message). The /// caller must treat the relay as unauthenticated and NOT tunnel. RealityVerify(&'static str), + /// REALITY.5b: the relay's [`crate::server::server_key_share`] was asked + /// to key a TLS group it does not implement server-side KEX for (only + /// `29`/X25519 and `4588`/X25519MLKEM768 are supported). + UnsupportedGroup(u16), } impl fmt::Display for Error { @@ -42,6 +46,7 @@ impl fmt::Display for Error { Error::Protocol(msg) => write!(f, "REALITY/TLS protocol error: {msg}"), Error::Clock => write!(f, "system clock reads before the Unix epoch"), Error::RealityVerify(m) => write!(f, "REALITY relay verification failed: {m}"), + Error::UnsupportedGroup(g) => write!(f, "REALITY server cannot key TLS group {g}"), } } } @@ -52,7 +57,10 @@ impl std::error::Error for Error { Error::Io(e) => Some(e), Error::Handshake(e) => Some(e), Error::Rng(e) => Some(e), - Error::Protocol(_) | Error::Clock | Error::RealityVerify(_) => None, + Error::Protocol(_) + | Error::Clock + | Error::RealityVerify(_) + | Error::UnsupportedGroup(_) => None, } } } diff --git a/crates/yip-utls/src/handshake.rs b/crates/yip-utls/src/handshake.rs index 63a20eb..2ee51dd 100644 --- a/crates/yip-utls/src/handshake.rs +++ b/crates/yip-utls/src/handshake.rs @@ -26,8 +26,11 @@ pub const GROUP_X25519: u16 = 0x001d; /// this group is `mlkem768_ciphertext(1088) ‖ x25519_public(32)` = 1120 /// bytes. pub const GROUP_X25519MLKEM768: u16 = 4588; -/// Byte length of an ML-KEM-768 ciphertext (FIPS 203 §7.2). -const MLKEM768_CIPHERTEXT_LEN: usize = 1088; +/// Byte length of an ML-KEM-768 ciphertext (FIPS 203 §7.2). `pub(crate)` +/// so [`crate::server`] (REALITY.5b's server-side KEX, the mirror of this +/// module's client-side group-4588 handling) can size the server +/// `key_share`'s ciphertext prefix without duplicating the constant. +pub(crate) const MLKEM768_CIPHERTEXT_LEN: usize = 1088; /// Byte length of an X25519 public value (RFC 7748). const X25519_LEN: usize = 32; const SUITE_AES_128_GCM_SHA256: u16 = 0x1301; diff --git a/crates/yip-utls/src/lib.rs b/crates/yip-utls/src/lib.rs index da5ba94..088f63f 100644 --- a/crates/yip-utls/src/lib.rs +++ b/crates/yip-utls/src/lib.rs @@ -8,11 +8,13 @@ pub mod error; pub mod handshake; pub mod hello; pub mod ja; +pub mod server; pub mod stream; pub mod template; pub mod wire; pub use error::Error; +pub use server::server_key_share; pub use stream::{capture_dest_flight, connect, RealityStream}; pub use template::{ CapturedFlight, CertChainShape, EncryptedFlightShape, ServerFlightTemplate, ServerHelloShape, diff --git a/crates/yip-utls/src/server.rs b/crates/yip-utls/src/server.rs new file mode 100644 index 0000000..ed3a40f --- /dev/null +++ b/crates/yip-utls/src/server.rs @@ -0,0 +1,144 @@ +//! REALITY.5b: the relay's server-side TLS 1.3 key exchange for an authed +//! connection — the mirror of `yip_utls`'s client KEX (`stream.rs`). Generates +//! the relay's own ephemeral so the relay holds the session keys, while the +//! `ServerHello` it goes into (see `emit_server_hello`) byte-matches dest's. + +use crate::error::Error; +use crate::handshake::{GROUP_X25519, GROUP_X25519MLKEM768, MLKEM768_CIPHERTEXT_LEN}; +use crate::hello::RandomSource; +use ml_kem::kem::Encapsulate; +use ml_kem::{EncodedSizeUser, KemCore, MlKem768}; + +/// Bridges the caller's [`RandomSource`] to the `rand_core::CryptoRngCore` +/// bound `ml_kem`'s `Encapsulate` needs. Unlike [`crate::stream`]'s +/// `MlKemRng` (which is hardwired to the OS CSPRNG for `connect`'s +/// production use), this wraps whatever [`RandomSource`] the caller passed +/// `server_key_share` — the OS CSPRNG in production, or a deterministic +/// seeded RNG in tests — so every byte `server_key_share` produces, X25519 +/// and ML-KEM alike, is attributable to its one `rng` argument. +struct RandomSourceRng<'a>(&'a mut dyn RandomSource); + +impl rand_core::RngCore for RandomSourceRng<'_> { + fn next_u32(&mut self) -> u32 { + let mut buf = [0u8; 4]; + self.fill_bytes(&mut buf); + u32::from_ne_bytes(buf) + } + + fn next_u64(&mut self) -> u64 { + let mut buf = [0u8; 8]; + self.fill_bytes(&mut buf); + u64::from_ne_bytes(buf) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + self.0.fill(dest); + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.fill_bytes(dest); + Ok(()) + } +} + +impl rand_core::CryptoRng for RandomSourceRng<'_> {} + +/// The relay's server-side KEX for `group`. Returns `(server_key_share_bytes, +/// shared_secret)`: the key_share to put in the `ServerHello`, and the ECDHE +/// secret for `derive_handshake_keys`. `shared_secret` uses REALITY.2's exact +/// combiner (`mlkem_ss ‖ x25519_ss` for 4588 — see `stream.rs`'s client KEX, +/// which this mirrors: the client `decapsulate`s what this `encapsulate`s). +/// Groups other than `4588`/`29` → `Err(UnsupportedGroup)`. +pub fn server_key_share( + group: u16, + client_x25519: &[u8; 32], + client_mlkem_ek: Option<&[u8]>, + rng: &mut dyn RandomSource, +) -> Result<(Vec, Vec), Error> { + // Fresh X25519 ephemeral (raw bytes → x25519-dalek, as the client does). + let mut eph = [0u8; 32]; + rng.fill(&mut eph); + let server_secret = x25519_dalek::StaticSecret::from(eph); + let server_x25519_pub = x25519_dalek::PublicKey::from(&server_secret).to_bytes(); + let x25519_ss = server_secret + .diffie_hellman(&x25519_dalek::PublicKey::from(*client_x25519)) + .to_bytes(); + + match group { + GROUP_X25519 => Ok((server_x25519_pub.to_vec(), x25519_ss.to_vec())), + GROUP_X25519MLKEM768 => { + let ek_bytes = client_mlkem_ek.ok_or(Error::Protocol( + "group 4588 requires the client's ML-KEM ek", + ))?; + // Decode the client's encapsulation key, encapsulate against it. + let encoded = + ml_kem::Encoded::<::EncapsulationKey>::try_from(ek_bytes) + .map_err(|_| Error::Protocol("client ML-KEM ek is the wrong length"))?; + let ek = ::EncapsulationKey::from_bytes(&encoded); + let mut kem_rng = RandomSourceRng(rng); + let (ct, mlkem_ss) = ek + .encapsulate(&mut kem_rng) + .map_err(|()| Error::Protocol("ML-KEM encapsulation failed"))?; + // server key_share = ct(1088) ‖ x25519_pub(32). + let mut ks = Vec::with_capacity(MLKEM768_CIPHERTEXT_LEN + 32); + ks.extend_from_slice(ct.as_slice()); + ks.extend_from_slice(&server_x25519_pub); + // shared = mlkem_ss ‖ x25519_ss (REALITY.2's combiner order). + let mut ss = Vec::with_capacity(64); + ss.extend_from_slice(mlkem_ss.as_slice()); + ss.extend_from_slice(&x25519_ss); + Ok((ks, ss)) + } + other => Err(Error::UnsupportedGroup(other)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hello::RandomSource; + + /// A deterministic test RNG (mirrors the one `hello.rs`/`stream.rs` tests + /// use): fills every buffer with a counting byte sequence starting at the + /// given seed. + struct SeqRng(u8); + impl RandomSource for SeqRng { + fn fill(&mut self, buf: &mut [u8]) { + for b in buf.iter_mut() { + *b = self.0; + self.0 = self.0.wrapping_add(1); + } + } + } + + #[test] + fn server_key_share_x25519_shapes() { + let client_x = [7u8; 32]; + let (ks, ss) = server_key_share(0x001d, &client_x, None, &mut SeqRng(1)).unwrap(); + assert_eq!(ks.len(), 32); // server x25519 public + assert_eq!(ss.len(), 32); // x25519 shared + } + + #[test] + fn server_key_share_4588_shapes() { + // A real client ML-KEM ek (generate one, as the client does). + use ml_kem::{EncodedSizeUser, KemCore, MlKem768}; + let mut r = crate::stream::MlKemRng::default(); + let (_dk, ek) = MlKem768::generate(&mut r); + let ek_bytes = ek.as_bytes().to_vec(); + let client_x = [9u8; 32]; + let (ks, ss) = server_key_share(4588, &client_x, Some(&ek_bytes), &mut SeqRng(1)).unwrap(); + assert_eq!(ks.len(), 1088 + 32); // ct ‖ x25519 public + assert_eq!(ss.len(), 64); // mlkem_ss(32) ‖ x25519_ss(32) + } + + #[test] + fn server_key_share_rejects_unsupported_group_and_missing_ek() { + assert!(matches!( + server_key_share(23, &[0; 32], None, &mut SeqRng(1)), + Err(Error::UnsupportedGroup(23)) + )); + assert!(server_key_share(4588, &[0; 32], None, &mut SeqRng(1)).is_err()); + // 4588 needs ek + } +} diff --git a/crates/yip-utls/src/stream.rs b/crates/yip-utls/src/stream.rs index ce5fd93..fc63971 100644 --- a/crates/yip-utls/src/stream.rs +++ b/crates/yip-utls/src/stream.rs @@ -148,8 +148,11 @@ impl RandomSource for OsRng { /// `generate` returns (which, on a latched failure, still returns *some* /// keypair built from bytes that silently stayed zero — [`connect`] discards /// it and bails out via `into_result` before ever using it). +/// `pub(crate)` (not just private to this module) so [`crate::server`]'s test +/// module can generate a real client ML-KEM keypair the same way `connect` +/// does, without duplicating this bridge. #[derive(Default)] -struct MlKemRng { +pub(crate) struct MlKemRng { error: Option, } From ae9578eabcc0afb158893774c67d4ac9d7cfbeaa Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 20:48:05 -0400 Subject: [PATCH 4/7] =?UTF-8?q?feat(reality.5b):=20emit=5Fserver=5Fhello?= =?UTF-8?q?=20=E2=80=94=20byte-matching=20ServerHello=20+=20server=20key?= =?UTF-8?q?=20schedule=20(round-trip=20proven)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/yip-utls/src/server.rs | 245 +++++++++++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 1 deletion(-) diff --git a/crates/yip-utls/src/server.rs b/crates/yip-utls/src/server.rs index ed3a40f..fafb68a 100644 --- a/crates/yip-utls/src/server.rs +++ b/crates/yip-utls/src/server.rs @@ -4,11 +4,24 @@ //! `ServerHello` it goes into (see `emit_server_hello`) byte-matches dest's. use crate::error::Error; -use crate::handshake::{GROUP_X25519, GROUP_X25519MLKEM768, MLKEM768_CIPHERTEXT_LEN}; +use crate::handshake::{ + derive_handshake_keys, transcript_hash, HandshakeKeys, GROUP_X25519, GROUP_X25519MLKEM768, + MLKEM768_CIPHERTEXT_LEN, +}; use crate::hello::RandomSource; +use crate::template::ServerHelloShape; +use crate::wire::HelloWriter; use ml_kem::kem::Encapsulate; use ml_kem::{EncodedSizeUser, KemCore, MlKem768}; +/// The `key_share` extension's registered TLS extension ID (RFC 8446 §4.2.8). +const EXT_KEY_SHARE: u16 = 0x0033; +/// `ServerHello.legacy_version` is fixed at TLS 1.2's wire value for TLS 1.3 +/// (the real version is negotiated via `supported_versions`; RFC 8446 §4.1.3). +const LEGACY_VERSION_TLS12: u16 = 0x0303; +/// The `ServerHello` handshake message type (RFC 8446 §4). +const HANDSHAKE_TYPE_SERVER_HELLO: u8 = 0x02; + /// Bridges the caller's [`RandomSource`] to the `rand_core::CryptoRngCore` /// bound `ml_kem`'s `Encapsulate` needs. Unlike [`crate::stream`]'s /// `MlKemRng` (which is hardwired to the OS CSPRNG for `connect`'s @@ -93,10 +106,93 @@ pub fn server_key_share( } } +/// Rebuilds a byte-matching `ServerHello` handshake message from `shape` (the +/// captured structure of a real `dest`'s flight — REALITY.5a) and derives the +/// relay's server-side handshake keys. Every field of `shape` is reproduced +/// VERBATIM (cipher suite, compression method, extension list in wire order +/// incl. any GREASE extension) EXCEPT: a fresh 32-byte `random`, the client's +/// `legacy_session_id` echoed back (per RFC 8446 §4.1.3 — NOT the template's +/// captured session id, which belonged to a different connection), and the +/// `key_share` extension's body, whose key-exchange VALUE is replaced by the +/// relay's own ([`server_key_share`]) so the relay (not the real `dest`) holds +/// the session keys. +/// +/// Returns the `ServerHello` handshake message (`0x02 ‖ u24 len ‖ body` — the +/// caller wraps it in a plaintext TLS record) and the [`HandshakeKeys`] +/// derived over `transcript_hash(client_hello_msg ‖ server_hello_msg, +/// shape.cipher_suite)`. A real [`crate::stream`]-style client that parses +/// this message and runs its own key exchange against the embedded +/// `key_share` derives the IDENTICAL keys (proven by this module's +/// round-trip tests) — the crux correctness property of REALITY.5b. +pub fn emit_server_hello( + shape: &ServerHelloShape, + client_hello_msg: &[u8], + client_legacy_session_id: &[u8], + client_x25519: &[u8; 32], + client_mlkem_ek: Option<&[u8]>, + rng: &mut dyn RandomSource, +) -> Result<(Vec, HandshakeKeys), Error> { + let (server_ks, shared) = + server_key_share(shape.key_share_group, client_x25519, client_mlkem_ek, rng)?; + + if client_legacy_session_id.len() > usize::from(u8::MAX) { + return Err(Error::Protocol( + "client legacy_session_id exceeds 255 bytes", + )); + } + + let mut body = HelloWriter::new(); + body.u16(LEGACY_VERSION_TLS12); + let mut random = [0u8; 32]; + rng.fill(&mut random); + body.bytes(&random); + // legacy_session_id_echo: echo the client's, per RFC 8446 §4.1.3 — NOT + // the shape's captured (different-connection) session id. + body.u8_prefixed(|w| w.bytes(client_legacy_session_id)); + body.u16(shape.cipher_suite); + body.u8(shape.legacy_compression_method); + // Extensions, verbatim from the shape EXCEPT key_share (relay's value). + body.u16_prefixed(|w| { + for (id, ext_body) in &shape.extensions { + w.u16(*id); + if *id == EXT_KEY_SHARE { + // ServerHello key_share ext body: group(2) ‖ u16 key_len ‖ + // key — the exact layout `parse_server_hello` reads. + w.u16_prefixed(|w| { + w.u16(shape.key_share_group); + w.u16_prefixed(|w| w.bytes(&server_ks)); + }); + } else { + w.u16_prefixed(|w| w.bytes(ext_body)); + } + } + }); + let body = body.into_bytes(); + + // Wrap as a handshake message: 0x02 ‖ u24 len ‖ body. + let mut sh_msg = Vec::with_capacity(4 + body.len()); + sh_msg.push(HANDSHAKE_TYPE_SERVER_HELLO); + let len = + u32::try_from(body.len()).map_err(|_| Error::Protocol("ServerHello body exceeds u24"))?; + sh_msg.extend_from_slice(&len.to_be_bytes()[1..]); + sh_msg.extend_from_slice(&body); + + // Derive keys over transcript = ClientHello ‖ ServerHello (both full + // handshake messages, not records). + let mut transcript = Vec::with_capacity(client_hello_msg.len() + sh_msg.len()); + transcript.extend_from_slice(client_hello_msg); + transcript.extend_from_slice(&sh_msg); + let th = transcript_hash(&transcript, shape.cipher_suite); + let keys = derive_handshake_keys(&shared, &th, shape.cipher_suite); + Ok((sh_msg, keys)) +} + #[cfg(test)] mod tests { use super::*; use crate::hello::RandomSource; + use crate::template::ServerHelloShape; + use ml_kem::kem::Decapsulate; /// A deterministic test RNG (mirrors the one `hello.rs`/`stream.rs` tests /// use): fills every buffer with a counting byte sequence starting at the @@ -111,6 +207,153 @@ mod tests { } } + /// A `ServerHelloShape` fixture: cipher `TLS_AES_128_GCM_SHA256`, ordered + /// extensions `supported_versions ‖ key_share(group) ‖ GREASE` (the + /// `key_share` body's group matches `group`; its key bytes are a dummy + /// placeholder — `emit_server_hello` substitutes the relay's own value). + fn shape_fixture(group: u16) -> ServerHelloShape { + let key_len: u16 = if group == GROUP_X25519MLKEM768 { + u16::try_from(MLKEM768_CIPHERTEXT_LEN + 32).expect("fits u16") + } else { + 32 + }; + let mut key_share_body = Vec::new(); + key_share_body.extend_from_slice(&group.to_be_bytes()); + key_share_body.extend_from_slice(&key_len.to_be_bytes()); + key_share_body.extend(std::iter::repeat_n(0xAB, usize::from(key_len))); + + ServerHelloShape { + cipher_suite: 0x1301, + legacy_compression_method: 0x00, + extensions: vec![ + (0x002b_u16, vec![0x03, 0x04]), // supported_versions -> TLS 1.3 + (0x0033_u16, key_share_body), // key_share + (0x2a2a_u16, vec![]), // GREASE, empty + ], + key_share_group: group, + } + } + + #[test] + fn emit_server_hello_roundtrips_x25519() { + roundtrip(0x001d); + } + #[test] + fn emit_server_hello_roundtrips_4588() { + roundtrip(4588); + } + + fn roundtrip(group: u16) { + // Client keypairs (as `stream::connect` generates them). + let mut mlkem_rng = crate::stream::MlKemRng::default(); + let (client_dk, client_ek) = MlKem768::generate(&mut mlkem_rng); + let client_ek_bytes = client_ek.as_bytes().to_vec(); + let mut cx = [0u8; 32]; + SeqRng(3).fill(&mut cx); + let client_secret = x25519_dalek::StaticSecret::from(cx); + let client_x_pub = x25519_dalek::PublicKey::from(&client_secret).to_bytes(); + + let shape = shape_fixture(group); + let client_hello_msg = vec![0x01, 0x00, 0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF]; + let sid = vec![0x11; 32]; + + let mek = if group == 4588 { + Some(client_ek_bytes.as_slice()) + } else { + None + }; + let (sh_msg, server_keys) = emit_server_hello( + &shape, + &client_hello_msg, + &sid, + &client_x_pub, + mek, + &mut SeqRng(1), + ) + .unwrap(); + + // CLIENT side (the round-trip proof): parse the emitted ServerHello, + // run the client KEX (decapsulate/DH), combine, derive — must match. + let shi = crate::handshake::parse_server_hello(&sh_msg[..]).unwrap(); + let ecdhe: Vec = if group == 4588 { + let (ct, sx) = shi.server_key_share.split_at(1088); + let ciphertext = ct.try_into().unwrap(); + let mlkem_ss = client_dk.decapsulate(&ciphertext).unwrap(); + let sxp: [u8; 32] = sx.try_into().unwrap(); + let x_ss = client_secret + .diffie_hellman(&x25519_dalek::PublicKey::from(sxp)) + .to_bytes(); + [&mlkem_ss[..], &x_ss[..]].concat() + } else { + let sxp: [u8; 32] = shi.server_key_share.as_slice().try_into().unwrap(); + client_secret + .diffie_hellman(&x25519_dalek::PublicKey::from(sxp)) + .to_bytes() + .to_vec() + }; + let mut transcript = client_hello_msg.clone(); + transcript.extend_from_slice(&sh_msg); + let th = crate::handshake::transcript_hash(&transcript, shi.suite); + let client_keys = crate::handshake::derive_handshake_keys(&ecdhe, &th, shi.suite); + + // Both sides derive the IDENTICAL handshake keys. + assert_eq!(server_keys.server_key, client_keys.server_key); + assert_eq!(server_keys.client_key, client_keys.client_key); + assert_eq!(server_keys.server_iv, client_keys.server_iv); + assert_eq!(server_keys.client_iv, client_keys.client_iv); + } + + #[test] + fn emit_server_hello_byte_matches_shape_except_substituted_fields() { + let shape = shape_fixture(0x001d); + let client_hello_msg = vec![0x01, 0x00, 0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF]; + let sid = vec![0x22; 32]; + let client_x_pub = [5u8; 32]; + + let (sh_msg, _keys) = emit_server_hello( + &shape, + &client_hello_msg, + &sid, + &client_x_pub, + None, + &mut SeqRng(1), + ) + .unwrap(); + + // Re-parse via parse_server_hello_shape: cipher/compression/extension + // order (incl. GREASE) must equal the source shape. + let parsed = crate::handshake::parse_server_hello_shape(&sh_msg).unwrap(); + assert_eq!(parsed.cipher_suite, shape.cipher_suite); + assert_eq!( + parsed.legacy_compression_method, + shape.legacy_compression_method + ); + assert_eq!(parsed.key_share_group, shape.key_share_group); + let parsed_ids: Vec = parsed.extensions.iter().map(|(id, _)| *id).collect(); + let shape_ids: Vec = shape.extensions.iter().map(|(id, _)| *id).collect(); + assert_eq!(parsed_ids, shape_ids); + + // Every extension body matches the shape verbatim EXCEPT key_share + // (the relay's own value replaces the captured placeholder). + for ((pid, pbody), (sid_ext, sbody)) in + parsed.extensions.iter().zip(shape.extensions.iter()) + { + assert_eq!(pid, sid_ext); + if *pid == 0x0033 { + assert_ne!(pbody, sbody, "key_share body must be the relay's own value"); + } else { + assert_eq!(pbody, sbody); + } + } + + // legacy_session_id_echo equals the client's, not the (nonexistent) + // template session_id. ServerHello body layout: handshake + // header(4) ‖ legacy_version(2) ‖ random(32) ‖ sid_len(1) ‖ sid. + let sid_len = usize::from(sh_msg[4 + 2 + 32]); + let sid_start = 4 + 2 + 32 + 1; + assert_eq!(&sh_msg[sid_start..sid_start + sid_len], sid.as_slice()); + } + #[test] fn server_key_share_x25519_shapes() { let client_x = [7u8; 32]; From 93acae3a6213dc7e83bf355491d05160e4083cfd Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 20:58:20 -0400 Subject: [PATCH 5/7] fix(yip-utls): guard ServerHello body against u24 ceiling not u32::MAX (5b review) --- crates/yip-utls/src/server.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/yip-utls/src/server.rs b/crates/yip-utls/src/server.rs index fafb68a..22c3c5a 100644 --- a/crates/yip-utls/src/server.rs +++ b/crates/yip-utls/src/server.rs @@ -169,7 +169,12 @@ pub fn emit_server_hello( }); let body = body.into_bytes(); - // Wrap as a handshake message: 0x02 ‖ u24 len ‖ body. + // Wrap as a handshake message: 0x02 ‖ u24 len ‖ body. The length is a u24, + // so guard against the u24 ceiling (0xFF_FFFF) — NOT u32::MAX — or the high + // byte would be silently dropped, emitting a truncated length. + if body.len() > 0xFF_FFFF { + return Err(Error::Protocol("ServerHello body exceeds u24")); + } let mut sh_msg = Vec::with_capacity(4 + body.len()); sh_msg.push(HANDSHAKE_TYPE_SERVER_HELLO); let len = From 24d7e588ea5c40edfb7fec1e60c946956ad977bd Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 21:02:46 -0400 Subject: [PATCH 6/7] feat(reality.5b): extract client ML-KEM ek from ClientHello (for group-4588 server KEX) --- bin/yip-rendezvous/src/reality.rs | 141 ++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 7 deletions(-) diff --git a/bin/yip-rendezvous/src/reality.rs b/bin/yip-rendezvous/src/reality.rs index 65ef86a..a0ea6b4 100644 --- a/bin/yip-rendezvous/src/reality.rs +++ b/bin/yip-rendezvous/src/reality.rs @@ -27,6 +27,13 @@ pub struct ClientHelloInfo { /// The x25519 (group `0x001d`) entry from the `key_share` extension, if /// present and exactly 32 bytes. pub key_share_x25519: Option<[u8; 32]>, + /// The ML-KEM-768 encapsulation key from the group-4588 + /// (X25519MLKEM768) entry in the `key_share` extension, if present. The + /// 4588 entry's `key_exchange` is `mlkem_ek(1184) ‖ x25519(32)`; this is + /// only the first 1184 bytes (the embedded x25519 is NOT read here — + /// `key_share_x25519` above stays sourced from the standalone `0x001d` + /// entry). + pub key_share_mlkem_ek: Option>, } /// TLS extension type for `server_name` (SNI). @@ -35,6 +42,14 @@ const EXT_SERVER_NAME: u16 = 0x0000; const EXT_KEY_SHARE: u16 = 0x0033; /// Named group id for x25519 in `key_share` entries. const GROUP_X25519: u16 = 0x001d; +/// Named group id for the hybrid X25519MLKEM768 in `key_share` entries. +const GROUP_X25519MLKEM768: u16 = 4588; +/// Byte length of the ML-KEM-768 encapsulation key embedded in a +/// group-4588 `key_share` entry. +const MLKEM768_EK_LEN: usize = 1184; +/// Byte length of a group-4588 `key_share` entry's `key_exchange`: +/// `mlkem_ek(1184) ‖ x25519(32)`. +const X25519MLKEM768_KEY_LEN: usize = MLKEM768_EK_LEN + 32; /// `HandshakeType::client_hello`. const HANDSHAKE_TYPE_CLIENT_HELLO: u8 = 0x01; @@ -85,34 +100,45 @@ pub fn parse_client_hello(msg: &[u8]) -> Option { let ext_total_len = usize::from(u16_be(rest.get(..2)?)?); let extensions = rest.get(2..2 + ext_total_len)?; - let (sni, key_share_x25519) = parse_extensions(extensions)?; + let (sni, key_share_x25519, key_share_mlkem_ek) = parse_extensions(extensions)?; Some(ClientHelloInfo { sni, client_random, legacy_session_id, key_share_x25519, + key_share_mlkem_ek, }) } -/// Walk the extensions block, extracting `server_name` and `key_share` -/// (x25519 entry only). Returns `None` if the block is malformed; a missing -/// extension is not malformed, it's just absent from the returned tuple. -fn parse_extensions(mut buf: &[u8]) -> Option<(Option, Option<[u8; 32]>)> { +/// Walk the extensions block, extracting `server_name` and `key_share` (the +/// x25519 entry and the group-4588 ML-KEM ek). Returns `None` if the block +/// is malformed; a missing extension is not malformed, it's just absent +/// from the returned tuple. +#[expect( + clippy::type_complexity, + reason = "internal helper; the 3-tuple mirrors ClientHelloInfo's three key_share/sni fields \ + and a named struct would be pure ceremony for a single private call site" +)] +fn parse_extensions(mut buf: &[u8]) -> Option<(Option, Option<[u8; 32]>, Option>)> { let mut sni = None; let mut key_share_x25519 = None; + let mut key_share_mlkem_ek = None; while !buf.is_empty() { let ext_type = u16_be(buf.get(..2)?)?; let ext_len = usize::from(u16_be(buf.get(2..4)?)?); let ext_body = buf.get(4..4 + ext_len)?; match ext_type { EXT_SERVER_NAME => sni = parse_server_name(ext_body), - EXT_KEY_SHARE => key_share_x25519 = parse_key_share_x25519(ext_body), + EXT_KEY_SHARE => { + key_share_x25519 = parse_key_share_x25519(ext_body); + key_share_mlkem_ek = parse_key_share_mlkem_ek(ext_body); + } _ => {} } buf = buf.get(4 + ext_len..)?; } - Some((sni, key_share_x25519)) + Some((sni, key_share_x25519, key_share_mlkem_ek)) } /// Parse a `server_name` extension body: `list_len(u16) | name_type(u8) | @@ -151,6 +177,29 @@ fn parse_key_share_x25519(body: &[u8]) -> Option<[u8; 32]> { None } +/// Parse a `key_share` (ClientHello) extension body: `client_shares_len(u16) +/// | entries...`, each entry `group(u16) | key_len(u16) | key_bytes`. Finds +/// the group-4588 (X25519MLKEM768) entry whose key is exactly +/// `mlkem_ek(1184) ‖ x25519(32)` = 1216 bytes, and returns the first 1184 +/// bytes (the ML-KEM ek only — the embedded x25519 is not extracted here). +/// A 4588 entry of any other length is skipped (scanning continues) rather +/// than treated as malformed. +fn parse_key_share_mlkem_ek(body: &[u8]) -> Option> { + let shares_len = usize::from(u16_be(body.get(..2)?)?); + let mut entries = body.get(2..2 + shares_len)?; + while !entries.is_empty() { + let group = u16_be(entries.get(..2)?)?; + let key_len = usize::from(u16_be(entries.get(2..4)?)?); + let key_bytes = entries.get(4..4 + key_len)?; + if group == GROUP_X25519MLKEM768 && key_bytes.len() == X25519MLKEM768_KEY_LEN { + let ek = key_bytes.get(..MLKEM768_EK_LEN)?; + return Some(ek.to_vec()); + } + entries = entries.get(4 + key_len..)?; + } + None +} + /// Big-endian `u16` from exactly 2 bytes. fn u16_be(b: &[u8]) -> Option { Some(u16::from_be_bytes(b.try_into().ok()?)) @@ -450,6 +499,7 @@ mod tests { client_random, legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), + key_share_mlkem_ek: None, }; assert!(reality_auth_open( @@ -479,6 +529,7 @@ mod tests { client_random, legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), + key_share_mlkem_ek: None, }; assert!(!reality_auth_open( @@ -508,6 +559,7 @@ mod tests { client_random, legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), + key_share_mlkem_ek: None, }; // Client clock in the PAST (now > ts): at the boundary accepted, past it rejected. @@ -563,6 +615,7 @@ mod tests { client_random, legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), + key_share_mlkem_ek: None, }; assert!(!reality_auth_open( @@ -592,6 +645,7 @@ mod tests { client_random, legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), + key_share_mlkem_ek: None, }; assert!(!reality_auth_open( @@ -611,6 +665,7 @@ mod tests { client_random: [0u8; 32], legacy_session_id: vec![0u8; SESSION_ID_LEN], key_share_x25519: None, + key_share_mlkem_ek: None, }; assert!(!reality_auth_open(&reality_priv, &info, &[[0u8; 8]], 0, 60)); } @@ -623,6 +678,7 @@ mod tests { client_random: [0u8; 32], legacy_session_id: vec![0u8; 10], // not 32 bytes key_share_x25519: Some([1u8; 32]), + key_share_mlkem_ek: None, }; assert!(!reality_auth_open(&reality_priv, &info, &[[0u8; 8]], 0, 60)); } @@ -720,6 +776,7 @@ mod tests { client_random, legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), + key_share_mlkem_ek: None, }; let ts_from_recover = reality_auth_recover(&reality_priv, &info, &[short_id], now, 10) @@ -751,6 +808,7 @@ mod tests { client_random: [0u8; 32], legacy_session_id: vec![0u8; SESSION_ID_LEN], key_share_x25519: None, + key_share_mlkem_ek: None, }; assert_eq!( reality_auth_recover_shared(&reality_priv, &info, &[[0u8; 8]], 0, 60), @@ -758,6 +816,75 @@ mod tests { ); } + // ---- Part E: ML-KEM ek extraction (REALITY.5b Task 3) ---- + + /// Build a ClientHello whose `key_share` extension carries BOTH a plain + /// x25519 (`0x001d`) entry and a hybrid group-4588 (X25519MLKEM768) + /// entry (`mlkem_ek(1184) ‖ x25519(32)`) — mirroring a real hybrid-PQ + /// client, which sends both. `key_share_x25519` stays sourced from the + /// `0x001d` entry (unchanged); `key_share_mlkem_ek` comes from the 4588 + /// entry's first 1184 bytes. + fn build_test_client_hello_with_4588_key_share(mlkem_ek: &[u8], x25519: &[u8; 32]) -> Vec { + let mut entries = Vec::new(); + // 0x001d entry: group | key_len(32) | key(32) + entries.extend_from_slice(&super::GROUP_X25519.to_be_bytes()); + entries.extend_from_slice(&32u16.to_be_bytes()); + entries.extend_from_slice(x25519); + // 4588 entry: group | key_len(1216) | mlkem_ek(1184) ‖ x25519(32) + let mut hybrid_key = Vec::with_capacity(mlkem_ek.len() + 32); + hybrid_key.extend_from_slice(mlkem_ek); + hybrid_key.extend_from_slice(x25519); + entries.extend_from_slice(&super::GROUP_X25519MLKEM768.to_be_bytes()); + entries.extend_from_slice(&u16::try_from(hybrid_key.len()).unwrap().to_be_bytes()); + entries.extend_from_slice(&hybrid_key); + + let mut ks_body = Vec::new(); + ks_body.extend_from_slice(&u16::try_from(entries.len()).unwrap().to_be_bytes()); + ks_body.extend_from_slice(&entries); + + let mut exts = Vec::new(); + exts.extend_from_slice(&super::EXT_KEY_SHARE.to_be_bytes()); + exts.extend_from_slice(&u16::try_from(ks_body.len()).unwrap().to_be_bytes()); + exts.extend_from_slice(&ks_body); + + build_client_hello_with_raw_exts([10u8; 32], &[11u8; 32], &exts) + } + + #[test] + fn parse_client_hello_extracts_mlkem_ek() { + let mlkem_ek = vec![0xABu8; 1184]; + let x25519 = [0xCDu8; 32]; + let ch = build_test_client_hello_with_4588_key_share(&mlkem_ek, &x25519); + let info = parse_client_hello(&ch).expect("parse"); + assert_eq!(info.key_share_mlkem_ek.as_deref(), Some(&mlkem_ek[..])); + assert_eq!(info.key_share_x25519, Some(x25519)); + } + + #[test] + fn parse_client_hello_mlkem_ek_wrong_length_is_none() { + // A 4588 entry that isn't 1184+32 = 1216 bytes. + let wrong_key = vec![0xEFu8; 100]; + + let mut entries = Vec::new(); + entries.extend_from_slice(&super::GROUP_X25519MLKEM768.to_be_bytes()); + entries.extend_from_slice(&u16::try_from(wrong_key.len()).unwrap().to_be_bytes()); + entries.extend_from_slice(&wrong_key); + + let mut ks_body = Vec::new(); + ks_body.extend_from_slice(&u16::try_from(entries.len()).unwrap().to_be_bytes()); + ks_body.extend_from_slice(&entries); + + let mut exts = Vec::new(); + exts.extend_from_slice(&super::EXT_KEY_SHARE.to_be_bytes()); + exts.extend_from_slice(&u16::try_from(ks_body.len()).unwrap().to_be_bytes()); + exts.extend_from_slice(&ks_body); + + let msg = build_client_hello_with_raw_exts([12u8; 32], &[13u8; 32], &exts); + let info = parse_client_hello(&msg) + .expect("a wrong-length 4588 key_share entry must not fail the whole parse"); + assert_eq!(info.key_share_mlkem_ek, None); + } + // ---- Part D: deferred parser-edge coverage (REALITY.1 Task 5, Test 3) ---- /// A `key_share` entry with the x25519 group but `key_len != 32` must be From 4155d72918555a692b194c44752d3d8d526eaaa6 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 21:08:22 -0400 Subject: [PATCH 7/7] test(reality.5b): distinct x25519 in 0x001d vs 4588 tail proves non-conflated sourcing (5b review) --- bin/yip-rendezvous/src/reality.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/bin/yip-rendezvous/src/reality.rs b/bin/yip-rendezvous/src/reality.rs index a0ea6b4..bf2df93 100644 --- a/bin/yip-rendezvous/src/reality.rs +++ b/bin/yip-rendezvous/src/reality.rs @@ -824,16 +824,24 @@ mod tests { /// client, which sends both. `key_share_x25519` stays sourced from the /// `0x001d` entry (unchanged); `key_share_mlkem_ek` comes from the 4588 /// entry's first 1184 bytes. - fn build_test_client_hello_with_4588_key_share(mlkem_ek: &[u8], x25519: &[u8; 32]) -> Vec { + /// `standalone_x25519` is the `0x001d` entry's key (what `key_share_x25519` + /// must resolve to); `hybrid_x25519` is the DISTINCT trailing key inside the + /// 4588 entry (must NOT feed `key_share_x25519`) — kept different on purpose + /// so the parse test would catch a sourcing regression. + fn build_test_client_hello_with_4588_key_share( + mlkem_ek: &[u8], + standalone_x25519: &[u8; 32], + hybrid_x25519: &[u8; 32], + ) -> Vec { let mut entries = Vec::new(); // 0x001d entry: group | key_len(32) | key(32) entries.extend_from_slice(&super::GROUP_X25519.to_be_bytes()); entries.extend_from_slice(&32u16.to_be_bytes()); - entries.extend_from_slice(x25519); + entries.extend_from_slice(standalone_x25519); // 4588 entry: group | key_len(1216) | mlkem_ek(1184) ‖ x25519(32) let mut hybrid_key = Vec::with_capacity(mlkem_ek.len() + 32); hybrid_key.extend_from_slice(mlkem_ek); - hybrid_key.extend_from_slice(x25519); + hybrid_key.extend_from_slice(hybrid_x25519); entries.extend_from_slice(&super::GROUP_X25519MLKEM768.to_be_bytes()); entries.extend_from_slice(&u16::try_from(hybrid_key.len()).unwrap().to_be_bytes()); entries.extend_from_slice(&hybrid_key); @@ -853,11 +861,19 @@ mod tests { #[test] fn parse_client_hello_extracts_mlkem_ek() { let mlkem_ek = vec![0xABu8; 1184]; - let x25519 = [0xCDu8; 32]; - let ch = build_test_client_hello_with_4588_key_share(&mlkem_ek, &x25519); + // Distinct x25519 in the 0x001d entry vs. the 4588 tail: key_share_x25519 + // must resolve to the standalone one, proving it is NOT read from the + // hybrid entry's trailing key. + let standalone_x25519 = [0xCDu8; 32]; + let hybrid_x25519 = [0xEEu8; 32]; + let ch = build_test_client_hello_with_4588_key_share( + &mlkem_ek, + &standalone_x25519, + &hybrid_x25519, + ); let info = parse_client_hello(&ch).expect("parse"); assert_eq!(info.key_share_mlkem_ek.as_deref(), Some(&mlkem_ek[..])); - assert_eq!(info.key_share_x25519, Some(x25519)); + assert_eq!(info.key_share_x25519, Some(standalone_x25519)); } #[test]