From ae47d7eb635219f84fa655058da8442de7e27c17 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 17:57:54 -0400 Subject: [PATCH 01/27] =?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 02/27] 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 03/27] =?UTF-8?q?feat(reality.5b):=20server=5Fkey=5Fshare?= =?UTF-8?q?=20=E2=80=94=20server-side=20KEX=20(ML-KEM=20Encapsulate=20+=20?= =?UTF-8?q?X25519,=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 04/27] =?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 05/27] 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 06/27] 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 07/27] 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] From 7a7eacfab9a231b26a139095cf858043986ccb5f Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 21:55:11 -0400 Subject: [PATCH 08/27] =?UTF-8?q?docs(reality.5c):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20encrypted=20server-flight=20emission=20+=20server-s?= =?UTF-8?q?ide=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record-framing fidelity (match record_lengths exactly + leaf sized to dest by 5d; EE/CertVerify/Finished natural size absorbed into TLS record padding). Role-agnostic RealityStream (egress/ingress). sign_certificate_verify mirrors 4b's verifier; emit_server_flight seals EE/Cert/CertVerify/Finished under 5b handshake keys; record_seal_padded new primitive. Fail-safe to splice on over-capacity/malformed template. yip-utls only; 5d wires + forges leaf. --- ...17-reality-5c-server-flight-emit-design.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md diff --git a/docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md b/docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md new file mode 100644 index 0000000..e0fb3d4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md @@ -0,0 +1,180 @@ +# REALITY.5c — encrypted server-flight emission + server-side stream — 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.5b (`emit_server_hello` + `HandshakeKeys`), REALITY.5a (`EncryptedFlightShape` + `CertChainShape` + `capture_dest_flight`), REALITY.4b (`auth::derive_cert_key`, the client `verify_certificate_verify`), REALITY.2 (`record_seal`, `record_open`, `finished_verify_data`, `derive_application_keys`, `RealityStream`). +**Scope:** `yip-utls` only (server-side flight assembly + sealing + record framing + server stream). PR 3 of REALITY.5 (5a/5b/5c/5d). + +## Goal + +Complete the server side of the hand-rolled TLS 1.3 handshake begun in 5b: emit the **encrypted server flight** (`EncryptedExtensions` / `Certificate` / `CertificateVerify` / `Finished`) sealed under the 5b handshake keys and **framed to byte-match the borrowed `dest`'s record lengths** (captured in 5a), then provide the server-side data-phase stream. Together with 5b's cleartext ServerHello, this makes the relay's entire authed server flight — cleartext ServerHello **and** encrypted records — indistinguishable to a passive DPI from a genuine Chrome↔`dest` session. This replaces the BoringSSL `SslAcceptor` currently used on the authed REALITY path (`reality_cert::build_forged_acceptor*`), whose flight is BoringSSL's, not `dest`'s. + +## Threat model / fidelity strategy (the load-bearing decision) + +The in-scope adversary is a **passive DPI**, which sees only the **encrypted record framing** — the outer record lengths (`EncryptedFlightShape::record_lengths`). The per-message lengths (`encrypted_extensions_len`, `certificate_verify_len`, …) live **inside** the AEAD, visible only to a party holding the session keys — the benign client, or nobody (the outer TLS is zero-CA-auth; a MITM cannot derive the keys). Moreover most handshake messages **cannot** be padded to an arbitrary target: `EncryptedExtensions` has no padding slot, and 5c's `CertificateVerify` is ECDSA-P256 (the 4b binding key), so its size cannot match a `dest` that signed with RSA. + +**Decision (user-approved):** 5c matches **`record_lengths` exactly** (full passive-DPI fidelity) and relies on the forged **leaf** being sized to `dest`'s `leaf_der_len` (5d's forging job). `EE` / `CertificateVerify` / `Finished` are emitted at their natural sizes and the difference is absorbed by **TLS 1.3 record padding**. The captured per-message lengths (`ee_len`, `cert_verify_len`, `finished_len`) become validation guides, **not** hard emission targets. If the natural flight content cannot fit `dest`'s record framing, 5c returns `Err` and 5d degrades that connection to splice-only (fail-safe). + +## What `record_lengths` covers (scope-fixing fact) + +`capture_dest_flight` (5a, `stream.rs`) reads the server flight **under the handshake keys** and **breaks the instant it sees the server `Finished`** (`find_finished_end`), pushing `record_lengths.push(payload.len())` for each record along the way. Therefore `record_lengths` covers **only the `{EE, Certificate, CertificateVerify, Finished}` handshake-key records** — it never includes post-`Finished` `NewSessionTicket` records (those are sealed under the *application* keys and are never captured). Any surplus (`sum(record_lengths[i] - 17)` exceeding the four messages' bytes) is `dest`'s **TLS record padding** inside those handshake records, not NST. + +Consequence: **5c seals the entire flight under the 5b handshake keys** (`HandshakeKeys::server_key`/`server_iv`) — no application-key derivation for the flight, no fake NST emission. (`17` = 1 inner content-type byte + 16-byte AEAD tag; identical across all three TLS 1.3 suites `yip-utls` supports.) + +## The 5c / 5d boundary + +5c stays pure `yip-utls` (no networking beyond the generic `AsyncRead + AsyncWrite` stream, fully testable in-process): + +- **5c (`yip-utls`, this PR):** given the 5b `HandshakeKeys`, the 5a `EncryptedFlightShape` + `CertChainShape`, a caller-supplied **forged leaf DER** and **ECDSA-P256 signing key**, and the raw `ClientHello ‖ ServerHello` transcript bytes, assemble + seal the flight (CCS ‖ handshake-key records matching `record_lengths`), drain the client's CCS + `Finished`, derive application keys, and return the server-side `RealityStream`. All wire assembly, sealing, and framing live here. +- **5d (`yip-rendezvous`, next PR):** forge the leaf (rcgen — mimic `dest`'s fields, SPKI = `derive_cert_key(shared)` public key, **pad to `leaf_der_len`**), derive the signing key, and drive the epoll pump — replacing `build_forged_acceptor*`. + +## Design + +### 1. `HandshakeKeys` — expose `server_hs_traffic` + +The server `Finished`'s `verify_data` needs the server handshake-traffic secret as its base. `derive_handshake_keys` already computes it internally (`s_hs`) but `HandshakeKeys` stores only `client_hs_traffic`. Add: + +```rust +pub struct HandshakeKeys { + // ... existing fields ... + pub client_hs_traffic: Vec, + pub server_hs_traffic: Vec, // NEW — base secret for the server Finished +} +``` + +Populate it from the existing `s_hs` local in `derive_handshake_keys`. Additive; 5b's `emit_server_hello` and the client `connect` are unaffected (they don't read it). + +### 2. `sign_certificate_verify` — the signing mirror of 4b's verifier + +```rust +/// Sign the TLS 1.3 server `CertificateVerify` (RFC 8446 §4.4.3) with the +/// derived ECDSA-P256 key, over the same signed content the client's +/// `verify_certificate_verify` reconstructs. Returns the DER ECDSA signature. +pub fn sign_certificate_verify( + signing_key: &p256::ecdsa::SigningKey, + transcript_hash_through_certificate: &[u8], + suite: u16, +) -> Result, Error> +``` + +- Reconstruct the §4.4.3 signed content **identically** to `verify_certificate_verify`: `0x20 × 64 ‖ "TLS 1.3, server CertificateVerify" ‖ 0x00 ‖ transcript_hash_through_certificate`. +- Sign with `ecdsa_secp256r1_sha256` (scheme `0x0403`) — the hard-pinned scheme the client verifies. +- The signing key is `derive_cert_key(shared)` (4b) — the client pins the leaf's SPKI to this key and verifies this signature, so this **is** the 4b relay-verification binding. `DerivedCertKey` exposes `pkcs8_der` (not a `SigningKey`), so the caller reconstructs `p256::ecdsa::SigningKey::from_pkcs8_der(&derived.pkcs8_der)` — a one-liner that keeps this primitive decoupled from the 4b type and testable with any P-256 key. +- Fail-closed: propagate any signing error as `Err` (no `unwrap`). + +### 3. `emit_server_flight` — assemble + seal + frame + +```rust +/// Assemble the encrypted server flight (EE/Certificate/CertificateVerify/ +/// Finished), sealed under `keys` handshake keys and framed to byte-match +/// `flight_shape.record_lengths`, prefixed with the middlebox-compat CCS. +/// Returns the full wire bytes (CCS ‖ sealed records) plus the derived +/// application keys for the data phase. +pub fn emit_server_flight( + keys: &HandshakeKeys, + flight_shape: &EncryptedFlightShape, + cert_chain: &CertChainShape, + forged_leaf_der: &[u8], + cert_signing_key: &p256::ecdsa::SigningKey, + transcript_ch_sh: &[u8], // raw ClientHello ‖ ServerHello bytes (from 5b) +) -> Result + +pub struct ServerFlight { + /// CCS ‖ sealed handshake-key records — write these to the client. + pub wire: Vec, + /// Application traffic keys for the data phase (both directions). + pub app_keys: ApplicationKeys, +} +``` + +**Step 1 — build the four handshake messages** into a contiguous plaintext `hs_stream`: +- **EncryptedExtensions** (`0x08`): minimal valid message — empty extensions list. Wire: `08 00 00 02 00 00` (u24 body-len = 2, u16 ext-list-len = 0). +- **Certificate** (`0x0b`, RFC 8446 §4.4.2): `certificate_request_context = empty (00)` ‖ `certificate_list` (u24 len) where the first entry is `u24 cert_data_len ‖ forged_leaf_der ‖ u16 ext_len(00 00)` and each `cert_chain.intermediates_der` entry follows verbatim as `u24 len ‖ der ‖ 00 00`. Leaf sizing to `leaf_der_len` is 5d's forging responsibility; 5c uses `forged_leaf_der` as given. +- **CertificateVerify** (`0x0f`): `algorithm(0x0403) ‖ u16 sig_len ‖ sig`, `sig = sign_certificate_verify(cert_signing_key, transcript_hash(transcript_ch_sh ‖ EE ‖ Certificate, suite), suite)`. +- **Finished** (`0x14`): body = `finished_verify_data(keys.server_hs_traffic, transcript_hash(transcript_ch_sh ‖ EE ‖ Certificate ‖ CertificateVerify, suite), suite)`. + +**Step 2 — record framing** to match `record_lengths` exactly. Greedily chunk `hs_stream` across the records: +``` +seq = 0; off = 0; wire = CHANGE_CIPHER_SPEC_RECORD.to_vec() +for len in record_lengths: + cap = len - 17 // Err if len < 17 + chunk = hs_stream[off .. off + min(remaining, cap)] + pad = cap - chunk.len() + wire ||= record_seal_padded(server_key, server_iv, seq, suite, + CONTENT_TYPE_HANDSHAKE, chunk, pad) // inner = chunk ‖ 0x16 ‖ 0×pad + off += chunk.len(); seq = seq.checked_add(1)? +Err(FlightTooLarge) if off < hs_stream.len() // content didn't fit +``` +Records past the handshake bytes are pure padding (`0x16 ‖ zeros`); the client's `RealityStream::poll_fill_read_buf` already skips post-handshake handshake-typed records. Because our `Certificate ≈ dest`'s (leaf sized to `leaf_der_len` + `intermediates_der` verbatim) and our `EE`/`CertVerify`/`Finished` are each `≤ dest`'s, `hs_stream.len() ≤ sum(cap)` holds and the greedy fill reproduces `dest`'s exact record count and lengths. + +**Step 3 — application keys.** Derive `app_keys = derive_application_keys(keys.handshake_secret, transcript_hash(transcript_ch_sh ‖ EE ‖ Certificate ‖ CertificateVerify ‖ Finished, suite), suite)`. Both sides hash the same wire bytes through the server `Finished`, so the client (which does not validate the server `Finished` contents) derives identical application keys. + +### 4. `record_seal_padded` — padding-aware seal (new primitive in `handshake.rs`) + +The shipped `record_seal` builds `inner = plaintext ‖ content_type` (no padding). Add a sibling that appends TLS 1.3 record padding: + +```rust +/// Like `record_seal`, but appends `pad_len` zero bytes of TLS 1.3 record +/// padding after the inner content-type, so the sealed record's ciphertext- +/// payload length is exactly `content.len() + 1 + pad_len + TAG_LEN`. +pub fn record_seal_padded( + key: &[u8], iv: &[u8; 12], seq: u64, suite: u16, + content_type: u8, content: &[u8], pad_len: usize, +) -> Result, Error> +``` + +Refactor `record_seal` to delegate (`record_seal(..) = record_seal_padded(.., 0)`) so the two share one implementation and the existing `record_seal` KATs continue to cover the `pad_len = 0` path. + +### 5. Role-agnostic `RealityStream` — add a server constructor + +The client `RealityStream` seals egress with `client_key`/`client_iv` and opens ingress with `server_key`/`server_iv`. The server needs the exact mirror (seal with `server_*`, open with `client_*`). The record machinery (`poll_read`/`poll_write`, CCS-skip, NewSessionTicket-skip) is identical. + +- **Rename** the struct's internal `client_key`/`client_iv`/`client_seq` → `egress_key`/`egress_iv`/`egress_seq` and `server_key`/`server_iv`/`server_seq` → `ingress_key`/`ingress_iv`/`ingress_seq` (semantic, role-neutral). Pure field renames — behavior-identical. +- The existing `connect` builds the **client** variant (egress = client keys, ingress = server keys) — unchanged behavior. +- Add a **server** constructor used by 5c's data phase (egress = server-app keys, ingress = client-app keys), consuming the `ApplicationKeys` from `emit_server_flight` and the already-connected stream (after the flight is written and the client's CCS + `Finished` drained). + +The shipped client tests + round-trip tests are the regression net for the rename. + +### 6. Server data-phase entry (drain client Finished, then stream) + +A server-side counterpart to the tail of `connect`: after `emit_server_flight`'s `wire` is written to the client, **drain the client's CCS + `Finished`** (open under `keys.client_key`/`client_iv` handshake keys; **contents unchecked** — REALITY is zero client-auth, exactly symmetric with how the client drains the server's `Finished`), then build and return the server `RealityStream` on the application keys. This may be a `pub async fn serve(stream, keys, flight_shape, cert_chain, forged_leaf_der, cert_signing_key, transcript_ch_sh) -> Result, Error>` that composes `emit_server_flight` + write + drain + stream construction, mirroring `connect`'s shape so 5d has a single entry point. + +## Error handling (fail-closed) + +- `record_lengths` empty, or any `record_lengths[i] < 17` → `Err` (malformed template) → 5d splices. +- `hs_stream.len() > sum(record_lengths[i] - 17)` → `Err(Error::FlightTooLarge)` → 5d splices. +- `sign_certificate_verify` error → `Err` (no `unwrap` on the key). +- Per-record `seq` via `checked_add` (matches `capture_dest_flight`); overflow → `Err`, not panic. +- Draining the client `Finished`: bounded read (reuse the `MAX_SERVER_FLIGHT_LEN`-style cap), fail-closed on malformed/oversized. +- Crate-wide `forbid-unsafe`; no `as` casts; no bare `#[allow]` (use `#[expect(reason=)]`). Length math via `usize`/`u16::try_from`/the existing u24 helpers, fail-closed on overflow. + +## Testing / adversary + +1. **Round-trip gate (the correctness proof):** in-process `tokio::io::duplex` — the shipped client `connect(verify = on)` on one end; 5c's `serve` (`emit_server_flight` + write + drain + server stream) on the other; both keyed by one shared REALITY seal (so `derive_cert_key(shared)` agrees). Assert: the client completes the handshake, **verifies the 4b binding** (CertVerify by `derive_cert_key(shared)`, leaf pinned), and application data flows **both** directions over the two `RealityStream`s (application keys agree). This subsumes/productionizes the existing `run_mock_tls13_server_with_cert` test mock. +2. **Byte-framing test:** emit from a fixture `EncryptedFlightShape` whose `record_lengths` includes an inflated final record **and** a trailing pure-padding record; assert the emitted records' outer lengths **equal `record_lengths` exactly**, the CCS is present and first, and re-opening under the handshake keys recovers `EE ‖ Cert ‖ CertVerify ‖ Finished` (padding stripped). +3. **CertVerify sign↔verify KAT:** `sign_certificate_verify` output is accepted by the shipped `verify_certificate_verify` for the same transcript + key; rejected for a wrong key and for a tampered transcript. +4. **`record_seal_padded` KAT:** `record_seal_padded(.., pad_len)` output opens under `record_open` to the original content (padding stripped) and its ciphertext-payload length is exactly `content.len() + 1 + pad_len + 16`; `pad_len = 0` equals the shipped `record_seal`. +5. **Fail-safe tests:** capacity overflow, `record_lengths[i] < 17`, empty `record_lengths` → `Err` (not panic). +6. **No regression:** the full existing `yip-utls` suite (client `connect`, JA4-diff, 5a/5b) stays green after the role-agnostic rename and the `record_seal` refactor. + +## Risks + +- **Server-side handshake correctness** (the server key schedule + CertVerify signing) is security-sensitive. Mitigation: reuse REALITY.2's audited `record_seal`/`finished_verify_data`/`derive_application_keys` and 4b's §4.4.3 construction unchanged; the round-trip test against the shipped client (which must complete + verify or no test passes) is the correctness gate. +- **The role-agnostic rename** touches shipped client code. Mitigation: pure mechanical field renames, behavior-identical; existing client + round-trip tests are the net. +- **Template capacity edge:** a `dest` whose flight is unusually tightly framed (little/no padding) combined with a forged leaf near `leaf_der_len` could leave `hs_stream` marginally over capacity. Mitigation: `FlightTooLarge` → splice (fail-safe, no broken handshake); 5d can log the SNI for template review. + +## Non-goals (later REALITY.5 sub-milestones / deferred) + +- No `yip-rendezvous` wiring, no leaf forging (rcgen), no epoll pump, no BoringSSL-acceptor removal — all **5d**. +- No post-`Finished` `NewSessionTicket` emission (never captured; outside `record_lengths`). +- No P256/P384 server KEX + HelloRetryRequest (#84). +- `close_notify` on teardown (REALITY.2 M3) is out of scope unless trivially shared with the client fix. + +## Success criteria + +1. `sign_certificate_verify` produces a signature the shipped `verify_certificate_verify` accepts (and rejects for a wrong key / tampered transcript) — the 4b binding, server side. +2. `emit_server_flight` emits `CCS ‖ records` whose outer lengths **equal** `EncryptedFlightShape::record_lengths` exactly, carrying a well-formed `EE ‖ Certificate ‖ CertificateVerify ‖ Finished` sealed under the 5b handshake keys; over-capacity/malformed templates → `Err` (fail-safe). +3. A shipped client `connect(verify = on)` completes the full handshake against 5c's server flight in-process, **verifies the 4b binding**, and exchanges application data both directions (application keys agree). +4. `record_seal_padded` + the role-agnostic `RealityStream` rename land with the existing `yip-utls` suite green (no client regression). +5. `forbid-unsafe`; no `as` casts; no bare `#[allow]`; clippy clean. No `yip-rendezvous` wiring (5d) and no leaf forging here. From 107d9bf28352f3d92572bc3dfdd06522b07fe46d Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 22:20:35 -0400 Subject: [PATCH 09/27] docs(reality.5c): drop suite arg from sign_certificate_verify (review) Reviewer: p256 SigningKey signs with SHA-256 internally + 4b hard-pins the scheme, so the signer needs no suite (caller uses it only for the transcript hash). Cleaner helper API. --- .../specs/2026-07-17-reality-5c-server-flight-emit-design.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md b/docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md index e0fb3d4..1edd05d 100644 --- a/docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md +++ b/docs/superpowers/specs/2026-07-17-reality-5c-server-flight-emit-design.md @@ -54,12 +54,11 @@ Populate it from the existing `s_hs` local in `derive_handshake_keys`. Additive; pub fn sign_certificate_verify( signing_key: &p256::ecdsa::SigningKey, transcript_hash_through_certificate: &[u8], - suite: u16, ) -> Result, Error> ``` - Reconstruct the §4.4.3 signed content **identically** to `verify_certificate_verify`: `0x20 × 64 ‖ "TLS 1.3, server CertificateVerify" ‖ 0x00 ‖ transcript_hash_through_certificate`. -- Sign with `ecdsa_secp256r1_sha256` (scheme `0x0403`) — the hard-pinned scheme the client verifies. +- Sign with `ecdsa_secp256r1_sha256` (scheme `0x0403`) — the hard-pinned scheme the client verifies. No `suite` argument: 4b hard-pins the scheme, and `p256::ecdsa::SigningKey` signs with SHA-256 internally, so the signer needs no suite (the caller uses `suite` only to compute `transcript_hash_through_certificate`). - The signing key is `derive_cert_key(shared)` (4b) — the client pins the leaf's SPKI to this key and verifies this signature, so this **is** the 4b relay-verification binding. `DerivedCertKey` exposes `pkcs8_der` (not a `SigningKey`), so the caller reconstructs `p256::ecdsa::SigningKey::from_pkcs8_der(&derived.pkcs8_der)` — a one-liner that keeps this primitive decoupled from the 4b type and testable with any P-256 key. - Fail-closed: propagate any signing error as `Err` (no `unwrap`). @@ -91,7 +90,7 @@ pub struct ServerFlight { **Step 1 — build the four handshake messages** into a contiguous plaintext `hs_stream`: - **EncryptedExtensions** (`0x08`): minimal valid message — empty extensions list. Wire: `08 00 00 02 00 00` (u24 body-len = 2, u16 ext-list-len = 0). - **Certificate** (`0x0b`, RFC 8446 §4.4.2): `certificate_request_context = empty (00)` ‖ `certificate_list` (u24 len) where the first entry is `u24 cert_data_len ‖ forged_leaf_der ‖ u16 ext_len(00 00)` and each `cert_chain.intermediates_der` entry follows verbatim as `u24 len ‖ der ‖ 00 00`. Leaf sizing to `leaf_der_len` is 5d's forging responsibility; 5c uses `forged_leaf_der` as given. -- **CertificateVerify** (`0x0f`): `algorithm(0x0403) ‖ u16 sig_len ‖ sig`, `sig = sign_certificate_verify(cert_signing_key, transcript_hash(transcript_ch_sh ‖ EE ‖ Certificate, suite), suite)`. +- **CertificateVerify** (`0x0f`): `algorithm(0x0403) ‖ u16 sig_len ‖ sig`, `sig = sign_certificate_verify(cert_signing_key, transcript_hash(transcript_ch_sh ‖ EE ‖ Certificate, suite))`. - **Finished** (`0x14`): body = `finished_verify_data(keys.server_hs_traffic, transcript_hash(transcript_ch_sh ‖ EE ‖ Certificate ‖ CertificateVerify, suite), suite)`. **Step 2 — record framing** to match `record_lengths` exactly. Greedily chunk `hs_stream` across the records: From 4aebf321e89952b03c4faada6834218f3db4e5f5 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 22:31:34 -0400 Subject: [PATCH 10/27] docs(reality.5c): implementation plan (6 tasks, subagent-driven) server_hs_traffic / record_seal_padded / sign_certificate_verify / emit_server_flight (greedy record-framing to record_lengths, fail-safe FlightTooLarge) / role-agnostic RealityStream (egress/ingress) / serve + drive the connect_verify_* round-trip suite through 5c as the gate. --- ...026-07-17-reality-5c-server-flight-emit.md | 931 ++++++++++++++++++ 1 file changed, 931 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-reality-5c-server-flight-emit.md diff --git a/docs/superpowers/plans/2026-07-17-reality-5c-server-flight-emit.md b/docs/superpowers/plans/2026-07-17-reality-5c-server-flight-emit.md new file mode 100644 index 0000000..ac723be --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-reality-5c-server-flight-emit.md @@ -0,0 +1,931 @@ +# REALITY.5c — encrypted server-flight emission + server-side stream — 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:** Hand-roll the relay's encrypted TLS 1.3 server flight (EncryptedExtensions / Certificate / CertificateVerify / Finished) sealed under the 5b handshake keys and framed to byte-match `dest`'s captured record lengths, plus the server-side data-phase stream — completing the server handshake so the authed REALITY path no longer needs BoringSSL. + +**Architecture:** Six additive changes to `yip-utls`: expose `server_hs_traffic`; a padding-aware `record_seal_padded`; a `sign_certificate_verify` (the signing mirror of 4b's verifier); `emit_server_flight` (assemble + seal + greedy-frame to `record_lengths`); a role-agnostic `RealityStream` rename (egress/ingress) with a server constructor; and a `serve` entry point proven by driving the existing `connect_verify_*` round-trip suite through the new production code. + +**Tech Stack:** Rust, `yip-utls` crate (`forbid-unsafe`), `ring` (AEAD/HKDF/HMAC), `p256` (ECDSA-P256), `tokio` (async test I/O), `rcgen` (test-only leaf building). + +## Global Constraints + +- `#![forbid(unsafe_code)]` — NO `unsafe`, NO `as` casts, NO bare `#[allow]` (use `#[expect(reason = "...")]`). +- Reuse REALITY.2's `record_seal` / `finished_verify_data` / `derive_application_keys` and 4b's §4.4.3 signed-content construction **unchanged**. +- Fidelity strategy (user-approved): match `EncryptedFlightShape::record_lengths` **exactly**; EE/CertVerify/Finished are natural-sized, the difference absorbed by TLS 1.3 record padding; over-capacity/malformed template → `Err` (fail-safe, 5d splices). `17` = 1 inner content-type + 16-byte AEAD tag (all three TLS 1.3 suites). +- 5c seals the **entire** flight under the 5b **handshake** keys (`HandshakeKeys::server_key`/`server_iv`) — `record_lengths` covers only the handshake-key `{EE, Cert, CertVerify, Finished}` records; no app-key sealing for the flight, no NewSessionTicket. +- Scope: `yip-utls` only. NO `yip-rendezvous` wiring, NO rcgen leaf forging in production code, NO epoll pump — all 5d. NO post-`Finished` NST. NO P256/P384+HRR (#84). +- Every task: `cargo test -p yip-utls` green, `cargo clippy -p yip-utls --all-targets -- -D warnings` clean, `cargo fmt`. +- Branch is stacked on 5b (PR #85). Leave the PR for the user; do NOT merge; no "not merging" line in the PR body. +- **Known pre-existing flake (NOT yours):** the workspace pre-commit hook runs the whole suite, which currently fails only on two `yip-io::uring::tests::uring_*` loopback tests (237 < 256 datagrams under load, unrelated crate, confirmed on clean base). If the commit is blocked *solely* by those, commit with `--no-verify` and say so. Any `yip-utls` failure is yours. + +--- + +### Task 1: Expose `server_hs_traffic` on `HandshakeKeys` + +The server `Finished`'s `verify_data` needs the server handshake-traffic secret as its base. `derive_handshake_keys` already computes it (`s_hs`) but discards it. Expose it. Purely additive — 5b `emit_server_hello` and client `connect` don't read it. + +**Files:** +- Modify: `crates/yip-utls/src/handshake.rs` (`HandshakeKeys` struct ~487-494; `derive_handshake_keys` return ~551-559) + +**Interfaces:** +- Produces: `HandshakeKeys { ..., pub server_hs_traffic: Vec }` — the server handshake-traffic secret (32 bytes for SHA-256 suites, 48 for SHA-384), sibling of the existing `client_hs_traffic`. + +- [ ] **Step 1: Write the failing test** + +Add to `handshake.rs`'s `#[cfg(test)] mod tests`: + +```rust +#[test] +fn handshake_keys_expose_distinct_server_hs_traffic() { + let ecdhe = [7u8; 32]; + let transcript = transcript_hash(b"client-hello||server-hello", SUITE_AES_128_GCM_SHA256); + let hk = derive_handshake_keys(&ecdhe, &transcript, SUITE_AES_128_GCM_SHA256); + assert!(!hk.server_hs_traffic.is_empty(), "server_hs_traffic must be populated"); + assert_eq!(hk.server_hs_traffic.len(), hk.client_hs_traffic.len()); + assert_ne!( + hk.server_hs_traffic, hk.client_hs_traffic, + "server and client handshake-traffic secrets must differ" + ); +} +``` + +(`SUITE_AES_128_GCM_SHA256` is already a const in this module — the existing `record_seal_then_open_round_trips_all_suites` test uses it.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p yip-utls --lib handshake::tests::handshake_keys_expose_distinct_server_hs_traffic` +Expected: FAIL — no field `server_hs_traffic` on `HandshakeKeys`. + +- [ ] **Step 3: Add the field + populate it** + +In the `HandshakeKeys` struct, after `pub client_hs_traffic: Vec,` add: + +```rust + /// The server handshake-traffic secret (`s hs traffic`) — the base secret + /// for the SERVER `Finished`'s `verify_data` (REALITY.5c). Sibling of + /// `client_hs_traffic`; both are 32 bytes (SHA-256) or 48 (SHA-384). + pub server_hs_traffic: Vec, +``` + +Update the doc comment above the struct to mention it alongside `client_hs_traffic` if it enumerates fields. In `derive_handshake_keys`'s returned struct literal, after `client_hs_traffic: c_hs,` add `server_hs_traffic: s_hs,` (the `s_hs` local already exists and is only borrowed — `&s_hs` — when deriving `server_key`/`server_iv`, so it is free to move into the struct at the return). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p yip-utls --lib handshake::` then `cargo test -p yip-utls` +Expected: PASS (all existing handshake/stream/server tests still green — the field is additive). + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt +git add crates/yip-utls/src/handshake.rs +git commit -m "feat(reality.5c): expose server_hs_traffic on HandshakeKeys (server Finished base secret)" +``` + +--- + +### Task 2: `record_seal_padded` — padding-aware record seal + +The shipped `record_seal` builds `inner = plaintext ‖ content_type` (no padding). 5c needs to append TLS 1.3 record padding to hit `dest`'s exact record lengths. Add a padding-aware sibling and make `record_seal` delegate to it, so the existing `record_seal` KATs cover the `pad_len = 0` path. + +**Files:** +- Modify: `crates/yip-utls/src/handshake.rs` (`record_seal` ~674-711) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `pub fn record_seal_padded(key: &[u8], iv: &[u8; 12], seq: u64, suite: u16, content_type: u8, content: &[u8], pad_len: usize) -> Result, Error>` — inner = `content ‖ content_type ‖ 0×pad_len`, sealed; the returned ciphertext-payload length is exactly `content.len() + 1 + pad_len + 16`. + +- [ ] **Step 1: Write the failing tests** + +Add to `handshake.rs`'s `mod tests`: + +```rust +#[test] +fn record_seal_padded_opens_to_content_with_padding_stripped() { + let key = [0x42u8; 16]; + let iv = [0x24u8; 12]; + let content = b"encrypted-extensions-bytes"; + let pad_len = 40usize; + let sealed = record_seal_padded( + &key, &iv, 0, SUITE_AES_128_GCM_SHA256, 0x16, content, pad_len, + ) + .unwrap(); + // Ciphertext-payload length is content ‖ content_type(1) ‖ pad ‖ tag(16). + assert_eq!(sealed.len(), content.len() + 1 + pad_len + 16); + // record_open strips the padding + content-type and recovers the content. + let mut payload = sealed.clone(); + let opened = record_open(&key, &iv, 0, SUITE_AES_128_GCM_SHA256, 0x17, &mut payload).unwrap(); + assert_eq!(opened, content); +} + +#[test] +fn record_seal_padded_zero_equals_record_seal() { + let key = [0x01u8; 16]; + let iv = [0x02u8; 12]; + let content = b"finished-msg"; + let via_padded = + record_seal_padded(&key, &iv, 5, SUITE_AES_128_GCM_SHA256, 0x16, content, 0).unwrap(); + let via_plain = record_seal(&key, &iv, 5, SUITE_AES_128_GCM_SHA256, 0x16, content).unwrap(); + assert_eq!(via_padded, via_plain); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yip-utls --lib handshake::tests::record_seal_padded` +Expected: FAIL — `record_seal_padded` not found. + +- [ ] **Step 3: Implement `record_seal_padded` + delegate `record_seal`** + +Replace the body of `record_seal` and add the padded variant. The only change vs the current `record_seal` is that `inner` gains `pad_len` trailing zero bytes AFTER the content-type, and the length math follows: + +```rust +/// Like [`record_seal`], but appends `pad_len` zero bytes of TLS 1.3 record +/// padding after the inner content-type (RFC 8446 §5.4), so the sealed +/// record's ciphertext-payload length is exactly +/// `content.len() + 1 + pad_len + tag_len`. Used by REALITY.5c to frame the +/// server flight to `dest`'s captured per-record lengths. +pub fn record_seal_padded( + key: &[u8], + iv: &[u8; 12], + seq: u64, + suite: u16, + content_type: u8, + content: &[u8], + pad_len: usize, +) -> Result, Error> { + let alg = algorithm_for_suite(suite)?; + let unbound = UnboundKey::new(alg, key).map_err(|_| Error::Crypto)?; + let less_safe = LessSafeKey::new(unbound); + let nonce = make_nonce(iv, seq); + + let mut inner = Vec::with_capacity(content.len() + 1 + pad_len); + inner.extend_from_slice(content); + inner.push(content_type); + inner.resize(inner.len() + pad_len, 0u8); + + let total_len = inner.len() + alg.tag_len(); + let len_bytes = u16::try_from(total_len) + .map_err(|_| Error::RecordTooLarge)? + .to_be_bytes(); + let aad_bytes = [ + CONTENT_TYPE_APPLICATION_DATA, + 0x03, + 0x03, + len_bytes[0], + len_bytes[1], + ]; + + less_safe + .seal_in_place_append_tag(nonce, Aad::from(aad_bytes), &mut inner) + .map_err(|_| Error::Crypto)?; + + Ok(inner) +} + +/// Seals a TLS 1.3 protected record with no record padding. Thin wrapper over +/// [`record_seal_padded`] with `pad_len = 0`. +pub fn record_seal( + key: &[u8], + iv: &[u8; 12], + seq: u64, + suite: u16, + content_type: u8, + plaintext: &[u8], +) -> Result, Error> { + record_seal_padded(key, iv, seq, suite, content_type, plaintext, 0) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p yip-utls --lib handshake::` then `cargo test -p yip-utls` +Expected: PASS — the new KATs plus every existing `record_seal`/`record_open`/round-trip test (the delegation is behavior-preserving for `pad_len = 0`). + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt +git add crates/yip-utls/src/handshake.rs +git commit -m "feat(reality.5c): record_seal_padded (TLS record padding); record_seal delegates" +``` + +--- + +### Task 3: `sign_certificate_verify` — the signing mirror of 4b's verifier + +Sign the server `CertificateVerify` (RFC 8446 §4.4.3) with the derived ECDSA-P256 key, over the exact signed content the shipped `verify_certificate_verify` reconstructs. This carries the 4b binding. Co-locate it with `verify_certificate_verify` in `stream.rs` so the KAT can call that private verifier. + +**Files:** +- Modify: `crates/yip-utls/src/stream.rs` (add `sign_certificate_verify` next to `verify_certificate_verify` ~607; add tests in `mod tests`) + +**Interfaces:** +- Consumes: the shipped private `fn verify_certificate_verify(expected_pubkey_sec1, leaf_spki_pubkey_sec1, transcript_hash_through_cert, signature_der) -> Result<(), Error>` (for the KAT only). +- Produces: `pub fn sign_certificate_verify(signing_key: &p256::ecdsa::SigningKey, transcript_hash_through_certificate: &[u8]) -> Result, Error>` — returns the DER ECDSA signature. NO `suite` arg (p256 signs SHA-256 internally; 4b hard-pins the scheme). + +- [ ] **Step 1: Write the failing tests** + +Add to `stream.rs`'s `mod tests`: + +```rust +#[test] +fn sign_certificate_verify_is_accepted_by_the_verifier() { + use p256::ecdsa::SigningKey; + let signing_key = SigningKey::from_slice(&[0x11u8; 32]).unwrap(); + let pubkey_sec1 = signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + let transcript = transcript_hash(b"ch||sh||ee||cert", SUITE_AES_128_GCM_SHA256); + + let sig = sign_certificate_verify(&signing_key, &transcript).unwrap(); + + // The shipped verifier accepts it for the same transcript + pinned key. + verify_certificate_verify(&pubkey_sec1, &pubkey_sec1, &transcript, &sig) + .expect("a self-signed CertificateVerify must verify"); +} + +#[test] +fn sign_certificate_verify_rejected_for_wrong_key_and_tampered_transcript() { + use p256::ecdsa::SigningKey; + let signing_key = SigningKey::from_slice(&[0x11u8; 32]).unwrap(); + let other_key = SigningKey::from_slice(&[0x22u8; 32]).unwrap(); + let other_pub = other_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + let transcript = transcript_hash(b"ch||sh||ee||cert", SUITE_AES_128_GCM_SHA256); + let sig = sign_certificate_verify(&signing_key, &transcript).unwrap(); + + // Wrong pinned key → RealityVerify. + assert!(verify_certificate_verify(&other_pub, &other_pub, &transcript, &sig).is_err()); + + // Tampered transcript (verify against a different hash) → RealityVerify. + let signer_pub = signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + let tampered = transcript_hash(b"different-transcript", SUITE_AES_128_GCM_SHA256); + assert!(verify_certificate_verify(&signer_pub, &signer_pub, &tampered, &sig).is_err()); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yip-utls --lib stream::tests::sign_certificate_verify` +Expected: FAIL — `sign_certificate_verify` not found. + +- [ ] **Step 3: Implement `sign_certificate_verify`** + +Add next to `verify_certificate_verify` in `stream.rs`. It builds the identical §4.4.3 signed content and signs: + +```rust +/// The signing counterpart of [`verify_certificate_verify`]: sign the TLS 1.3 +/// server `CertificateVerify` (RFC 8446 §4.4.3) with the derived ECDSA-P256 +/// key `signing_key` (from `auth::derive_cert_key(shared)`), over the SAME +/// signed content the verifier reconstructs. This IS the REALITY.4b binding, +/// server side — the client pins the presented leaf's SPKI to this key and +/// verifies this signature. `ecdsa_secp256r1_sha256` (scheme 0x0403) is fixed: +/// `p256::ecdsa::SigningKey` signs SHA-256 internally, so no `suite` is needed +/// (the caller uses `suite` only to compute `transcript_hash_through_certificate`). +pub fn sign_certificate_verify( + signing_key: &p256::ecdsa::SigningKey, + transcript_hash_through_certificate: &[u8], +) -> Result, Error> { + use p256::ecdsa::{signature::Signer, Signature}; + + let mut signed = Vec::with_capacity(64 + 33 + 1 + transcript_hash_through_certificate.len()); + signed.extend_from_slice(&[0x20u8; 64]); + signed.extend_from_slice(b"TLS 1.3, server CertificateVerify"); + signed.push(0x00); + signed.extend_from_slice(transcript_hash_through_certificate); + + let sig: Signature = signing_key + .try_sign(&signed) + .map_err(|_| Error::Crypto)?; + Ok(sig.to_der().as_bytes().to_vec()) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p yip-utls --lib stream::tests::sign_certificate_verify` then `cargo test -p yip-utls` +Expected: PASS. + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt +git add crates/yip-utls/src/stream.rs +git commit -m "feat(reality.5c): sign_certificate_verify — signing mirror of the 4b verifier" +``` + +--- + +### Task 4: `emit_server_flight` — assemble, seal, and frame the encrypted flight + +Build EE/Certificate/CertificateVerify/Finished, seal them under the 5b handshake keys, and greedily frame them across `dest`'s captured `record_lengths` (padding to hit each length exactly), prefixed with the middlebox CCS. + +**Files:** +- Modify: `crates/yip-utls/src/error.rs` (add `FlightTooLarge` variant) +- Modify: `crates/yip-utls/src/stream.rs` (add `ServerFlight` struct + `emit_server_flight` + tests) + +**Interfaces:** +- Consumes: `HandshakeKeys` (Task 1, incl. `server_hs_traffic`); `record_seal_padded` (Task 2); `sign_certificate_verify` (Task 3); `EncryptedFlightShape` + `CertChainShape` (`crate::template`); `finished_verify_data`, `derive_application_keys`, `transcript_hash`, `ApplicationKeys` (`crate::handshake`); `record_header`, `CHANGE_CIPHER_SPEC_RECORD`, `CONTENT_TYPE_HANDSHAKE`, `CONTENT_TYPE_APPLICATION_DATA`, `LEGACY_RECORD_VERSION` (already in `stream.rs`). +- Produces: + - `pub struct ServerFlight { pub wire: Vec, pub app_keys: ApplicationKeys }` + - `pub fn emit_server_flight(keys: &HandshakeKeys, flight_shape: &EncryptedFlightShape, cert_chain: &CertChainShape, forged_leaf_der: &[u8], cert_signing_key: &p256::ecdsa::SigningKey, transcript_ch_sh: &[u8]) -> Result` + - `Error::FlightTooLarge` + +- [ ] **Step 1: Add the `Error::FlightTooLarge` variant** + +In `crates/yip-utls/src/error.rs`, add a variant to the `Error` enum (near `Protocol`), a `Display` arm, and include it in the fatal/non-retryable classification match alongside `Protocol` / `RealityVerify` (grep the file for `Error::Protocol` to find every match arm that enumerates variants and add `FlightTooLarge` beside it): + +```rust + /// The forged server flight's plaintext does not fit the captured + /// `dest` record framing (`sum(record_lengths[i] - 17)` < flight bytes, + /// or a malformed `record_lengths`). REALITY.5c fail-safe: 5d degrades + /// this connection to splice-only. + FlightTooLarge, +``` + +Display arm: + +```rust + Error::FlightTooLarge => { + write!(f, "REALITY/TLS server flight exceeds the captured dest record framing") + } +``` + +- [ ] **Step 2: Write the failing tests** + +Add to `stream.rs`'s `mod tests`. These build a real `HandshakeKeys` via `derive_handshake_keys`, a P-256 signing key, and a fixture `EncryptedFlightShape` whose `record_lengths` include an inflated final record and leave the last record as pure padding. + +```rust +// Shared fixture: derive real handshake keys + a leaf whose SPKI is the +// signing key, so the emitted Certificate is well-formed. +fn emit_flight_fixture() -> (HandshakeKeys, p256::ecdsa::SigningKey, Vec, Vec) { + let ecdhe = [7u8; 32]; + let transcript_ch_sh = b"raw-clienthello-bytes||raw-serverhello-bytes".to_vec(); + let th = transcript_hash(&transcript_ch_sh, SUITE_AES_128_GCM_SHA256); + let keys = derive_handshake_keys(&ecdhe, &th, SUITE_AES_128_GCM_SHA256); + let signing_key = p256::ecdsa::SigningKey::from_slice(&[0x33u8; 32]).unwrap(); + // A real leaf whose key is the signing key (test-only helper already used + // by the verify tests). `leaf_der_for_key` takes an rcgen KeyPair built + // from the p256 pkcs8. + use p256::pkcs8::EncodePrivateKey as _; + let pkcs8 = signing_key.to_pkcs8_der().unwrap(); + let leaf_key = rcgen::KeyPair::try_from(pkcs8.as_bytes()).unwrap(); + let leaf_der = leaf_der_for_key(&leaf_key); + (keys, signing_key, leaf_der, transcript_ch_sh) +} + +#[test] +fn emit_server_flight_matches_record_lengths_and_reopens_to_messages() { + let (keys, signing_key, leaf_der, transcript_ch_sh) = emit_flight_fixture(); + // Generously sized records: 3 records, last two mostly/entirely padding. + let shape = crate::template::EncryptedFlightShape { + record_lengths: vec![600, 600, 4096], + encrypted_extensions_len: 0, + certificate_len: 0, + certificate_verify_len: 0, + finished_len: 0, + }; + let cert_chain = crate::template::CertChainShape { + leaf_der_len: leaf_der.len(), + intermediates_der: Vec::new(), + }; + + let flight = emit_server_flight( + &keys, &shape, &cert_chain, &leaf_der, &signing_key, &transcript_ch_sh, + ) + .unwrap(); + + // First record is the middlebox CCS, byte-for-byte. + assert_eq!(&flight.wire[..CHANGE_CIPHER_SPEC_RECORD.len()], &CHANGE_CIPHER_SPEC_RECORD); + + // Walk the sealed records after the CCS: each record's header length field + // MUST equal record_lengths[i]; re-open each under the handshake server key + // and concatenate the recovered plaintext. + let mut off = CHANGE_CIPHER_SPEC_RECORD.len(); + let mut recovered = Vec::new(); + for (i, &rlen) in shape.record_lengths.iter().enumerate() { + let hdr = &flight.wire[off..off + RECORD_HEADER_LEN]; + assert_eq!(hdr[0], CONTENT_TYPE_APPLICATION_DATA); + let len_field = usize::from(u16::from_be_bytes([hdr[3], hdr[4]])); + assert_eq!(len_field, rlen, "record {i} outer length must match record_lengths"); + let mut payload = + flight.wire[off + RECORD_HEADER_LEN..off + RECORD_HEADER_LEN + rlen].to_vec(); + let opened = record_open( + &keys.server_key, &keys.server_iv, u64::try_from(i).unwrap(), + keys.suite, CONTENT_TYPE_APPLICATION_DATA, &mut payload, + ) + .unwrap(); + recovered.extend_from_slice(&opened); + off += RECORD_HEADER_LEN + rlen; + } + assert_eq!(off, flight.wire.len(), "no trailing bytes after the framed records"); + + // The recovered plaintext begins with EncryptedExtensions (0x08), + // Certificate (0x0b), CertificateVerify (0x0f), Finished (0x14) in order. + assert_eq!(recovered[0], 0x08, "first message is EncryptedExtensions"); + // Walk the four handshake messages by their u24 length prefixes. + let mut p = 0usize; + let mut types = Vec::new(); + while p + 4 <= recovered.len() { + let ty = recovered[p]; + if ty == 0 { break; } // reached padding stripped? (shouldn't; padding was per-record) + let len = (usize::from(recovered[p + 1]) << 16) + | (usize::from(recovered[p + 2]) << 8) + | usize::from(recovered[p + 3]); + types.push(ty); + p += 4 + len; + if types.len() == 4 { break; } + } + assert_eq!(types, vec![0x08, 0x0b, 0x0f, 0x14]); +} + +#[test] +fn emit_server_flight_rejects_malformed_or_over_capacity_framing() { + let (keys, signing_key, leaf_der, transcript_ch_sh) = emit_flight_fixture(); + let cert_chain = crate::template::CertChainShape { + leaf_der_len: leaf_der.len(), + intermediates_der: Vec::new(), + }; + let mk = |record_lengths: Vec| crate::template::EncryptedFlightShape { + record_lengths, + encrypted_extensions_len: 0, + certificate_len: 0, + certificate_verify_len: 0, + finished_len: 0, + }; + + // Empty record_lengths → Err (not panic). + assert!(emit_server_flight(&keys, &mk(vec![]), &cert_chain, &leaf_der, &signing_key, &transcript_ch_sh).is_err()); + // A record length below the 17-byte AEAD/content-type floor → Err. + assert!(emit_server_flight(&keys, &mk(vec![10]), &cert_chain, &leaf_der, &signing_key, &transcript_ch_sh).is_err()); + // Total capacity far below the flight size → FlightTooLarge. + let err = emit_server_flight(&keys, &mk(vec![20, 20]), &cert_chain, &leaf_der, &signing_key, &transcript_ch_sh) + .unwrap_err(); + assert!(matches!(err, Error::FlightTooLarge)); +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `cargo test -p yip-utls --lib stream::tests::emit_server_flight` +Expected: FAIL — `emit_server_flight` / `ServerFlight` not found. + +- [ ] **Step 4: Implement `ServerFlight` + `emit_server_flight`** + +Add to `stream.rs` (near `capture_dest_flight`). Uses two tiny local helpers for handshake-message framing: + +```rust +/// The output of [`emit_server_flight`]: the wire bytes to send (CCS ‖ sealed +/// handshake-key records) and the application traffic keys for the data phase. +pub struct ServerFlight { + pub wire: Vec, + pub app_keys: handshake::ApplicationKeys, +} + +/// Wrap a handshake-message body as `type ‖ u24 len ‖ body`. +fn handshake_message(msg_type: u8, body: &[u8]) -> Result, Error> { + let len = u32::try_from(body.len()).map_err(|_| Error::RecordTooLarge)?; + if body.len() > 0xFF_FFFF { + return Err(Error::RecordTooLarge); + } + let mut out = Vec::with_capacity(4 + body.len()); + out.push(msg_type); + out.extend_from_slice(&len.to_be_bytes()[1..]); // u24 + out.extend_from_slice(body); + Ok(out) +} + +/// A single `CertificateEntry`: `u24 cert_data_len ‖ der ‖ u16 ext_len(0)`. +fn certificate_entry(der: &[u8]) -> Result, Error> { + let len = u32::try_from(der.len()).map_err(|_| Error::RecordTooLarge)?; + if der.len() > 0xFF_FFFF { + return Err(Error::RecordTooLarge); + } + let mut out = Vec::with_capacity(3 + der.len() + 2); + out.extend_from_slice(&len.to_be_bytes()[1..]); // u24 + out.extend_from_slice(der); + out.extend_from_slice(&[0x00, 0x00]); // empty entry extensions + Ok(out) +} + +/// REALITY.5c: assemble the encrypted server flight (EE/Certificate/ +/// CertificateVerify/Finished), seal it under `keys` handshake keys, and frame +/// it to byte-match `flight_shape.record_lengths` (padding each record to its +/// captured length), prefixed with the middlebox-compat CCS. Returns the wire +/// bytes + the derived application keys. Fail-closed: a malformed or +/// too-small `record_lengths` → `Err` (5d degrades to splice). +pub fn emit_server_flight( + keys: &HandshakeKeys, + flight_shape: &crate::template::EncryptedFlightShape, + cert_chain: &crate::template::CertChainShape, + forged_leaf_der: &[u8], + cert_signing_key: &p256::ecdsa::SigningKey, + transcript_ch_sh: &[u8], +) -> Result { + let suite = keys.suite; + + // 1. EncryptedExtensions: empty extensions list. + let ee = handshake_message(0x08, &[0x00, 0x00])?; + + // 2. Certificate: empty context ‖ u24 list-len ‖ leaf entry ‖ intermediates. + let mut cert_list = certificate_entry(forged_leaf_der)?; + for inter in &cert_chain.intermediates_der { + cert_list.extend_from_slice(&certificate_entry(inter)?); + } + let mut cert_body = Vec::with_capacity(1 + 3 + cert_list.len()); + cert_body.push(0x00); // certificate_request_context length = 0 + let list_len = u32::try_from(cert_list.len()).map_err(|_| Error::RecordTooLarge)?; + if cert_list.len() > 0xFF_FFFF { + return Err(Error::RecordTooLarge); + } + cert_body.extend_from_slice(&list_len.to_be_bytes()[1..]); // u24 + cert_body.extend_from_slice(&cert_list); + let certificate = handshake_message(0x0b, &cert_body)?; + + // 3. CertificateVerify over the transcript through Certificate. + let mut tr = transcript_ch_sh.to_vec(); + tr.extend_from_slice(&ee); + tr.extend_from_slice(&certificate); + let th_cert = transcript_hash(&tr, suite); + let sig = sign_certificate_verify(cert_signing_key, &th_cert)?; + let mut cv_body = Vec::with_capacity(2 + 2 + sig.len()); + cv_body.extend_from_slice(&0x0403u16.to_be_bytes()); // ecdsa_secp256r1_sha256 + cv_body.extend_from_slice(&u16::try_from(sig.len()).map_err(|_| Error::RecordTooLarge)?.to_be_bytes()); + cv_body.extend_from_slice(&sig); + let certificate_verify = handshake_message(0x0f, &cv_body)?; + + // 4. Finished over the transcript through CertificateVerify. + tr.extend_from_slice(&certificate_verify); + let th_cv = transcript_hash(&tr, suite); + let verify_data = handshake::finished_verify_data(&keys.server_hs_traffic, &th_cv, suite); + let finished = handshake_message(0x14, &verify_data)?; + + // Application keys over the transcript through the server Finished. + tr.extend_from_slice(&finished); + let th_fin = transcript_hash(&tr, suite); + let app_keys = handshake::derive_application_keys(&keys.handshake_secret, &th_fin, suite); + + // The contiguous handshake-message plaintext to frame. + let mut hs_stream = Vec::with_capacity(ee.len() + certificate.len() + certificate_verify.len() + finished.len()); + hs_stream.extend_from_slice(&ee); + hs_stream.extend_from_slice(&certificate); + hs_stream.extend_from_slice(&certificate_verify); + hs_stream.extend_from_slice(&finished); + + // 5. Greedy record framing to match record_lengths exactly. + if flight_shape.record_lengths.is_empty() { + return Err(Error::Protocol("empty record_lengths in captured flight template")); + } + let mut wire = CHANGE_CIPHER_SPEC_RECORD.to_vec(); + let mut off = 0usize; + for (i, &rlen) in flight_shape.record_lengths.iter().enumerate() { + // cap = plaintext(+padding) budget for this record (excludes content-type + tag). + let cap = rlen + .checked_sub(17) + .ok_or(Error::Protocol("record length below the 17-byte AEAD floor"))?; + let remaining = hs_stream.len() - off; + let chunk_len = remaining.min(cap); + let chunk = &hs_stream[off..off + chunk_len]; + let pad_len = cap - chunk_len; // once hs_stream is exhausted, chunk_len=0 → pad_len=cap (pure-padding record) + let seq = u64::try_from(i).map_err(|_| Error::Protocol("record index overflow"))?; + let sealed = record_seal_padded( + &keys.server_key, &keys.server_iv, seq, suite, + CONTENT_TYPE_HANDSHAKE, chunk, pad_len, + )?; + let hdr = record_header(CONTENT_TYPE_APPLICATION_DATA, LEGACY_RECORD_VERSION, sealed.len())?; + wire.extend_from_slice(&hdr); + wire.extend_from_slice(&sealed); + off += chunk_len; + } + if off < hs_stream.len() { + // The flight did not fit the captured record framing. + return Err(Error::FlightTooLarge); + } + + Ok(ServerFlight { wire, app_keys }) +} +``` + +Add the needed imports to `stream.rs`'s `use crate::handshake::{...}` line: `record_seal_padded`, `ApplicationKeys`, `derive_application_keys`, `finished_verify_data` (whichever are not already imported — check the existing `use` at the top of `stream.rs` first and add only the missing names). + +**Note the highlighted greedy property:** once `off` reaches `hs_stream.len()`, every subsequent record gets `chunk_len = 0` and `pad_len = cap`, producing pure-padding records of exactly the requested size — preserving `dest`'s record count and each length even when our flight is shorter than dest's. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p yip-utls --lib stream::tests::emit_server_flight` then `cargo test -p yip-utls` +Expected: PASS. + +- [ ] **Step 6: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt +git add crates/yip-utls/src/error.rs crates/yip-utls/src/stream.rs +git commit -m "feat(reality.5c): emit_server_flight — assemble+seal+frame the encrypted flight to record_lengths" +``` + +--- + +### Task 5: Role-agnostic `RealityStream` (egress/ingress) + constructors + +Rename the stream's direction-specific fields so the same state machine serves both roles, and add constructors. Pure mechanical rename + additive constructors — behavior-identical; the existing client/round-trip tests are the regression net. + +**Files:** +- Modify: `crates/yip-utls/src/stream.rs` (`RealityStream` struct ~1216-1240; its `impl` `poll_*` methods; the `connect` construction ~984-998; the two test literals ~1652-1681) + +**Interfaces:** +- Consumes: `ApplicationKeys` (`crate::handshake`). +- Produces: + - `RealityStream` fields renamed: `client_key/client_iv/client_seq` → `egress_key/egress_iv/egress_seq`; `server_key/server_iv/server_seq` → `ingress_key/ingress_iv/ingress_seq`. + - `RealityStream::::client(inner: S, ak: ApplicationKeys) -> Self` (egress = client keys, ingress = server keys — the connect direction). + - `RealityStream::::server(inner: S, ak: ApplicationKeys) -> Self` (egress = server keys, ingress = client keys — the serve direction). + +- [ ] **Step 1: Rename the fields** + +In the `RealityStream` struct definition rename the six fields (and update their doc comments to say "egress" / "ingress" rather than "client" / "server"). Then update every use inside the `impl RealityStream` methods: +- `poll_fill_read_buf` opens ingress records → `self.ingress_key` / `self.ingress_iv` / `self.ingress_seq`. +- `poll_write` seals egress records → `this.egress_key` / `this.egress_iv` / `this.egress_seq`. + +Grep to find every occurrence so none are missed: + +```bash +grep -n "\.client_key\|\.client_iv\|\.client_seq\|\.server_key\|\.server_iv\|\.server_seq\|client_key:\|client_iv:\|client_seq:\|server_key:\|server_iv:\|server_seq:" crates/yip-utls/src/stream.rs +``` + +Only occurrences **on a `RealityStream` value / literal** get renamed — do NOT touch `hk.server_key` / `ak.client_key` etc. (those are `HandshakeKeys` / `ApplicationKeys` field names, unchanged). + +- [ ] **Step 2: Add the constructors + convert the `connect` literal** + +Add to `impl RealityStream` (or a plain `impl RealityStream` — no bounds needed to construct): + +```rust + /// Build the CLIENT-role stream (used by [`connect`]): egress sealed with + /// the client application key, ingress opened with the server's. + fn client(inner: S, ak: handshake::ApplicationKeys) -> Self { + RealityStream { + inner, + suite: ak.suite, + egress_key: ak.client_key, + egress_iv: ak.client_iv, + egress_seq: 0, + ingress_key: ak.server_key, + ingress_iv: ak.server_iv, + ingress_seq: 0, + raw_buf: Vec::new(), + read_buf: Vec::new(), + read_pos: 0, + write_buf: Vec::new(), + write_off: 0, + } + } + + /// Build the SERVER-role stream (used by [`serve`]): egress sealed with the + /// server application key, ingress opened with the client's — the mirror of + /// [`client`]. + fn server(inner: S, ak: handshake::ApplicationKeys) -> Self { + RealityStream { + inner, + suite: ak.suite, + egress_key: ak.server_key, + egress_iv: ak.server_iv, + egress_seq: 0, + ingress_key: ak.client_key, + ingress_iv: ak.client_iv, + ingress_seq: 0, + raw_buf: Vec::new(), + read_buf: Vec::new(), + read_pos: 0, + write_buf: Vec::new(), + write_off: 0, + } + } +``` + +Replace the `connect` construction (`Ok(RealityStream { inner: stream, ... })` ~984) with `Ok(RealityStream::client(stream, ak))`. + +- [ ] **Step 3: Update the two test literals** + +In `reality_stream_round_trips_over_duplex` (~1652, 1667), rename `client_key/client_iv/client_seq` → `egress_key/egress_iv/egress_seq` and `server_key/server_iv/server_seq` → `ingress_key/ingress_iv/ingress_seq` in both `RealityStream { .. }` literals (the semantic mapping is unchanged — A's egress is what B ingests). + +- [ ] **Step 4: Run the full suite to prove the rename is behavior-preserving** + +Run: `cargo test -p yip-utls` +Expected: PASS — every existing test (client `connect`, verify round-trips, JA4-diff, 5a/5b, the duplex round-trip) stays green. This is the regression gate for the rename. + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt +git add crates/yip-utls/src/stream.rs +git commit -m "refactor(reality.5c): role-agnostic RealityStream (egress/ingress) + client/server constructors" +``` + +--- + +### Task 6: `serve` + drive the verify round-trip suite through 5c (THE GATE) + +Compose `emit_server_flight` + write + drain the client's CCS/Finished + build the server stream into a single `serve` entry point, then refactor the shared test mock `run_mock_tls13_server_with_cert` to emit its flight **through `serve`/`emit_server_flight`** — so the four shipped `connect_verify_*` round-trip tests exercise real 5c code end-to-end (the correctness gate). Add a bidirectional data assertion. + +**Files:** +- Modify: `crates/yip-utls/src/stream.rs` (add `serve`; refactor `run_mock_tls13_server_with_cert` ~2180-end; add a bidirectional round-trip test) + +**Interfaces:** +- Consumes: `emit_server_flight` / `ServerFlight` (Task 4); `RealityStream::server` (Task 5); `read_raw_record`, `record_open`, `HandshakeKeys` (existing). +- Produces: `pub async fn serve(stream: S, keys: &HandshakeKeys, flight_shape: &EncryptedFlightShape, cert_chain: &CertChainShape, forged_leaf_der: &[u8], cert_signing_key: &p256::ecdsa::SigningKey, transcript_ch_sh: &[u8]) -> Result, Error>` + +- [ ] **Step 1: Implement `serve`** + +Add to `stream.rs`. It emits the flight, writes it, drains the client's CCS + Finished (contents unchecked — zero client-auth, symmetric with how `connect` drains the server's Finished), and returns the server-role stream. The client sends exactly a CCS record then one sealed handshake (Finished) record: + +```rust +/// REALITY.5c server entry point — the mirror of [`connect`]. Emits the +/// encrypted server flight (via [`emit_server_flight`]) framed to `dest`'s +/// captured record lengths, writes it, drains the client's middlebox CCS + +/// `Finished` (contents UNCHECKED — the outer TLS is zero client-auth by +/// design, exactly as `connect` never checks the server's `Finished`), and +/// returns the server-role [`RealityStream`] on the derived application keys. +/// The ServerHello (REALITY.5b `emit_server_hello`) and `transcript_ch_sh` +/// (raw ClientHello ‖ ServerHello) must already have been produced/sent by +/// the caller (5d). +pub async fn serve( + mut stream: S, + keys: &HandshakeKeys, + flight_shape: &crate::template::EncryptedFlightShape, + cert_chain: &crate::template::CertChainShape, + forged_leaf_der: &[u8], + cert_signing_key: &p256::ecdsa::SigningKey, + transcript_ch_sh: &[u8], +) -> Result, Error> { + let flight = emit_server_flight( + keys, flight_shape, cert_chain, forged_leaf_der, cert_signing_key, transcript_ch_sh, + )?; + stream.write_all(&flight.wire).await?; + + // Drain the client's CCS + sealed Finished. The client sends a CCS record + // (skipped) then exactly one application-data record carrying the sealed + // Finished, opened under the CLIENT handshake key; its contents are not + // validated (zero client-auth). + let mut client_hs_seq = 0u64; + loop { + let (record_type, mut payload) = read_raw_record(&mut stream).await?; + if record_type == CONTENT_TYPE_CHANGE_CIPHER_SPEC { + continue; + } + if record_type != CONTENT_TYPE_APPLICATION_DATA { + return Err(Error::Protocol("expected the client's sealed Finished record")); + } + // Open (and discard) the client Finished — proves it is well-framed + // under the negotiated key; contents intentionally unchecked. + record_open( + &keys.client_key, &keys.client_iv, client_hs_seq, keys.suite, + record_type, &mut payload, + )?; + client_hs_seq = client_hs_seq + .checked_add(1) + .ok_or(Error::Protocol("client handshake sequence overflow"))?; + break; + } + + Ok(RealityStream::server(stream, flight.app_keys)) +} +``` + +- [ ] **Step 2: Refactor `run_mock_tls13_server_with_cert` to emit its flight through 5c** + +The mock currently hand-rolls EE/Certificate/CertificateVerify/Finished, seals them in one record, and hand-drives the app-data echo (roughly lines 2274 to the end of the function). Replace that tail — everything from the `ee_msg`/certificate construction through the sealed-flight write and the client-Finished drain and the echo setup — with a call to `serve`, keeping the per-behavior `(leaf_der, signing_key)` selection that already exists (~2255-2272). Concretely, after the mock has `hk`, `ch_sh_transcript`, `derived`, and the `(leaf_key_pkcs8, signing_key_pkcs8)` pair: + +```rust + // Build the (forged leaf, signing key) pair per behavior — unchanged. + let leaf_key = rcgen::KeyPair::try_from(leaf_key_pkcs8.as_slice()).unwrap(); + let leaf_der = leaf_der_for_key(&leaf_key); + use p256::pkcs8::DecodePrivateKey as _; + let cert_signing_key = p256::ecdsa::SigningKey::from_pkcs8_der(&signing_key_pkcs8).unwrap(); + + let intermediates = if include_intermediate { + let intermediate_key = rcgen::KeyPair::generate().unwrap(); + vec![leaf_der_for_key(&intermediate_key)] + } else { + Vec::new() + }; + let flight_shape = crate::template::EncryptedFlightShape { + record_lengths: vec![600, 4096], // comfortably larger than the flight; exercises padding + encrypted_extensions_len: 0, + certificate_len: 0, + certificate_verify_len: 0, + finished_len: 0, + }; + let cert_chain = crate::template::CertChainShape { + leaf_der_len: leaf_der.len(), + intermediates_der: intermediates, + }; + // transcript_ch_sh = the RAW ClientHello ‖ ServerHello bytes (ch_sh_transcript), + // NOT the hash — emit_server_flight hashes internally. + // + // IMPORTANT: do NOT `.expect()` on serve. For the reject behaviors + // (WrongLeafKey / BadSignature) the client verifies, FAILS, and drops the + // connection WITHOUT sending its Finished — so `serve` (which drains the + // client Finished) returns Err. That is the correct outcome for those + // tests (they assert the CLIENT `connect` returns Err); the server task + // must simply exit cleanly, not panic. `if let Ok` handles both paths. + if let Ok(mut server) = serve( + io, &hk, &flight_shape, &cert_chain, &leaf_der, &cert_signing_key, &ch_sh_transcript, + ) + .await + { + // Echo whatever the client sends (robust to any ping size the + // round-trip tests use), proving the server-app egress seal + client + // ingress open both work. + let mut buf = [0u8; 64]; + if let Ok(n) = server.read(&mut buf).await { + if n > 0 { + let _ = server.write_all(&buf[..n]).await; + let _ = server.flush().await; + } + } + } +``` + +Remove the now-dead hand-rolled flight/seal/drain/echo code and any locals it alone used (e.g. `server_flight_plain`, `sealed_flight`, `transcript_thru_sfin`, the manual app-key derivation, `build_certificate_message*`, `build_certificate_verify_message` calls — but leave those test *helpers* defined if other tests use them; grep before deleting a helper). `ch_sh_transcript` is consumed by `serve`; if a later line still needs it, clone before the call. Ensure `io` is `mut` as required by `serve`'s `stream.write_all`. + +Note: the mock must still produce the raw `ch_sh_transcript` as `ch_msg ‖ sh_msg` (it already does at ~2241-2243) and derive `hk` (~2245) — those stay. The ServerHello it hand-rolls (~2196-2238) stays (5c consumes a ready ServerHello; wiring 5b's `emit_server_hello` in is 5d's job). + +- [ ] **Step 3: Add a bidirectional round-trip assertion** + +The existing `connect_verify_accepts_correct_binder` only checks client→server→echo. Add a focused test proving BOTH directions over the real 5c server stream (server-initiated write): + +```rust +#[tokio::test] +async fn serve_round_trips_application_data_both_directions() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let server_reality_priv = [3u8; 32]; + let server_reality_pub = { + let secret = x25519_dalek::StaticSecret::from(server_reality_priv); + x25519_dalek::PublicKey::from(&secret).to_bytes() + }; + + // Server: hand-rolled ServerHello + 5c serve (reuses the refactored mock, + // CorrectBinder → the client must verify the 4b binding). + let server_task = tokio::spawn(run_mock_tls13_server_with_cert( + server_io, + server_reality_priv, + ServerCertBehavior::CorrectBinder, + false, + )); + + let mut s = connect(client_io, "example.com", &server_reality_pub, [1u8; 8], true) + .await + .expect("correctly-bound relay verifies"); + + // client → server → echo (server's echo path in the mock). + s.write_all(b"PING").await.unwrap(); + s.flush().await.unwrap(); + let mut buf = [0u8; 4]; + s.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"PING"); + + server_task.await.unwrap(); +} +``` + +(The server→client direction is proven by the echo — the server's `write_all` in the refactored mock seals under the server-app egress key and the client opens it with its ingress key. If you prefer an explicit server-first write, add a variant mock; the echo already exercises both seal directions.) + +- [ ] **Step 4: Run the full suite (the gate)** + +Run: `cargo test -p yip-utls` +Expected: PASS — in particular `connect_verify_accepts_correct_binder`, `connect_verify_accepts_correct_binder_with_intermediate_chain`, `connect_verify_rejects_wrong_key`, `connect_verify_rejects_bad_signature`, `connect_no_verify_ignores_cert`, and the new `serve_round_trips_application_data_both_directions` — all now driving the real `emit_server_flight`/`serve` code. The reject tests prove the 4b binding still fails closed when the server presents a wrong leaf key / bad signature *through 5c's emission*. + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-utls --all-targets -- -D warnings +cargo fmt +git add crates/yip-utls/src/stream.rs +git commit -m "feat(reality.5c): serve — server flight + drain + stream; verify round-trip suite runs through 5c" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the 5c delta (base = 5b tip / PR #85 head), focused on: KEX/transcript correctness (CertVerify + Finished over the right boundaries), fail-closed framing (`FlightTooLarge`, `< 17`, empty), the role rename being behavior-preserving, and the verify-reject tests genuinely failing closed through the new emission path. +- Push the branch; open a PR **stacked on #85** (base = `feat/reality-5b-serverhello-emit`). Leave it for the user; do NOT merge; no "not merging" line. +- Update the ledger + `yip-antidpi-status.md` memory (5c complete; 5d = wire into `tls_front`, forge the leaf to `leaf_der_len`, honor the 5d checklist from 5b: key the 4588 DH against the bundled x25519 / OS-CSPRNG rng). + +## Self-Review notes (carried from spec) + +- **Greedy chunking of an empty remaining stream** yields pure-padding records of the exact requested size (`chunk_len = 0`, `pad_len = cap`) — preserving `dest`'s record count + lengths. Highlighted in Task 4 Step 4. +- **`record_open` already strips trailing padding** (`while payload.last() == Some(&0) { payload.pop(); }`), so the shipped client consumes 5c's padded records with no change — the round-trip suite (Task 6) is the proof. From ef6ab12c8cb23fe2c5d3f42e201ddcb9fdddb284 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 22:35:38 -0400 Subject: [PATCH 11/27] feat(reality.5c): expose server_hs_traffic on HandshakeKeys (server Finished base secret) --- crates/yip-utls/src/handshake.rs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/yip-utls/src/handshake.rs b/crates/yip-utls/src/handshake.rs index 2ee51dd..e544fe1 100644 --- a/crates/yip-utls/src/handshake.rs +++ b/crates/yip-utls/src/handshake.rs @@ -479,11 +479,12 @@ fn key_len_for_suite(suite: u16) -> usize { /// The client/server handshake traffic keys, plus the handshake secret /// itself (needed as an input to [`derive_application_keys`]) and the raw -/// client handshake traffic secret (needed by Task 7's `connect`, via -/// [`finished_verify_data`], to derive the client `Finished` message). -/// `handshake_secret`/`client_hs_traffic` are 32 bytes for SHA-256-hash -/// suites, 48 bytes for `TLS_AES_256_GCM_SHA384` (`0x1302`) — hence `Vec` -/// rather than a fixed-size array. +/// client/server handshake traffic secrets (needed by Task 7's `connect`, via +/// [`finished_verify_data`], to derive the client `Finished` message, and by +/// REALITY.5c's server-side logic to derive the server `Finished`'s verify_data). +/// `handshake_secret`/`client_hs_traffic`/`server_hs_traffic` are 32 bytes for +/// SHA-256-hash suites, 48 bytes for `TLS_AES_256_GCM_SHA384` (`0x1302`) — +/// hence `Vec` rather than a fixed-size array. pub struct HandshakeKeys { pub client_key: Vec, pub client_iv: [u8; 12], @@ -492,6 +493,10 @@ pub struct HandshakeKeys { pub suite: u16, pub handshake_secret: Vec, pub client_hs_traffic: Vec, + /// The server handshake-traffic secret (`s hs traffic`) — the base secret + /// for the SERVER `Finished`'s `verify_data` (REALITY.5c). Sibling of + /// `client_hs_traffic`; both are 32 bytes (SHA-256) or 48 (SHA-384). + pub server_hs_traffic: Vec, } /// Derives the TLS 1.3 handshake traffic keys from the ECDHE (or hybrid @@ -556,6 +561,7 @@ pub fn derive_handshake_keys( suite, handshake_secret, client_hs_traffic: c_hs, + server_hs_traffic: s_hs, } } @@ -1272,4 +1278,20 @@ mod tests { Error::EmptyInnerPlaintext ); } + + #[test] + fn handshake_keys_expose_distinct_server_hs_traffic() { + let ecdhe = [7u8; 32]; + let transcript = transcript_hash(b"client-hello||server-hello", SUITE_AES_128_GCM_SHA256); + let hk = derive_handshake_keys(&ecdhe, &transcript, SUITE_AES_128_GCM_SHA256); + assert!( + !hk.server_hs_traffic.is_empty(), + "server_hs_traffic must be populated" + ); + assert_eq!(hk.server_hs_traffic.len(), hk.client_hs_traffic.len()); + assert_ne!( + hk.server_hs_traffic, hk.client_hs_traffic, + "server and client handshake-traffic secrets must differ" + ); + } } From 9bee5278bdd58508a174582a6578a9cb6f9b7d2b Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 17 Jul 2026 22:39:54 -0400 Subject: [PATCH 12/27] feat(reality.5c): record_seal_padded (TLS record padding); record_seal delegates --- crates/yip-utls/src/handshake.rs | 72 ++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/yip-utls/src/handshake.rs b/crates/yip-utls/src/handshake.rs index e544fe1..5c35c8b 100644 --- a/crates/yip-utls/src/handshake.rs +++ b/crates/yip-utls/src/handshake.rs @@ -669,30 +669,29 @@ fn make_nonce(iv: &[u8; 12], seq: u64) -> Nonce { Nonce::assume_unique_for_key(nonce_bytes) } -/// Seals `plaintext` as a TLS 1.3 protected record, appending `content_type` -/// as the real (inner) content type before encryption (no additional -/// padding). AEAD algorithm is selected from the negotiated `suite` (key -/// length alone cannot disambiguate `TLS_AES_256_GCM_SHA384` from -/// `TLS_CHACHA20_POLY1305_SHA256` — both use 32-byte keys). Returns the wire -/// `encrypted_record` body (ciphertext ‖ tag) — the caller prepends the -/// 5-byte outer record header `[0x17, 0x03, 0x03, len_hi, len_lo]` (`len = ` -/// returned length) itself. -pub fn record_seal( +/// Like [`record_seal`], but appends `pad_len` zero bytes of TLS 1.3 record +/// padding after the inner content-type (RFC 8446 §5.4), so the sealed +/// record's ciphertext-payload length is exactly +/// `content.len() + 1 + pad_len + tag_len`. Used by REALITY.5c to frame the +/// server flight to `dest`'s captured per-record lengths. +pub fn record_seal_padded( key: &[u8], iv: &[u8; 12], seq: u64, suite: u16, content_type: u8, - plaintext: &[u8], + content: &[u8], + pad_len: usize, ) -> Result, Error> { let alg = algorithm_for_suite(suite)?; let unbound = UnboundKey::new(alg, key).map_err(|_| Error::Crypto)?; let less_safe = LessSafeKey::new(unbound); let nonce = make_nonce(iv, seq); - let mut inner = Vec::with_capacity(plaintext.len() + 1); - inner.extend_from_slice(plaintext); + let mut inner = Vec::with_capacity(content.len() + 1 + pad_len); + inner.extend_from_slice(content); inner.push(content_type); + inner.resize(inner.len() + pad_len, 0u8); let total_len = inner.len() + alg.tag_len(); let len_bytes = u16::try_from(total_len) @@ -713,6 +712,19 @@ pub fn record_seal( Ok(inner) } +/// Seals `plaintext` as a TLS 1.3 protected record with no record padding. +/// Thin wrapper over [`record_seal_padded`] with `pad_len = 0`. +pub fn record_seal( + key: &[u8], + iv: &[u8; 12], + seq: u64, + suite: u16, + content_type: u8, + plaintext: &[u8], +) -> Result, Error> { + record_seal_padded(key, iv, seq, suite, content_type, plaintext, 0) +} + /// Opens a TLS 1.3 protected record. `record_type` is the *outer* record /// type read from the record header on the wire (always `0x17` for TLS 1.3 /// post-`ServerHello` records) and is used to reconstruct the AAD; `payload` @@ -1294,4 +1306,40 @@ mod tests { "server and client handshake-traffic secrets must differ" ); } + + #[test] + fn record_seal_padded_opens_to_content_with_padding_stripped() { + let key = [0x42u8; 16]; + let iv = [0x24u8; 12]; + let content = b"encrypted-extensions-bytes"; + let pad_len = 40usize; + let sealed = record_seal_padded( + &key, + &iv, + 0, + SUITE_AES_128_GCM_SHA256, + 0x16, + content, + pad_len, + ) + .unwrap(); + // Ciphertext-payload length is content ‖ content_type(1) ‖ pad ‖ tag(16). + assert_eq!(sealed.len(), content.len() + 1 + pad_len + 16); + // record_open strips the padding + content-type and recovers the content. + let mut payload = sealed.clone(); + let opened = + record_open(&key, &iv, 0, SUITE_AES_128_GCM_SHA256, 0x17, &mut payload).unwrap(); + assert_eq!(opened, content); + } + + #[test] + fn record_seal_padded_zero_equals_record_seal() { + let key = [0x01u8; 16]; + let iv = [0x02u8; 12]; + let content = b"finished-msg"; + let via_padded = + record_seal_padded(&key, &iv, 5, SUITE_AES_128_GCM_SHA256, 0x16, content, 0).unwrap(); + let via_plain = record_seal(&key, &iv, 5, SUITE_AES_128_GCM_SHA256, 0x16, content).unwrap(); + assert_eq!(via_padded, via_plain); + } } From b568122d0448a6e194a9c6b99fd576ab37601045 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 01:43:49 -0400 Subject: [PATCH 13/27] =?UTF-8?q?feat(reality.5c):=20sign=5Fcertificate=5F?= =?UTF-8?q?verify=20=E2=80=94=20signing=20mirror=20of=20the=204b=20verifie?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/yip-utls/src/stream.rs | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/yip-utls/src/stream.rs b/crates/yip-utls/src/stream.rs index fc63971..49a6443 100644 --- a/crates/yip-utls/src/stream.rs +++ b/crates/yip-utls/src/stream.rs @@ -640,6 +640,32 @@ fn verify_certificate_verify( .map_err(|_| Error::RealityVerify("CertificateVerify signature does not verify")) } +/// The signing counterpart of [`verify_certificate_verify`]: sign the TLS 1.3 +/// server `CertificateVerify` (RFC 8446 §4.4.3) with the derived ECDSA-P256 +/// key `signing_key` (from `auth::derive_cert_key(shared)`), over the SAME +/// signed content the verifier reconstructs. This IS the REALITY.4b binding, +/// server side — the client pins the presented leaf's SPKI to this key and +/// verifies this signature. `ecdsa_secp256r1_sha256` (scheme 0x0403) is fixed: +/// `p256::ecdsa::SigningKey` signs SHA-256 internally, so no `suite` is needed +/// (the caller uses `suite` only to compute `transcript_hash_through_certificate`). +pub fn sign_certificate_verify( + signing_key: &p256::ecdsa::SigningKey, + transcript_hash_through_certificate: &[u8], +) -> Result, Error> { + use p256::ecdsa::{signature::Signer, Signature}; + + let mut signed = Vec::with_capacity(64 + 33 + 1 + transcript_hash_through_certificate.len()); + signed.extend_from_slice(&[0x20u8; 64]); + signed.extend_from_slice(b"TLS 1.3, server CertificateVerify"); + signed.push(0x00); + signed.extend_from_slice(transcript_hash_through_certificate); + + let sig: Signature = signing_key + .try_sign(&signed) + .map_err(|_| Error::Handshake(handshake::Error::Crypto))?; + Ok(sig.to_der().as_bytes().to_vec()) +} + /// Orchestrates REALITY.4b `CertificateVerify` verification: locates the /// Certificate/CertificateVerify messages in `hs_flight` (see /// [`scan_cert_flight`]), extracts the leaf's P-256 SPKI (see @@ -2848,4 +2874,56 @@ mod tests { assert!(!cap.template.encrypted_flight.record_lengths.is_empty()); assert!(!cap.leaf_der.is_empty()); } + + // ----------------------------------------------------------------- + // REALITY.5c Task 3: sign_certificate_verify + // ----------------------------------------------------------------- + + #[test] + fn sign_certificate_verify_is_accepted_by_the_verifier() { + use p256::ecdsa::SigningKey; + const SUITE: u16 = 0x1301; // TLS_AES_128_GCM_SHA256 + + let signing_key = SigningKey::from_slice(&[0x11u8; 32]).unwrap(); + let pubkey_sec1 = signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + let transcript = transcript_hash(b"ch||sh||ee||cert", SUITE); + + let sig = sign_certificate_verify(&signing_key, &transcript).unwrap(); + + // The shipped verifier accepts it for the same transcript + pinned key. + verify_certificate_verify(&pubkey_sec1, &pubkey_sec1, &transcript, &sig) + .expect("a self-signed CertificateVerify must verify"); + } + + #[test] + fn sign_certificate_verify_rejected_for_wrong_key_and_tampered_transcript() { + use p256::ecdsa::SigningKey; + const SUITE: u16 = 0x1301; // TLS_AES_128_GCM_SHA256 + + let signing_key = SigningKey::from_slice(&[0x11u8; 32]).unwrap(); + let other_key = SigningKey::from_slice(&[0x22u8; 32]).unwrap(); + let other_pub = other_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + let transcript = transcript_hash(b"ch||sh||ee||cert", SUITE); + let sig = sign_certificate_verify(&signing_key, &transcript).unwrap(); + + // Wrong pinned key → RealityVerify. + assert!(verify_certificate_verify(&other_pub, &other_pub, &transcript, &sig).is_err()); + + // Tampered transcript (verify against a different hash) → RealityVerify. + let signer_pub = signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + let tampered = transcript_hash(b"different-transcript", SUITE); + assert!(verify_certificate_verify(&signer_pub, &signer_pub, &tampered, &sig).is_err()); + } } From 54c643b4b35088ea12f8f649949e8f028b5da95b Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 01:51:55 -0400 Subject: [PATCH 14/27] =?UTF-8?q?feat(reality.5c):=20emit=5Fserver=5Ffligh?= =?UTF-8?q?t=20=E2=80=94=20assemble+seal+frame=20the=20encrypted=20flight?= =?UTF-8?q?=20to=20record=5Flengths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/yip-utls/src/error.rs | 14 +- crates/yip-utls/src/stream.rs | 314 +++++++++++++++++++++++++++++++++- 2 files changed, 325 insertions(+), 3 deletions(-) diff --git a/crates/yip-utls/src/error.rs b/crates/yip-utls/src/error.rs index 531ff8d..afe1982 100644 --- a/crates/yip-utls/src/error.rs +++ b/crates/yip-utls/src/error.rs @@ -35,6 +35,11 @@ pub enum Error { /// to key a TLS group it does not implement server-side KEX for (only /// `29`/X25519 and `4588`/X25519MLKEM768 are supported). UnsupportedGroup(u16), + /// The forged server flight's plaintext does not fit the captured + /// `dest` record framing (`sum(record_lengths[i] - 17)` < flight bytes, + /// or a malformed `record_lengths`). REALITY.5c fail-safe: 5d degrades + /// this connection to splice-only. + FlightTooLarge, } impl fmt::Display for Error { @@ -47,6 +52,12 @@ impl fmt::Display for Error { 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}"), + Error::FlightTooLarge => { + write!( + f, + "REALITY/TLS server flight exceeds the captured dest record framing" + ) + } } } } @@ -60,7 +71,8 @@ impl std::error::Error for Error { Error::Protocol(_) | Error::Clock | Error::RealityVerify(_) - | Error::UnsupportedGroup(_) => None, + | Error::UnsupportedGroup(_) + | Error::FlightTooLarge => None, } } } diff --git a/crates/yip-utls/src/stream.rs b/crates/yip-utls/src/stream.rs index 49a6443..d22772f 100644 --- a/crates/yip-utls/src/stream.rs +++ b/crates/yip-utls/src/stream.rs @@ -32,8 +32,8 @@ use crate::auth; use crate::error::Error; use crate::handshake::{ self, derive_application_keys, derive_handshake_keys, finished_verify_data, parse_server_hello, - parse_server_hello_shape, record_open, record_open_typed, record_seal, transcript_hash, - HandshakeKeys, GROUP_X25519, GROUP_X25519MLKEM768, + parse_server_hello_shape, record_open, record_open_typed, record_seal, record_seal_padded, + transcript_hash, ApplicationKeys, HandshakeKeys, GROUP_X25519, GROUP_X25519MLKEM768, }; use crate::hello::{self, ClientHelloParams, RandomSource}; use crate::template::{CapturedFlight, CertChainShape, EncryptedFlightShape, ServerFlightTemplate}; @@ -1232,6 +1232,153 @@ pub async fn capture_dest_flight( }) } +/// The output of [`emit_server_flight`]: the wire bytes to send (CCS ‖ sealed +/// handshake-key records) and the application traffic keys for the data phase. +pub struct ServerFlight { + pub wire: Vec, + pub app_keys: ApplicationKeys, +} + +/// Wrap a handshake-message body as `type ‖ u24 len ‖ body`. +fn handshake_message(msg_type: u8, body: &[u8]) -> Result, Error> { + let len = u32::try_from(body.len()).map_err(|_| handshake::Error::RecordTooLarge)?; + if body.len() > 0xFF_FFFF { + return Err(handshake::Error::RecordTooLarge.into()); + } + let mut out = Vec::with_capacity(4 + body.len()); + out.push(msg_type); + out.extend_from_slice(&len.to_be_bytes()[1..]); // u24 + out.extend_from_slice(body); + Ok(out) +} + +/// A single `CertificateEntry`: `u24 cert_data_len ‖ der ‖ u16 ext_len(0)`. +fn certificate_entry(der: &[u8]) -> Result, Error> { + let len = u32::try_from(der.len()).map_err(|_| handshake::Error::RecordTooLarge)?; + if der.len() > 0xFF_FFFF { + return Err(handshake::Error::RecordTooLarge.into()); + } + let mut out = Vec::with_capacity(3 + der.len() + 2); + out.extend_from_slice(&len.to_be_bytes()[1..]); // u24 + out.extend_from_slice(der); + out.extend_from_slice(&[0x00, 0x00]); // empty entry extensions + Ok(out) +} + +/// REALITY.5c: assemble the encrypted server flight (EncryptedExtensions/ +/// Certificate/CertificateVerify/Finished), seal it under `keys`'s handshake +/// keys, and frame it to byte-match `flight_shape.record_lengths` (padding +/// each record to its captured length), prefixed with the middlebox-compat +/// CCS. Returns the wire bytes + the derived application keys. Fail-closed: a +/// malformed or too-small `record_lengths` → `Err` (5d degrades to splice). +pub fn emit_server_flight( + keys: &HandshakeKeys, + flight_shape: &crate::template::EncryptedFlightShape, + cert_chain: &crate::template::CertChainShape, + forged_leaf_der: &[u8], + cert_signing_key: &p256::ecdsa::SigningKey, + transcript_ch_sh: &[u8], +) -> Result { + let suite = keys.suite; + + // 1. EncryptedExtensions: empty extensions list. + let ee = handshake_message(0x08, &[0x00, 0x00])?; + + // 2. Certificate: empty context ‖ u24 list-len ‖ leaf entry ‖ intermediates. + let mut cert_list = certificate_entry(forged_leaf_der)?; + for inter in &cert_chain.intermediates_der { + cert_list.extend_from_slice(&certificate_entry(inter)?); + } + let mut cert_body = Vec::with_capacity(1 + 3 + cert_list.len()); + cert_body.push(0x00); // certificate_request_context length = 0 + let list_len = u32::try_from(cert_list.len()).map_err(|_| handshake::Error::RecordTooLarge)?; + if cert_list.len() > 0xFF_FFFF { + return Err(handshake::Error::RecordTooLarge.into()); + } + cert_body.extend_from_slice(&list_len.to_be_bytes()[1..]); // u24 + cert_body.extend_from_slice(&cert_list); + let certificate = handshake_message(0x0b, &cert_body)?; + + // 3. CertificateVerify over the transcript through Certificate. + let mut tr = transcript_ch_sh.to_vec(); + tr.extend_from_slice(&ee); + tr.extend_from_slice(&certificate); + let th_cert = transcript_hash(&tr, suite); + let sig = sign_certificate_verify(cert_signing_key, &th_cert)?; + let mut cv_body = Vec::with_capacity(2 + 2 + sig.len()); + cv_body.extend_from_slice(&0x0403u16.to_be_bytes()); // ecdsa_secp256r1_sha256 + cv_body.extend_from_slice( + &u16::try_from(sig.len()) + .map_err(|_| handshake::Error::RecordTooLarge)? + .to_be_bytes(), + ); + cv_body.extend_from_slice(&sig); + let certificate_verify = handshake_message(0x0f, &cv_body)?; + + // 4. Finished over the transcript through CertificateVerify. + tr.extend_from_slice(&certificate_verify); + let th_cv = transcript_hash(&tr, suite); + let verify_data = finished_verify_data(&keys.server_hs_traffic, &th_cv, suite); + let finished = handshake_message(0x14, &verify_data)?; + + // Application keys over the transcript through the server Finished. + tr.extend_from_slice(&finished); + let th_fin = transcript_hash(&tr, suite); + let app_keys = derive_application_keys(&keys.handshake_secret, &th_fin, suite); + + // The contiguous handshake-message plaintext to frame. + let mut hs_stream = Vec::with_capacity( + ee.len() + certificate.len() + certificate_verify.len() + finished.len(), + ); + hs_stream.extend_from_slice(&ee); + hs_stream.extend_from_slice(&certificate); + hs_stream.extend_from_slice(&certificate_verify); + hs_stream.extend_from_slice(&finished); + + // 5. Greedy record framing to match record_lengths exactly. + if flight_shape.record_lengths.is_empty() { + return Err(Error::Protocol( + "empty record_lengths in captured flight template", + )); + } + let mut wire = CHANGE_CIPHER_SPEC_RECORD.to_vec(); + let mut off = 0usize; + for (i, &rlen) in flight_shape.record_lengths.iter().enumerate() { + // cap = plaintext(+padding) budget for this record (excludes content-type + tag). + let cap = rlen.checked_sub(17).ok_or(Error::Protocol( + "record length below the 17-byte AEAD floor", + ))?; + let remaining = hs_stream.len() - off; + let chunk_len = remaining.min(cap); + let chunk = &hs_stream[off..off + chunk_len]; + let pad_len = cap - chunk_len; // once hs_stream is exhausted, chunk_len=0 → pad_len=cap (pure-padding record) + let seq = u64::try_from(i).map_err(|_| Error::Protocol("record index overflow"))?; + let sealed = record_seal_padded( + &keys.server_key, + &keys.server_iv, + seq, + suite, + CONTENT_TYPE_HANDSHAKE, + chunk, + pad_len, + )?; + let hdr = record_header( + CONTENT_TYPE_APPLICATION_DATA, + LEGACY_RECORD_VERSION, + sealed.len(), + )?; + wire.extend_from_slice(&hdr); + wire.extend_from_slice(&sealed); + off += chunk_len; + } + if off < hs_stream.len() { + // The flight did not fit the captured record framing. + return Err(Error::FlightTooLarge); + } + + Ok(ServerFlight { wire, app_keys }) +} + /// An established REALITY/TLS 1.3 connection's application-data stream. /// Implements `AsyncRead`/`AsyncWrite` over the negotiated application /// traffic keys, framing plaintext into/out of TLS 1.3 records @@ -2926,4 +3073,167 @@ mod tests { let tampered = transcript_hash(b"different-transcript", SUITE); assert!(verify_certificate_verify(&signer_pub, &signer_pub, &tampered, &sig).is_err()); } + + // ----------------------------------------------------------------- + // REALITY.5c Task 4: emit_server_flight + // ----------------------------------------------------------------- + + // Shared fixture: derive real handshake keys + a leaf whose SPKI is the + // signing key, so the emitted Certificate is well-formed. + fn emit_flight_fixture() -> (HandshakeKeys, p256::ecdsa::SigningKey, Vec, Vec) { + const SUITE_AES_128_GCM_SHA256: u16 = 0x1301; + + let ecdhe = [7u8; 32]; + let transcript_ch_sh = b"raw-clienthello-bytes||raw-serverhello-bytes".to_vec(); + let th = transcript_hash(&transcript_ch_sh, SUITE_AES_128_GCM_SHA256); + let keys = derive_handshake_keys(&ecdhe, &th, SUITE_AES_128_GCM_SHA256); + let signing_key = p256::ecdsa::SigningKey::from_slice(&[0x33u8; 32]).unwrap(); + // A real leaf whose key is the signing key (test-only helper already used + // by the verify tests). `leaf_der_for_key` takes an rcgen KeyPair built + // from the p256 pkcs8. + use p256::pkcs8::EncodePrivateKey as _; + let pkcs8 = signing_key.to_pkcs8_der().unwrap(); + let leaf_key = rcgen::KeyPair::try_from(pkcs8.as_bytes()).unwrap(); + let leaf_der = leaf_der_for_key(&leaf_key); + (keys, signing_key, leaf_der, transcript_ch_sh) + } + + #[test] + fn emit_server_flight_matches_record_lengths_and_reopens_to_messages() { + let (keys, signing_key, leaf_der, transcript_ch_sh) = emit_flight_fixture(); + // Generously sized records: 3 records, last two mostly/entirely padding. + let shape = crate::template::EncryptedFlightShape { + record_lengths: vec![600, 600, 4096], + encrypted_extensions_len: 0, + certificate_len: 0, + certificate_verify_len: 0, + finished_len: 0, + }; + let cert_chain = crate::template::CertChainShape { + leaf_der_len: leaf_der.len(), + intermediates_der: Vec::new(), + }; + + let flight = emit_server_flight( + &keys, + &shape, + &cert_chain, + &leaf_der, + &signing_key, + &transcript_ch_sh, + ) + .unwrap(); + + // First record is the middlebox CCS, byte-for-byte. + assert_eq!( + &flight.wire[..CHANGE_CIPHER_SPEC_RECORD.len()], + &CHANGE_CIPHER_SPEC_RECORD + ); + + // Walk the sealed records after the CCS: each record's header length field + // MUST equal record_lengths[i]; re-open each under the handshake server key + // and concatenate the recovered plaintext. + let mut off = CHANGE_CIPHER_SPEC_RECORD.len(); + let mut recovered = Vec::new(); + for (i, &rlen) in shape.record_lengths.iter().enumerate() { + let hdr = &flight.wire[off..off + RECORD_HEADER_LEN]; + assert_eq!(hdr[0], CONTENT_TYPE_APPLICATION_DATA); + let len_field = usize::from(u16::from_be_bytes([hdr[3], hdr[4]])); + assert_eq!( + len_field, rlen, + "record {i} outer length must match record_lengths" + ); + let mut payload = + flight.wire[off + RECORD_HEADER_LEN..off + RECORD_HEADER_LEN + rlen].to_vec(); + let opened = record_open( + &keys.server_key, + &keys.server_iv, + u64::try_from(i).unwrap(), + keys.suite, + CONTENT_TYPE_APPLICATION_DATA, + &mut payload, + ) + .unwrap(); + recovered.extend_from_slice(&opened); + off += RECORD_HEADER_LEN + rlen; + } + assert_eq!( + off, + flight.wire.len(), + "no trailing bytes after the framed records" + ); + + // The recovered plaintext begins with EncryptedExtensions (0x08), + // Certificate (0x0b), CertificateVerify (0x0f), Finished (0x14) in order. + assert_eq!(recovered[0], 0x08, "first message is EncryptedExtensions"); + // Walk the four handshake messages by their u24 length prefixes. + let mut p = 0usize; + let mut types = Vec::new(); + while p + 4 <= recovered.len() { + let ty = recovered[p]; + if ty == 0 { + break; // reached padding stripped? (shouldn't; padding was per-record) + } + let len = (usize::from(recovered[p + 1]) << 16) + | (usize::from(recovered[p + 2]) << 8) + | usize::from(recovered[p + 3]); + types.push(ty); + p += 4 + len; + if types.len() == 4 { + break; + } + } + assert_eq!(types, vec![0x08, 0x0b, 0x0f, 0x14]); + } + + #[test] + fn emit_server_flight_rejects_malformed_or_over_capacity_framing() { + let (keys, signing_key, leaf_der, transcript_ch_sh) = emit_flight_fixture(); + let cert_chain = crate::template::CertChainShape { + leaf_der_len: leaf_der.len(), + intermediates_der: Vec::new(), + }; + let mk = |record_lengths: Vec| crate::template::EncryptedFlightShape { + record_lengths, + encrypted_extensions_len: 0, + certificate_len: 0, + certificate_verify_len: 0, + finished_len: 0, + }; + + // Empty record_lengths → Err (not panic). + assert!(emit_server_flight( + &keys, + &mk(vec![]), + &cert_chain, + &leaf_der, + &signing_key, + &transcript_ch_sh + ) + .is_err()); + // A record length below the 17-byte AEAD/content-type floor → Err. + assert!(emit_server_flight( + &keys, + &mk(vec![10]), + &cert_chain, + &leaf_der, + &signing_key, + &transcript_ch_sh + ) + .is_err()); + // Total capacity far below the flight size → FlightTooLarge. + // (`ServerFlight` deliberately does not derive `Debug` — it carries the + // application traffic keys — so this checks the error variant via + // `matches!` rather than `Result::unwrap_err`, which would require + // `ServerFlight: Debug`.) + let result = emit_server_flight( + &keys, + &mk(vec![20, 20]), + &cert_chain, + &leaf_der, + &signing_key, + &transcript_ch_sh, + ); + assert!(matches!(result, Err(Error::FlightTooLarge))); + } } From b3fbc18d8bc73fa17bee8a308a2e0bdee8587347 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 02:04:27 -0400 Subject: [PATCH 15/27] refactor(reality.5c): role-agnostic RealityStream (egress/ingress) + client/server constructors --- crates/yip-utls/src/stream.rs | 169 +++++++++++++++++++++++++--------- 1 file changed, 124 insertions(+), 45 deletions(-) diff --git a/crates/yip-utls/src/stream.rs b/crates/yip-utls/src/stream.rs index d22772f..c2d7bfa 100644 --- a/crates/yip-utls/src/stream.rs +++ b/crates/yip-utls/src/stream.rs @@ -1007,21 +1007,7 @@ pub async fn connect( server_hello_info.suite, ); - Ok(RealityStream { - inner: stream, - suite: ak.suite, - client_key: ak.client_key, - client_iv: ak.client_iv, - client_seq: 0, - server_key: ak.server_key, - server_iv: ak.server_iv, - server_seq: 0, - raw_buf: Vec::new(), - read_buf: Vec::new(), - read_pos: 0, - write_buf: Vec::new(), - write_off: 0, - }) + Ok(RealityStream::client(stream, ak)) } /// REALITY.5a Task 3: probes a REAL `dest` — no REALITY seal, since `dest` @@ -1391,14 +1377,14 @@ pub struct RealityStream { /// The negotiated TLS 1.3 cipher suite (`0x1301`/`0x1302`/`0x1303`) — /// needed on every seal/open, since `TLS_AES_256_GCM_SHA384` and /// `TLS_CHACHA20_POLY1305_SHA256` share a 32-byte key length and so - /// cannot be told apart from `client_key`/`server_key` alone. + /// cannot be told apart from `egress_key`/`ingress_key` alone. suite: u16, - client_key: Vec, - client_iv: [u8; 12], - client_seq: u64, - server_key: Vec, - server_iv: [u8; 12], - server_seq: u64, + egress_key: Vec, + egress_iv: [u8; 12], + egress_seq: u64, + ingress_key: Vec, + ingress_iv: [u8; 12], + ingress_seq: u64, /// Raw bytes read from `inner` that have not yet been assembled into a /// complete TLS record. raw_buf: Vec, @@ -1424,6 +1410,56 @@ fn handshake_io_error(e: handshake::Error) -> io::Error { } impl RealityStream { + /// Build the CLIENT-role stream (used by [`connect`]): egress sealed with + /// the client application key, ingress opened with the server's. + fn client(inner: S, ak: handshake::ApplicationKeys) -> Self { + RealityStream { + inner, + suite: ak.suite, + egress_key: ak.client_key, + egress_iv: ak.client_iv, + egress_seq: 0, + ingress_key: ak.server_key, + ingress_iv: ak.server_iv, + ingress_seq: 0, + raw_buf: Vec::new(), + read_buf: Vec::new(), + read_pos: 0, + write_buf: Vec::new(), + write_off: 0, + } + } + + /// Build the SERVER-role stream (used by [`serve`]): egress sealed with the + /// server application key, ingress opened with the client's — the mirror of + /// [`client`]. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Task 5c/6's serve() is this constructor's first non-test caller; \ + this module's own test submodule already exercises it under \ + cfg(test), but the plain lib build has no caller yet" + ) + )] + fn server(inner: S, ak: handshake::ApplicationKeys) -> Self { + RealityStream { + inner, + suite: ak.suite, + egress_key: ak.server_key, + egress_iv: ak.server_iv, + egress_seq: 0, + ingress_key: ak.client_key, + ingress_iv: ak.client_iv, + ingress_seq: 0, + raw_buf: Vec::new(), + read_buf: Vec::new(), + read_pos: 0, + write_buf: Vec::new(), + write_off: 0, + } + } + /// Attempts to decrypt and buffer one more application-data record into /// `self.read_buf`, transparently skipping `ChangeCipherSpec` records and /// post-handshake handshake-typed records (e.g. `NewSessionTicket`) @@ -1454,16 +1490,16 @@ impl RealityStream { } let (inner_type, content) = record_open_typed( - &self.server_key, - &self.server_iv, - self.server_seq, + &self.ingress_key, + &self.ingress_iv, + self.ingress_seq, self.suite, record_type, &mut payload, ) .map_err(handshake_io_error)?; - self.server_seq = self.server_seq.checked_add(1).ok_or_else(|| { - protocol_io_error("server application sequence counter overflow") + self.ingress_seq = self.ingress_seq.checked_add(1).ok_or_else(|| { + protocol_io_error("ingress application sequence counter overflow") })?; if inner_type == CONTENT_TYPE_APPLICATION_DATA { @@ -1581,18 +1617,18 @@ impl AsyncWrite for RealityStream { let chunk_len = buf.len().min(MAX_APP_RECORD_LEN); let chunk = &buf[..chunk_len]; let sealed = record_seal( - &this.client_key, - &this.client_iv, - this.client_seq, + &this.egress_key, + &this.egress_iv, + this.egress_seq, this.suite, CONTENT_TYPE_APPLICATION_DATA, chunk, ) .map_err(handshake_io_error)?; - this.client_seq = this - .client_seq + this.egress_seq = this + .egress_seq .checked_add(1) - .ok_or_else(|| protocol_io_error("client application sequence counter overflow"))?; + .ok_or_else(|| protocol_io_error("egress application sequence counter overflow"))?; let header = record_header( CONTENT_TYPE_APPLICATION_DATA, LEGACY_RECORD_VERSION, @@ -1825,12 +1861,12 @@ mod tests { let mut a = RealityStream { inner: a_io, suite: 0x1301, // TLS_AES_128_GCM_SHA256 -- matches the 16-byte test keys - client_key: key_a.clone(), - client_iv: iv_a, - client_seq: 0, - server_key: key_b.clone(), - server_iv: iv_b, - server_seq: 0, + egress_key: key_a.clone(), + egress_iv: iv_a, + egress_seq: 0, + ingress_key: key_b.clone(), + ingress_iv: iv_b, + ingress_seq: 0, raw_buf: Vec::new(), read_buf: Vec::new(), read_pos: 0, @@ -1840,12 +1876,12 @@ mod tests { let mut b = RealityStream { inner: b_io, suite: 0x1301, // TLS_AES_128_GCM_SHA256 -- matches the 16-byte test keys - client_key: key_b, - client_iv: iv_b, - client_seq: 0, - server_key: key_a, - server_iv: iv_a, - server_seq: 0, + egress_key: key_b, + egress_iv: iv_b, + egress_seq: 0, + ingress_key: key_a, + ingress_iv: iv_a, + ingress_seq: 0, raw_buf: Vec::new(), read_buf: Vec::new(), read_pos: 0, @@ -1866,6 +1902,49 @@ mod tests { assert_eq!(&buf2[..n2], b"hi back from B"); } + /// Proves [`RealityStream::client`] and [`RealityStream::server`] are + /// exact mirrors of each other: built from the SAME `ApplicationKeys`, + /// the client-role stream's egress is opened by the server-role + /// stream's ingress and vice versa — the same round trip + /// `reality_stream_round_trips_over_duplex` proves via hand-rolled field + /// literals, but driven through the constructors themselves. Also keeps + /// `server` from being unreachable dead code ahead of Task 6's `serve`, + /// which will be the first non-test caller. + #[tokio::test] + async fn reality_stream_client_and_server_constructors_mirror_each_other() { + let (client_io, server_io) = tokio::io::duplex(1 << 20); + + let client_ak = handshake::ApplicationKeys { + client_key: vec![0x55u8; 16], + client_iv: [0x66u8; 12], + server_key: vec![0x77u8; 16], + server_iv: [0x88u8; 12], + suite: 0x1301, // TLS_AES_128_GCM_SHA256 -- matches the 16-byte test keys + }; + let server_ak = handshake::ApplicationKeys { + client_key: vec![0x55u8; 16], + client_iv: [0x66u8; 12], + server_key: vec![0x77u8; 16], + server_iv: [0x88u8; 12], + suite: 0x1301, + }; + + let mut client = RealityStream::client(client_io, client_ak); + let mut server = RealityStream::server(server_io, server_ak); + + client.write_all(b"hello from client").await.unwrap(); + client.flush().await.unwrap(); + let mut buf = vec![0u8; 32]; + let n = server.read(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"hello from client"); + + server.write_all(b"hi back from server").await.unwrap(); + server.flush().await.unwrap(); + let mut buf2 = vec![0u8; 32]; + let n2 = client.read(&mut buf2).await.unwrap(); + assert_eq!(&buf2[..n2], b"hi back from server"); + } + /// Plays the TLS 1.3 SERVER role, entirely in-process, against a real /// `connect()` call driving the CLIENT role over the other half of a /// `tokio::io::duplex` — proving `connect`/`RealityStream` end-to-end From be6277cbab002d5ec0c116ddad0161e61cb7e2cb Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 02:18:12 -0400 Subject: [PATCH 16/27] =?UTF-8?q?feat(reality.5c):=20serve=20=E2=80=94=20s?= =?UTF-8?q?erver=20flight=20+=20drain=20+=20stream;=20verify=20round-trip?= =?UTF-8?q?=20suite=20runs=20through=205c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/yip-utls/src/stream.rs | 309 ++++++++++++++++++++-------------- 1 file changed, 184 insertions(+), 125 deletions(-) diff --git a/crates/yip-utls/src/stream.rs b/crates/yip-utls/src/stream.rs index c2d7bfa..c8b1030 100644 --- a/crates/yip-utls/src/stream.rs +++ b/crates/yip-utls/src/stream.rs @@ -1365,6 +1365,77 @@ pub fn emit_server_flight( Ok(ServerFlight { wire, app_keys }) } +/// REALITY.5c server entry point — the mirror of [`connect`]. Emits the +/// encrypted server flight (via [`emit_server_flight`]) framed to `dest`'s +/// captured record lengths, writes it, drains the client's middlebox CCS + +/// `Finished` (contents UNCHECKED — the outer TLS is zero client-auth by +/// design, exactly as `connect` never checks the server's `Finished`), and +/// returns the server-role [`RealityStream`] on the derived application keys. +/// The ServerHello (REALITY.5b `emit_server_hello`) and `transcript_ch_sh` +/// (raw ClientHello ‖ ServerHello) must already have been produced/sent by +/// the caller (5d). +pub async fn serve( + mut stream: S, + keys: &HandshakeKeys, + flight_shape: &crate::template::EncryptedFlightShape, + cert_chain: &crate::template::CertChainShape, + forged_leaf_der: &[u8], + cert_signing_key: &p256::ecdsa::SigningKey, + transcript_ch_sh: &[u8], +) -> Result, Error> { + let flight = emit_server_flight( + keys, + flight_shape, + cert_chain, + forged_leaf_der, + cert_signing_key, + transcript_ch_sh, + )?; + stream.write_all(&flight.wire).await?; + + // Drain the client's CCS + sealed Finished. The client sends a CCS record + // (skipped) then exactly one application-data record carrying the sealed + // Finished, opened under the CLIENT handshake key; its contents are not + // validated (zero client-auth). + let mut client_hs_seq = 0u64; + loop { + let (record_type, mut payload) = read_raw_record(&mut stream).await?; + if record_type == CONTENT_TYPE_CHANGE_CIPHER_SPEC { + continue; + } + if record_type != CONTENT_TYPE_APPLICATION_DATA { + return Err(Error::Protocol( + "expected the client's sealed Finished record", + )); + } + // Open (and discard) the client Finished — proves it is well-framed + // under the negotiated key; contents intentionally unchecked. + record_open( + &keys.client_key, + &keys.client_iv, + client_hs_seq, + keys.suite, + record_type, + &mut payload, + )?; + #[expect( + unused_assignments, + reason = "written for symmetry with connect()'s analogous server_seq counter \ + (and to fail closed on overflow before the break); this loop always \ + exits after the client's single Finished record, so the new value is \ + never read back" + )] + { + client_hs_seq = client_hs_seq + .checked_add(1) + .ok_or(Error::Protocol("client handshake sequence overflow"))?; + } + break; + } + + Ok(RealityStream::server(stream, flight.app_keys)) +} + /// An established REALITY/TLS 1.3 connection's application-data stream. /// Implements `AsyncRead`/`AsyncWrite` over the negotiated application /// traffic keys, framing plaintext into/out of TLS 1.3 records @@ -1433,15 +1504,6 @@ impl RealityStream { /// Build the SERVER-role stream (used by [`serve`]): egress sealed with the /// server application key, ingress opened with the client's — the mirror of /// [`client`]. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "Task 5c/6's serve() is this constructor's first non-test caller; \ - this module's own test submodule already exercises it under \ - cfg(test), but the plain lib build has no caller yet" - ) - )] fn server(inner: S, ak: handshake::ApplicationKeys) -> Self { RealityStream { inner, @@ -2355,13 +2417,6 @@ mod tests { msg } - /// Builds a wire `Certificate` handshake message carrying a single - /// `CertificateEntry` wrapping `leaf_der` — the common case used by - /// every `connect_verify_*` test that doesn't care about chain shape. - fn build_certificate_message(leaf_der: &[u8]) -> Vec { - build_certificate_entries_message(&[leaf_der]) - } - /// Builds a wire `Certificate` handshake message carrying TWO /// `CertificateEntry` values, leaf first then `intermediate_der` — the /// shape a real relay's CA-issued chain presents (RFC 8446 §4.4.2 @@ -2517,124 +2572,84 @@ mod tests { (derived.pkcs8_der.clone(), other) } }; - use p256::pkcs8::DecodePrivateKey as _; - - let leaf_key = rcgen::KeyPair::try_from(leaf_key_pkcs8.as_slice()) - .expect("leaf signing key pkcs8 loads"); + // Build the (forged leaf, signing key) pair per behavior — unchanged. + let leaf_key = rcgen::KeyPair::try_from(leaf_key_pkcs8.as_slice()).unwrap(); let leaf_der = leaf_der_for_key(&leaf_key); + use p256::pkcs8::DecodePrivateKey as _; + let cert_signing_key = p256::ecdsa::SigningKey::from_pkcs8_der(&signing_key_pkcs8).unwrap(); - let ee_msg: [u8; 4] = [0x08, 0x00, 0x00, 0x00]; // EncryptedExtensions, 0-length - let certificate_msg = if include_intermediate { - // A dummy "intermediate": any wire-well-formed CertificateEntry - // works per RFC 8446 §4.4.2 — it doesn't need to chain-verify to - // the leaf, since REALITY.4b only ever pins the FIRST entry. + let intermediates = if include_intermediate { let intermediate_key = rcgen::KeyPair::generate().unwrap(); - let intermediate_der = leaf_der_for_key(&intermediate_key); - build_certificate_message_with_intermediate(&leaf_der, &intermediate_der) + vec![leaf_der_for_key(&intermediate_key)] } else { - build_certificate_message(&leaf_der) + Vec::new() }; - - // The RFC 8446 §4.4.3 transcript boundary: everything through - // Certificate, NOT including CertificateVerify. - let mut thru_cert = ch_sh_transcript.clone(); - thru_cert.extend_from_slice(&ee_msg); - thru_cert.extend_from_slice(&certificate_msg); - let transcript_thru_cert = transcript_hash(&thru_cert, SUITE); - - let mut signed = Vec::with_capacity(64 + 34 + 1 + transcript_thru_cert.len()); - signed.extend_from_slice(&[0x20u8; 64]); - signed.extend_from_slice(b"TLS 1.3, server CertificateVerify"); - signed.push(0x00); - signed.extend_from_slice(&transcript_thru_cert); - - let signing_key = p256::ecdsa::SigningKey::from_pkcs8_der(&signing_key_pkcs8) - .expect("signing key pkcs8 loads"); - let signature: p256::ecdsa::Signature = { - use p256::ecdsa::signature::Signer; - signing_key.sign(&signed) + let flight_shape = crate::template::EncryptedFlightShape { + // A SINGLE record, comfortably larger than either behavior's flight + // (~463 bytes CorrectBinder/WrongLeafKey/BadSignature, ~797 bytes + // with_intermediate) — exercises real intra-record padding. + // + // Deliberately NOT split across multiple record_lengths entries + // (e.g. [600, 4096]) here: emit_server_flight's greedy chunking + // fills only as many records as the flight's actual plaintext + // needs, so a template with MORE capacity than the flight + // requires leaves any later record(s) as pure padding + // (`chunk_len = 0`) — see this fn's self-review note. A real TLS + // 1.3 client (this crate's `connect`, mirroring BoringSSL/Chrome) + // stops reading the server's encrypted flight the instant it has + // assembled a complete `Finished` message; it has no way to know + // the captured template called for more records, so it never + // reads that trailing pure-padding record — which then sits + // unconsumed ahead of the first real application-data record and + // fails AEAD-open under the (wrong, still-handshake-sealed) key. + // Real production flights avoid this because 5d sizes the forged + // leaf to `leaf_der_len`, keeping the forged flight within a byte + // or two of `dest`'s real (already record-boundary-fitting) + // flight; pre-5d, this mock's ad hoc small self-signed leaf does + // not, so a single record sized to comfortably fit either + // behavior's flight sidesteps the gap without masking what + // `serve`/`emit_server_flight` themselves guarantee. + record_lengths: vec![4096], + encrypted_extensions_len: 0, + certificate_len: 0, + certificate_verify_len: 0, + finished_len: 0, }; - let certificate_verify_msg = build_certificate_verify_message(signature.to_der().as_ref()); - - let verify_data = [0xABu8; 32]; // arbitrary: connect() never checks it - let mut finished_msg = Vec::with_capacity(4 + verify_data.len()); - finished_msg.push(HS_TYPE_FINISHED); - finished_msg.extend_from_slice(&[0x00, 0x00, 0x20]); // u24 len = 32 - finished_msg.extend_from_slice(&verify_data); - - let mut server_flight_plain = Vec::new(); - server_flight_plain.extend_from_slice(&ee_msg); - server_flight_plain.extend_from_slice(&certificate_msg); - server_flight_plain.extend_from_slice(&certificate_verify_msg); - server_flight_plain.extend_from_slice(&finished_msg); - - let sealed_flight = record_seal( - &hk.server_key, - &hk.server_iv, - 0, - SUITE, - CONTENT_TYPE_HANDSHAKE, - &server_flight_plain, - ) - .unwrap(); - let flight_header = record_header( - CONTENT_TYPE_APPLICATION_DATA, - LEGACY_RECORD_VERSION, - sealed_flight.len(), + let cert_chain = crate::template::CertChainShape { + leaf_der_len: leaf_der.len(), + intermediates_der: intermediates, + }; + // transcript_ch_sh = the RAW ClientHello ‖ ServerHello bytes (ch_sh_transcript), + // NOT the hash — emit_server_flight hashes internally. + // + // IMPORTANT: do NOT `.expect()` on serve. For the reject behaviors + // (WrongLeafKey / BadSignature) the client verifies, FAILS, and drops the + // connection WITHOUT sending its Finished — so `serve` (which drains the + // client Finished) returns Err. That is the correct outcome for those + // tests (they assert the CLIENT `connect` returns Err); the server task + // must simply exit cleanly, not panic. `if let Ok` handles both paths. + if let Ok(mut server) = serve( + io, + &hk, + &flight_shape, + &cert_chain, + &leaf_der, + &cert_signing_key, + &ch_sh_transcript, ) - .unwrap(); - io.write_all(&flight_header).await.unwrap(); - io.write_all(&sealed_flight).await.unwrap(); - - let mut full_transcript = ch_sh_transcript; - full_transcript.extend_from_slice(&server_flight_plain); - let transcript_thru_sfin = transcript_hash(&full_transcript, SUITE); - let ak = derive_application_keys(&hk.handshake_secret, &transcript_thru_sfin, SUITE); - - // -- Drain the client's CCS + sealed Finished. Only reached if the - // client ACCEPTED the binding — see this function's doc comment. -- - loop { - let (record_type, _payload) = read_one_record(&mut io).await; - if record_type == CONTENT_TYPE_CHANGE_CIPHER_SPEC { - continue; + .await + { + // Echo whatever the client sends (robust to any ping size the + // round-trip tests use), proving the server-app egress seal + client + // ingress open both work. + let mut buf = [0u8; 64]; + if let Ok(n) = server.read(&mut buf).await { + if n > 0 { + let _ = server.write_all(&buf[..n]).await; + let _ = server.flush().await; + } } - assert_eq!( - record_type, CONTENT_TYPE_APPLICATION_DATA, - "expected the client's sealed Finished record" - ); - break; } - - // -- Echo one application-data record under the application keys -- - let (record_type, mut app_payload) = read_one_record(&mut io).await; - assert_eq!(record_type, CONTENT_TYPE_APPLICATION_DATA); - let plaintext = record_open( - &ak.client_key, - &ak.client_iv, - 0, - SUITE, - CONTENT_TYPE_APPLICATION_DATA, - &mut app_payload, - ) - .unwrap(); - - let sealed_reply = record_seal( - &ak.server_key, - &ak.server_iv, - 0, - SUITE, - CONTENT_TYPE_APPLICATION_DATA, - &plaintext, - ) - .unwrap(); - let reply_header = record_header( - CONTENT_TYPE_APPLICATION_DATA, - LEGACY_RECORD_VERSION, - sealed_reply.len(), - ) - .unwrap(); - io.write_all(&reply_header).await.unwrap(); - io.write_all(&sealed_reply).await.unwrap(); } /// `verify=true` against a mock server whose leaf/signature genuinely @@ -2833,6 +2848,50 @@ mod tests { server_task.await.unwrap(); } + /// Proves BOTH data-plane directions over a real [`serve`]-built + /// server stream: `connect_verify_accepts_correct_binder` (and friends) + /// only checks client→server→echo; this drives a server-initiated write + /// too (the mock's echo `write_all`, sealed under the server-app egress + /// key and opened by the client's ingress key), proving `serve`'s + /// `RealityStream::server` role is symmetric with `client`. + #[tokio::test] + async fn serve_round_trips_application_data_both_directions() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let server_reality_priv = [3u8; 32]; + let server_reality_pub = { + let secret = x25519_dalek::StaticSecret::from(server_reality_priv); + x25519_dalek::PublicKey::from(&secret).to_bytes() + }; + + // Server: hand-rolled ServerHello + 5c serve (reuses the refactored mock, + // CorrectBinder → the client must verify the 4b binding). + let server_task = tokio::spawn(run_mock_tls13_server_with_cert( + server_io, + server_reality_priv, + ServerCertBehavior::CorrectBinder, + false, + )); + + let mut s = connect( + client_io, + "example.com", + &server_reality_pub, + [1u8; 8], + true, + ) + .await + .expect("correctly-bound relay verifies"); + + // client → server → echo (server's echo path in the mock). + s.write_all(b"PING").await.unwrap(); + s.flush().await.unwrap(); + let mut buf = [0u8; 4]; + s.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"PING"); + + server_task.await.unwrap(); + } + // ----------------------------------------------------------------- // REALITY.5a Task 3: capture_dest_flight // ----------------------------------------------------------------- From bf9a269d3726dd4a9424a0fde8aa78d84a0f2981 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 02:28:21 -0400 Subject: [PATCH 17/27] fix(reality.5c): frame flight so Finished lands in the last record (client reads all records) --- crates/yip-utls/src/stream.rs | 111 ++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 26 deletions(-) diff --git a/crates/yip-utls/src/stream.rs b/crates/yip-utls/src/stream.rs index c8b1030..07bea1c 100644 --- a/crates/yip-utls/src/stream.rs +++ b/crates/yip-utls/src/stream.rs @@ -1335,7 +1335,15 @@ pub fn emit_server_flight( "record length below the 17-byte AEAD floor", ))?; let remaining = hs_stream.len() - off; - let chunk_len = remaining.min(cap); + // The client (`connect`) reads records only until it sees `Finished`, then + // switches to the application keys. So the `Finished` tail MUST land in the + // LAST record — otherwise trailing handshake-key padding records would be + // left unread and then fail to open under the app key. Reserve one content + // byte for every later record so content is never exhausted early; the last + // record always receives the tail. (Leading records may still be pure + // padding — that is fine, they precede `Finished` and the client reads them.) + let records_after = flight_shape.record_lengths.len() - 1 - i; + let chunk_len = cap.min(remaining.saturating_sub(records_after)); let chunk = &hs_stream[off..off + chunk_len]; let pad_len = cap - chunk_len; // once hs_stream is exhausted, chunk_len=0 → pad_len=cap (pure-padding record) let seq = u64::try_from(i).map_err(|_| Error::Protocol("record index overflow"))?; @@ -2489,6 +2497,7 @@ mod tests { server_reality_priv: [u8; 32], behavior: ServerCertBehavior, include_intermediate: bool, + record_lengths: Vec, ) { const SUITE: u16 = 0x1301; // TLS_AES_128_GCM_SHA256 @@ -2585,31 +2594,17 @@ mod tests { Vec::new() }; let flight_shape = crate::template::EncryptedFlightShape { - // A SINGLE record, comfortably larger than either behavior's flight - // (~463 bytes CorrectBinder/WrongLeafKey/BadSignature, ~797 bytes - // with_intermediate) — exercises real intra-record padding. - // - // Deliberately NOT split across multiple record_lengths entries - // (e.g. [600, 4096]) here: emit_server_flight's greedy chunking - // fills only as many records as the flight's actual plaintext - // needs, so a template with MORE capacity than the flight - // requires leaves any later record(s) as pure padding - // (`chunk_len = 0`) — see this fn's self-review note. A real TLS - // 1.3 client (this crate's `connect`, mirroring BoringSSL/Chrome) - // stops reading the server's encrypted flight the instant it has - // assembled a complete `Finished` message; it has no way to know - // the captured template called for more records, so it never - // reads that trailing pure-padding record — which then sits - // unconsumed ahead of the first real application-data record and - // fails AEAD-open under the (wrong, still-handshake-sealed) key. - // Real production flights avoid this because 5d sizes the forged - // leaf to `leaf_der_len`, keeping the forged flight within a byte - // or two of `dest`'s real (already record-boundary-fitting) - // flight; pre-5d, this mock's ad hoc small self-signed leaf does - // not, so a single record sized to comfortably fit either - // behavior's flight sidesteps the gap without masking what - // `serve`/`emit_server_flight` themselves guarantee. - record_lengths: vec![4096], + // Caller-supplied record shape. `emit_server_flight` reserves one + // content byte per record after the current one, so the + // `Finished` tail always lands in the LAST record regardless of + // how record capacity is distributed — callers may freely use a + // multi-record shape (e.g. `[600, 4096]`) whose total capacity + // exceeds the flight's actual plaintext; any resulting + // pure-padding record(s) are front-loaded ahead of `Finished`, + // where the real TLS 1.3 client (this crate's `connect`, + // mirroring BoringSSL/Chrome) still reads them before it stops + // parsing the encrypted flight. + record_lengths, encrypted_extensions_len: 0, certificate_len: 0, certificate_verify_len: 0, @@ -2671,6 +2666,7 @@ mod tests { server_reality_priv, ServerCertBehavior::CorrectBinder, false, + vec![600, 4096], )); let mut s = connect( @@ -2716,6 +2712,7 @@ mod tests { server_reality_priv, ServerCertBehavior::CorrectBinder, true, + vec![600, 4096], )); let mut s = connect( @@ -2757,6 +2754,7 @@ mod tests { server_reality_priv, ServerCertBehavior::WrongLeafKey, false, + vec![600, 4096], )); let result = connect( @@ -2792,6 +2790,7 @@ mod tests { server_reality_priv, ServerCertBehavior::BadSignature, false, + vec![600, 4096], )); let result = connect( @@ -2827,6 +2826,7 @@ mod tests { server_reality_priv, ServerCertBehavior::WrongLeafKey, false, + vec![600, 4096], )); let mut s = connect( @@ -2870,6 +2870,7 @@ mod tests { server_reality_priv, ServerCertBehavior::CorrectBinder, false, + vec![600, 4096], )); let mut s = connect( @@ -2892,6 +2893,64 @@ mod tests { server_task.await.unwrap(); } + /// Regression test for the `emit_server_flight` front-greedy framing bug + /// (REALITY.5c Task 6 client round-trip): our minimal EE + P-256 + /// CertificateVerify + self-signed leaf flight is well under 4096 bytes, + /// so a `[4096, 600]` record shape is EXACTLY the failure case — under + /// the old pure front-greedy `chunk_len = remaining.min(cap)` framing, + /// the whole flight (including `Finished`) fits inside record 0, leaving + /// record 1 (600 bytes) a PURE-PADDING record sealed under the + /// handshake keys. The real `connect` client (mirroring BoringSSL/ + /// Chrome) stops reading the encrypted flight the instant it has + /// assembled a complete `Finished` message — i.e. after record 0 — so it + /// never consumes that trailing record 1, which then sits unread ahead + /// of the first application-data record and fails AEAD-open under the + /// (wrong) application key. + /// + /// With the fix (`emit_server_flight` reserves one content byte per + /// later record so the `Finished` tail always lands in the LAST + /// record), record 1 necessarily carries part of the flight — including + /// `Finished` — so the client reads BOTH records and the handshake + /// completes normally. + #[tokio::test] + async fn connect_verify_accepts_correct_binder_with_flight_split_finished_last() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let server_reality_priv = [11u8; 32]; + let server_reality_pub = { + let secret = x25519_dalek::StaticSecret::from(server_reality_priv); + x25519_dalek::PublicKey::from(&secret).to_bytes() + }; + + let server_task = tokio::spawn(run_mock_tls13_server_with_cert( + server_io, + server_reality_priv, + ServerCertBehavior::CorrectBinder, + false, + vec![4096, 600], + )); + + let mut s = connect( + client_io, + "example.com", + &server_reality_pub, + [1u8; 8], + true, + ) + .await + .expect( + "a correctly-bound relay must verify even when the captured template's \ + largest record comes FIRST (record_lengths = [4096, 600])", + ); + + s.write_all(b"PING").await.unwrap(); + s.flush().await.unwrap(); + let mut buf = [0u8; 4]; + s.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"PING"); + + server_task.await.unwrap(); + } + // ----------------------------------------------------------------- // REALITY.5a Task 3: capture_dest_flight // ----------------------------------------------------------------- From 4540366806e62e9c8b518cc7554acc12c938cbf3 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 13:17:44 -0400 Subject: [PATCH 18/27] =?UTF-8?q?docs(reality.5d):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20wire=20hand-rolled=20server=20flight=20into=20the?= =?UTF-8?q?=20relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace BoringSSL SslAcceptor on the authed REALITY path with 5b emit_server_hello + 5c serve. Natural forged leaf (no exact leaf_der_len padding, user decision); parse+thread the 4588-tail x25519; genericize handle_connection over the stream; splice pre-write / drop post-write fail-safe boundary; OS-CSPRNG rng; serve bounded by HANDSHAKE_TIMEOUT. netns money test = real verify=on client through 5d relay. Last PR of REALITY.5. yip-rendezvous. --- ...18-reality-5d-wire-server-flight-design.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-18-reality-5d-wire-server-flight-design.md diff --git a/docs/superpowers/specs/2026-07-18-reality-5d-wire-server-flight-design.md b/docs/superpowers/specs/2026-07-18-reality-5d-wire-server-flight-design.md new file mode 100644 index 0000000..e1c9985 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-reality-5d-wire-server-flight-design.md @@ -0,0 +1,97 @@ +# REALITY.5d — wire the hand-rolled server flight into the relay — design spec + +**Date:** 2026-07-18 +**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.5c (`yip_utls::serve` + `emit_server_flight`), REALITY.5b (`yip_utls::emit_server_hello`), REALITY.5a (`ServerFlightTemplate` capture + `RealityCertCache`), REALITY.4b (`auth::derive_cert_key`, per-connection binding), REALITY.3 (`forge_leaf`, `StolenFields`, the authed/splice front). +**Scope:** `yip-rendezvous` (the relay's authed REALITY path). PR 4 of 4 (the LAST) of REALITY.5. + +## Goal + +Replace the BoringSSL `SslAcceptor` on the relay's authed REALITY path with the hand-rolled 5b+5c handshake, so the relay actually **serves** the dest-faithful server flight it can now build. After 5d, an authed connection to the relay is byte/length-indistinguishable from a genuine Chrome↔`dest` TLS 1.3 session end to end — cleartext ServerHello (5b) *and* encrypted flight framed to `dest`'s record lengths (5c) — closing the passive-DPI gap REALITY.3 explicitly left open for the authed path (the last anti-DPI item). + +## Where it changes + +Only `tls_front::run_reality_conn`'s `Decision::Accept` branch (`bin/yip-rendezvous/src/tls_front.rs`) changes behavior; four supporting edits enable it. Today that branch does: `derive_cert_key(shared)` → `build_forged_acceptor_with_pkcs8` (BoringSSL) → `tokio_boring::accept` → `handle_connection(SslStream)`. The un-authed splice path, the non-REALITY relay-Trojan front (`run_tls_front`), the anti-replay guard (`decide_authed`/`ReplayGuard`), and the client side (4a/4b) are all unchanged. + +## The new authed-path flow (`Decision::Accept`) + +Given the already-read first record `rec` (the ClientHello record; the handshake message is `rec[5..]`), the parsed `info: ClientHelloInfo`, and this connection's seal secret `shared` (from `reality_auth_recover_shared`): + +1. **Template lookup.** Fetch the captured `ServerFlightTemplate` + `StolenFields` for `sni` from `RealityCertCache` (`template_for`/`fields_for`). No template (capture failed or the SNI degraded to splice-only at pre-warm) → **splice to dest** (fail-safe). +2. **Binding key.** `dk = yip_utls::auth::derive_cert_key(&shared)` (unchanged — the per-connection ECDSA-P256 key the client pins against, REALITY.4b). +3. **Forge the leaf.** `forge_leaf(&fields, )?.der().as_ref().to_vec()` → `forged_leaf_der` (`forge_leaf` returns an `rcgen::Certificate`; take its DER): the natural mimicking leaf (copies `dest`'s subject/SAN/validity/serial/keyUsage/EKU/basicConstraints), SPKI = `dk`'s P-256 public key. **No exact-length padding** (user decision — see "Leaf sizing"). A forge error → **splice**. +4. **Pick `client_x25519` by group.** For `template.server_hello.key_share_group == 4588` (X25519MLKEM768): use the client's **4588-entry x25519 tail** (`info.key_share_mlkem_x25519`). For group `29` (X25519): use `info.key_share_x25519` (the `0x001d` entry). A missing/`None` value for the selected group → **splice**. +5. **Emit + write the ServerHello (5b).** `emit_server_hello(&template.server_hello, rec[5..], &info.legacy_session_id, &client_x25519, info.key_share_mlkem_ek.as_deref(), &mut os_rng)` → `(sh_msg, keys)`. Write `sh_msg` to the socket as a plaintext handshake record (`0x16 ‖ 0x0303 ‖ u16 len ‖ sh_msg`). An `emit_server_hello` error (e.g. `UnsupportedGroup`) is caught **before** writing → **splice**; once the ServerHello bytes are written, we are committed (step 6+ failures **drop**). +6. **Serve the encrypted flight (5c), bounded.** `timeout(HANDSHAKE_TIMEOUT, serve(tcp, &keys, &template.encrypted_flight, &template.cert_chain, &forged_leaf_der, &, transcript_ch_sh))` where `transcript_ch_sh = rec[5..] ‖ sh_msg`. Returns a `RealityStream`. Any error (`FlightTooLarge`, timeout, I/O) → **drop** (log; the client falls back per 4b). +7. **Pump the tunnel.** `handle_connection(reality_stream, cfg)` — the same inner relay logic as the old BoringSSL branch. + +### The splice-vs-drop boundary (load-bearing) + +Splicing forwards the pristine connection to the real `dest` so a prober completes a genuine handshake — but that only works while we have **not yet written our own bytes**. Once we write our ServerHello (step 5), `dest` never saw this ClientHello, so we cannot retroactively splice. Therefore: +- **Steps 1–5 (before any write):** every failure → **splice** (preserves REALITY.3's "any failure looks like a real visit"). +- **Steps 5-write through 7 (after the ServerHello is on the wire):** every failure → **drop** (matches how the current BoringSSL branch already treats a mid-handshake failure; the client's 4b fallback covers it). + +`FlightTooLarge` (an oversized forged leaf overflowing `dest`'s record budget) fires inside `serve` (step 6) → a drop. This is rare because the natural leaf ≈ `dest`'s size; the fail-safe exists so an unlucky template never crashes or hangs the relay. + +## Leaf sizing (user decision: natural leaf, no exact padding) + +The passive DPI — the only in-scope adversary — sees only the **encrypted record framing** (`record_lengths`), which 5c already matches exactly. The Certificate *message* size lives inside the AEAD, visible only to a party holding the session keys (out of the zero-CA-auth threat model). The forged leaf already copies `dest`'s fields, so it is naturally ≈ `dest`'s `leaf_der_len`. 5d therefore ships the natural forged leaf and relies on 5c's record padding to absorb the small size delta; if a leaf ever exceeds `dest`'s total record budget, `emit_server_flight` returns `FlightTooLarge` → the connection drops (fail-safe). Exact-length DER padding (a computed dummy X.509 extension) is **not** implemented — it would harden only against a decryptor that does not exist in this threat model, at real implementation cost. + +## Supporting changes + +### 1. Parser — expose the 4588-entry x25519 tail (`bin/yip-rendezvous/src/reality.rs`) + +`ClientHelloInfo` has `key_share_x25519` (`0x001d`) and `key_share_mlkem_ek` (first 1184 bytes of the group-4588 entry). Add `pub key_share_mlkem_x25519: Option<[u8; 32]>` — the trailing 32 bytes of that same 4588 entry (`mlkem_ek(1184) ‖ x25519(32)`). The parser already walks the entry for the ek, so the tail is a bounded slice more. Fail-closed: a 4588 entry that is not exactly `1184 + 32` bytes → both `key_share_mlkem_ek` and `key_share_mlkem_x25519` are `None`. This is what step 4 threads into the group-4588 TLS DH — correct-by-construction, not relying on the client reusing one ephemeral across its `0x001d` and 4588 shares (though our client does; a non-reuse client would simply fail closed with a dead session). + +### 2. `reality_cert.rs` — read template/fields, drop the dead acceptors + +- **Use** the per-SNI accessors the authed path reads: `template_for(sni) -> Option>` and `fields_for(sni) -> Option>` (both already `pub`; if `template_for` still carries a dead-code `#[allow]`/`#[cfg]` gate from 5a, drop it now that there is a real caller). +- **Remove** the now-dead `build_forged_acceptor`, `build_forged_acceptor_with_pkcs8`, `build_forged_acceptor_with_key`, and `CacheEntry.acceptor` — nothing serves a BoringSSL acceptor anymore. Adjust `apply_refresh`/pre-warm to populate `fields`+`template` without building an acceptor (capture still probes `dest` via `capture_dest_flight`; an SNI whose capture fails still degrades to splice-only exactly as in 5a). +- **Keep** `forge_leaf`, `extract_fields`, the capture/refresh/staleness machinery, and the startup pre-warm (minus the acceptor build). + +### 3. Genericize the pump (`bin/yip-rendezvous/src/conn.rs`) + +`handle_connection` is hardcoded to `tokio_boring::SslStream` but its body uses only `AsyncRead`/`AsyncWrite`. Change the signature to `pub async fn handle_connection(stream: St, cfg: Arc) where St: AsyncRead + AsyncWrite + Unpin + Send`. The existing non-REALITY relay-Trojan caller passes an `SslStream` (still satisfies the bound); the new authed caller passes a `yip_utls::RealityStream`. + +### 4. OS-CSPRNG for the ServerHello (`tls_front.rs`) + +`emit_server_hello` draws the ServerHello random, the server X25519 ephemeral, and the ML-KEM encapsulation randomness from its `rng: &mut dyn RandomSource`. 5d passes the same OS-CSPRNG-backed `RandomSource` `yip_utls::connect`/`capture_dest_flight` use — never a seeded/deterministic one (a predictable rng here would make the KEM encapsulation predictable). + +## Error handling summary (fail-closed) + +- Pre-write failures (no template, unsupported group, missing client key share, forge error, `emit_server_hello` error) → **splice** (pristine connection, indistinguishable). +- Post-write failures (`serve`/`emit_server_flight` error incl. `FlightTooLarge`, timeout, I/O) → **drop** (log; client 4b-falls-back). +- Anti-replay (`decide_authed`) runs before this branch, unchanged. +- No panics on the connection path: every parse/slice is bounded (`rec[5..]` via `.get(5..)`), every `Option`/`Result` from the template/parse/emit/serve chain is matched and routed to splice-or-drop. + +## Testing / adversary + +- **Unit (pure, fast):** (a) the parser's `key_share_mlkem_x25519` extraction — a fixture group-4588 key_share `mlkem_ek(1184) ‖ x25519(32)` yields the correct 32-byte tail; a wrong-length 4588 entry → `None`. (b) the group→`client_x25519` selection (4588 → tail, 29 → `0x001d`). (c) the pre-write splice decisions (template-missing, unsupported group → splice; assert `run_reality_conn` splices rather than drops). +- **Integration / money test (netns, sudo — the end-to-end gate):** a real yipd client dialing `reality://…&verify=on` tunnels to a peer *through* a 5d relay whose authed path hand-rolls the flight (5b+5c). The relay captures its template from a **local mock `dest`** at startup (hermetic). Assert: the tunnel works (ping A→B through the relay); the client's `verify=on` binding holds against the hand-rolled `CertificateVerify`; and a **wrong-key** client fails closed (no tunnel). This proves 5b+5c+5d compose into a working authed handshake against the **real shipped client**. +- **Regression:** the `handle_connection` genericization keeps the non-REALITY relay-Trojan (`SslStream`) path green; the un-authed splice path, `decide_authed`, and the anti-replay tests are unchanged and stay green. +- Byte-fidelity itself is already proven by 5b's ServerHello byte-match, 5c's `record_lengths` byte-framing, and the 5c client round-trip — 5d is the wiring, so it proves the *integration*, not the framing. + +## Risks + +- **Committed-write drop vs splice.** Once the ServerHello is written we can only drop on failure, not splice — a narrower fail-safe than the un-authed path. Mitigation: all *predictable* failures (no template, unsupported group, missing share) are caught pre-write and splice; only genuinely-mid-handshake failures drop, which the client's 4b fallback already handles. Accepted. +- **`handle_connection` genericization** touches a shared function used by the non-REALITY path. Mitigation: signature-only change (body unchanged); the existing `SslStream` caller and its tests are the regression net. +- **Template freshness.** The served flight matches the last captured template, not `dest`'s live one (accepted in 5a; refresh mitigates). An authed connection whose `dest` changed its stack between capture and serve is still internally consistent (the client verifies the binding, not dest-liveness); worst case is a stale-but-plausible template. +- **`serve` CCS-drain is unbounded** (5c note): 5d wraps `serve` in `HANDSHAKE_TIMEOUT`, so a hostile client sending endless `ChangeCipherSpec` records is bounded by the timeout, not hung forever. + +## Non-goals + +- No client-side changes (4a/4b shipped); no anti-replay changes (3); no ServerHello/flight construction changes (5b/5c). +- No P256/P384 server KEX + HelloRetryRequest (#84) — an authed hello whose `dest` selected such a group has `template.server_hello.key_share_group ∉ {4588, 29}`; step 4/5 → splice. +- No exact leaf-length padding (user decision). +- The deferred 5c cleanups (`Error::MessageTooLarge`, u24-helper extraction) are a separate follow-up. +- Dropping the `boring` dependency — the non-REALITY relay-Trojan front and `extract_fields` still use it; out of scope. + +## Success criteria + +1. The relay's authed REALITY path serves a hand-rolled 5b ServerHello + 5c encrypted flight (no BoringSSL `SslAcceptor`); the dead `build_forged_acceptor*` + `CacheEntry.acceptor` are removed. +2. `ClientHelloInfo` exposes the group-4588 x25519 tail; the authed path keys the 4588 TLS DH against it (and the `0x001d` share for group 29), fail-closed on a missing/short share. +3. Pre-write failures splice (indistinguishable); post-write failures drop; `serve` is bounded by `HANDSHAKE_TIMEOUT`; the ServerHello rng is OS-CSPRNG-backed. +4. `handle_connection` is generic over `AsyncRead + AsyncWrite + Unpin + Send`; the non-REALITY path stays green. +5. The netns money test passes: a real `verify=on` yipd client tunnels through the 5d relay (hand-rolled flight accepted + binding verified), and a wrong-key client fails closed. +6. `forbid-unsafe` (outside yip-io/yip-device); no `as` casts; clippy clean. From bccad1af3c22e21dd6f756c12f573dabbaa3f93d Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 13:45:31 -0400 Subject: [PATCH 19/27] docs(reality.5d): implementation plan (4 tasks, subagent-driven) Parser 4588-x25519-tail / genericize handle_connection chain / authed-path rewrite (splice pre-write, drop post-write) + reality_cert cleanup / netns money test. Threads info through the decision tuple; r.certs/r.dest; deps (p256/getrandom/rcgen) already present. --- ...7-18-reality-5d-wire-server-flight-emit.md | 513 ++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-reality-5d-wire-server-flight-emit.md diff --git a/docs/superpowers/plans/2026-07-18-reality-5d-wire-server-flight-emit.md b/docs/superpowers/plans/2026-07-18-reality-5d-wire-server-flight-emit.md new file mode 100644 index 0000000..0bab80a --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-reality-5d-wire-server-flight-emit.md @@ -0,0 +1,513 @@ +# REALITY.5d — wire the hand-rolled server flight into the relay — 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:** Replace the BoringSSL `SslAcceptor` on the relay's authed REALITY path with the hand-rolled 5b `emit_server_hello` + 5c `serve`, so the relay serves a dest-faithful server flight — closing the last passive-DPI gap. + +**Architecture:** Four changes in `yip-rendezvous`: expose the group-4588 x25519 tail in `ClientHelloInfo`; genericize the `conn.rs` connection pump over `AsyncRead+AsyncWrite` (so it accepts a `RealityStream`); rewrite `tls_front::run_reality_conn`'s `Decision::Accept` branch to forge a leaf + emit the ServerHello + `serve` the flight (deleting the dead BoringSSL acceptor builders); and a netns money test proving a real `verify=on` client tunnels through. + +**Tech Stack:** Rust, `yip-rendezvous` bin crate, `yip_utls` (5b/5c/4b/3 primitives), `rcgen` (leaf forge), `tokio` (async), `getrandom` (OS CSPRNG). + +## Global Constraints + +- `#![forbid(unsafe_code)]` (outside yip-io/yip-device) — NO `unsafe`, NO `as` casts, NO bare `#[allow]` (use `#[expect(reason = "...")]`). +- Reuse REALITY.5b `emit_server_hello` / 5c `serve` / 4b `auth::derive_cert_key` / 3 `forge_leaf` **unchanged** — this PR calls them, it does not modify them. +- Fail-closed: **pre-write** failures (no template, unsupported group, missing client share, forge/emit error) → **splice** to dest; **post-write** failures (`serve`/timeout/IO after the ServerHello is on the wire) → **drop** (log). No panic on the connection path — every slice is bounded (`rec.get(5..)`), every `Option`/`Result` matched. +- `serve` is bounded by `HANDSHAKE_TIMEOUT` (the CCS-drain is otherwise unbounded). +- The ServerHello rng is an OS-CSPRNG-backed `RandomSource` — never seeded/deterministic. +- NO client changes (4a/4b shipped); NO anti-replay changes (`decide_authed`/`ReplayGuard`); NO 5b/5c construction changes; NO P256/P384+HRR (#84 — such a group → splice); NO exact leaf-length padding (natural leaf, user decision); do NOT drop the `boring` dependency. +- Every task: `cargo test -p yip-rendezvous-bin` green (plus the netns script for Task 4), `cargo clippy -p yip-rendezvous-bin --all-targets -- -D warnings` clean, `cargo fmt`. +- Branch stacked on 5c (PR #86). Leave the PR for the user; do NOT merge; no "not merging" line. +- **Known pre-existing flake (NOT yours):** the pre-commit hook runs the whole workspace suite, which fails only on two `yip-io::uring::tests::uring_*` loopback tests (237 < 256 datagrams under load, unrelated crate, confirmed on clean base). If the commit is blocked *solely* by those, commit with `--no-verify` and say so. Any `yip-rendezvous`/`yip-utls` failure is yours. + +--- + +### Task 1: Expose the group-4588 x25519 tail in `ClientHelloInfo` + +The authed path keys the group-4588 TLS DH against the x25519 bundled in the client's 4588 key_share entry (`mlkem_ek(1184) ‖ x25519(32)`), not the standalone `0x001d` entry. The parser already walks that entry for the ML-KEM ek; also capture its trailing 32 bytes. + +**Files:** +- Modify: `bin/yip-rendezvous/src/reality.rs` (`ClientHelloInfo` struct; `parse_extensions`/`parse_key_share_mlkem_ek` area; every `ClientHelloInfo { .. }` construction site — `grep -c "ClientHelloInfo {"` reports **11**) + +**Interfaces:** +- Produces: `ClientHelloInfo { …, pub key_share_mlkem_x25519: Option<[u8; 32]> }` — the trailing 32-byte x25519 of the group-4588 key_share entry; `None` when there is no valid 4588 entry. + +- [ ] **Step 1: Write the failing tests** + +Add to `reality.rs`'s `#[cfg(test)] mod tests`. The existing `build_test_client_hello_with_4588_key_share(mlkem_ek, standalone_x25519, hybrid_x25519)` helper already builds a 4588 entry whose tail is `hybrid_x25519` distinct from the standalone `0x001d` entry — reuse it: + +```rust + #[test] + fn parse_client_hello_extracts_mlkem_x25519_tail() { + let mlkem_ek = vec![0xABu8; 1184]; + 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"); + // The 4588 tail is the hybrid x25519, NOT the standalone 0x001d one. + assert_eq!(info.key_share_mlkem_x25519, Some(hybrid_x25519)); + assert_eq!(info.key_share_x25519, Some(standalone_x25519)); + assert_eq!(info.key_share_mlkem_ek.as_deref(), Some(&mlkem_ek[..])); + } + + #[test] + fn parse_client_hello_mlkem_x25519_wrong_length_is_none() { + // A 4588 entry that isn't exactly 1184+32 = 1216 bytes → tail is None + // (same gate as key_share_mlkem_ek). + 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("rest of hello still parses"); + assert_eq!(info.key_share_mlkem_x25519, None); + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yip-rendezvous-bin --bin yip-rendezvous reality::tests::parse_client_hello_extracts_mlkem_x25519_tail` +Expected: FAIL — no field `key_share_mlkem_x25519`. + +- [ ] **Step 3: Add the field + extract the tail** + +Add `pub key_share_mlkem_x25519: Option<[u8; 32]>` to `ClientHelloInfo` (with a doc comment mirroring `key_share_mlkem_ek`'s). The existing `parse_key_share_mlkem_ek` finds the group-4588 entry and returns its first 1184 bytes when the entry is exactly `1184 + 32` bytes. Extend the extraction so the SAME parse also yields the trailing 32 bytes. The cleanest shape: a single helper that returns both, e.g. + +```rust +/// From the `key_share` extension body, find the group-4588 entry +/// (`mlkem_ek(1184) ‖ x25519(32)`, exactly 1216 bytes) and return +/// `(mlkem_ek: Vec, x25519_tail: [u8; 32])`. Any other length or a +/// missing entry → `None` (fail-closed, bounded `.get(..)`). +fn parse_key_share_mlkem768(body: &[u8]) -> Option<(Vec, [u8; 32])> { + 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() == MLKEM768_EK_LEN + 32 { + let ek = key_bytes.get(..MLKEM768_EK_LEN)?.to_vec(); + let tail: [u8; 32] = key_bytes.get(MLKEM768_EK_LEN..)?.try_into().ok()?; + return Some((ek, tail)); + } + entries = entries.get(4 + key_len..)?; + } + None +} +``` + +Replace the current `key_share_mlkem_ek` extraction call so both fields come from this one walk (e.g. `let (ek, tail) = parse_key_share_mlkem768(body).map_or((None, None), |(e, t)| (Some(e), Some(t)));`), keeping the existing `parse_extensions` return-tuple shape (extend it with the tail, or set both on the `ClientHelloInfo`). Use the existing `GROUP_X25519MLKEM768` / `MLKEM768_EK_LEN` constants (already defined for Task-5b work; if `MLKEM768_EK_LEN` isn't a local const, use the literal `1184` as the existing `key_share_mlkem_ek` code does). **Initialize `key_share_mlkem_x25519` at every `ClientHelloInfo { .. }` site** — the real one in `parse_client_hello` (to the parsed value) and all test sites (to `None`). `cargo build -p yip-rendezvous-bin` fails until all 11 are updated; fix each "missing field" error. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p yip-rendezvous-bin --bin yip-rendezvous reality` then `cargo test -p yip-rendezvous-bin` +Expected: PASS (all existing reality/auth/anti-replay tests green — the field is additive). + +- [ ] **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.5d): extract group-4588 x25519 tail from ClientHello (for server-side hybrid DH)" +``` + +--- + +### Task 2: Genericize the connection pump over the stream type + +`handle_connection` and its helpers `read_and_classify` and `into_decoy` are typed to `tokio_boring::SslStream`, but their bodies only use `AsyncRead`/`AsyncWrite`. Genericize the whole chain so it can also carry a `yip_utls::RealityStream`. (`reality_reject(stream: S)` is already generic — leave it.) + +**Files:** +- Modify: `bin/yip-rendezvous/src/conn.rs` (`handle_connection` ~82, `read_and_classify` ~146, `into_decoy` ~183) + +**Interfaces:** +- Produces: `pub async fn handle_connection(stream: St, cfg: Arc) where St: AsyncRead + AsyncWrite + Unpin + Send` — accepts any async byte stream (the existing `SslStream` caller and the new `RealityStream` caller both satisfy the bound). + +- [ ] **Step 1: Find every `SslStream` mention in the chain** + +Run: `grep -n "SslStream" bin/yip-rendezvous/src/conn.rs` +Expected: `handle_connection` (param `tokio_boring::SslStream`), `read_and_classify` (param `&mut tokio_boring::SslStream`), `into_decoy` (param `tokio_boring::SslStream`), plus comments mentioning close_notify. These three function signatures are what changes; the bodies don't. + +- [ ] **Step 2: Genericize the three signatures** + +Change each from the `SslStream`-typed form to a generic `St` bounded by `AsyncRead + AsyncWrite + Unpin + Send`. Concretely: + +```rust +pub async fn handle_connection(mut stream: St, cfg: Arc) +where + St: AsyncRead + AsyncWrite + Unpin + Send, +{ /* body unchanged */ } + +async fn read_and_classify( + stream: &mut St, + /* ...other params unchanged... */ +) +where + St: AsyncRead + AsyncWrite + Unpin + Send, +{ /* body unchanged */ } + +async fn into_decoy(mut stream: St, cfg: &TlsFrontCfg, buffered: Vec) +where + St: AsyncRead + AsyncWrite + Unpin + Send, +{ /* body unchanged */ } +``` + +Keep every call site inside `conn.rs` as-is (they pass the stream through; type inference resolves `St`). Leave the `// close_notify on SslStream` comments or soften them to "on a TLS stream" — cosmetic. Do NOT change `reality_reject` (already generic). Ensure `use tokio::io::{AsyncRead, AsyncWrite}` (and `AsyncWriteExt` for `.shutdown()`) are in scope — they already are (the file uses them), but confirm after editing. + +- [ ] **Step 3: Verify the non-REALITY caller still compiles** + +The existing caller is `run_tls_front` in `tls_front.rs`: `handle_connection(s, ...)` where `s` is a `tokio_boring::SslStream<...>`. `SslStream` implements `AsyncRead + AsyncWrite + Unpin + Send`, so it still satisfies the new bound — no caller change needed. + +Run: `cargo build -p yip-rendezvous-bin` +Expected: compiles clean. + +- [ ] **Step 4: Run the full suite (behavior-preserving gate)** + +Run: `cargo test -p yip-rendezvous-bin` +Expected: PASS — every existing test (incl. `probe_is_proxied_to_decoy`, `reality_inner_fail_writes_generic_error_not_decoy`) stays green. The signature change is behavior-identical. + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-rendezvous-bin --all-targets -- -D warnings +cargo fmt +git add bin/yip-rendezvous/src/conn.rs +git commit -m "refactor(reality.5d): genericize handle_connection pump over AsyncRead+AsyncWrite (accept RealityStream)" +``` + +--- + +### Task 3: Rewrite the authed path to serve the hand-rolled flight + drop the dead acceptors + +Replace `run_reality_conn`'s `Decision::Accept` branch (BoringSSL) with the 5b+5c hand-rolled handshake, and delete the now-dead `build_forged_acceptor*` + `CacheEntry.acceptor`. + +**Files:** +- Modify: `bin/yip-rendezvous/src/tls_front.rs` (the `Decision::Accept` arm ~258-291; add an `OsRandomSource` + a pure `select_client_x25519` helper + its tests) +- Modify: `bin/yip-rendezvous/src/reality_cert.rs` (remove `build_forged_acceptor`, `build_forged_acceptor_with_pkcs8`, `build_forged_acceptor_with_key`, `CacheEntry.acceptor`; adjust `apply_refresh`/pre-warm to not build an acceptor) + +**Interfaces:** +- Consumes: `ClientHelloInfo.key_share_mlkem_x25519` (Task 1); `handle_connection` (Task 2); `RealityCertCache::{template_for, fields_for}`; `yip_utls::{emit_server_hello, serve}`, `yip_utls::auth::derive_cert_key`, `yip_utls::hello::RandomSource`; `reality_cert::forge_leaf`. +- Produces: `fn select_client_x25519(group: u16, info: &ClientHelloInfo) -> Option<[u8; 32]>` (pure, testable). + +- [ ] **Step 1: Write the failing unit test for the pure decision** + +Add to `tls_front.rs`'s `#[cfg(test)] mod tests` (build a minimal `ClientHelloInfo` — set `key_share_x25519 = Some([1;32])`, `key_share_mlkem_x25519 = Some([2;32])`, other fields defaults/`None`): + +```rust + fn info_with_shares(x0: Option<[u8; 32]>, x4588: Option<[u8; 32]>) -> ClientHelloInfo { + ClientHelloInfo { + sni: Some("example.com".to_string()), + client_random: [0u8; 32], + legacy_session_id: vec![0u8; 32], + key_share_x25519: x0, + key_share_mlkem_ek: None, + key_share_mlkem_x25519: x4588, + } + } + + #[test] + fn select_client_x25519_by_group() { + let info = info_with_shares(Some([1u8; 32]), Some([2u8; 32])); + // group 4588 → the 4588-entry tail; group 29 → the 0x001d entry. + assert_eq!(select_client_x25519(4588, &info), Some([2u8; 32])); + assert_eq!(select_client_x25519(29, &info), Some([1u8; 32])); + // unsupported group → None (→ splice). + assert_eq!(select_client_x25519(23, &info), None); + // missing share for the selected group → None. + assert_eq!(select_client_x25519(4588, &info_with_shares(Some([1u8; 32]), None)), None); + assert_eq!(select_client_x25519(29, &info_with_shares(None, Some([2u8; 32]))), None); + } +``` + +(Adjust the `ClientHelloInfo` literal to the real field set — check `reality.rs` for the exact fields after Task 1. `ClientHelloInfo` derives `Clone`/`PartialEq` per its existing `#[derive]`.) + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yip-rendezvous-bin --bin yip-rendezvous tls_front::tests::select_client_x25519_by_group` +Expected: FAIL — `select_client_x25519` not defined. + +- [ ] **Step 3: Add the pure helper + the `OsRandomSource`** + +In `tls_front.rs`: + +```rust +/// Which client X25519 public key feeds the server-side TLS DH, by negotiated +/// group: for X25519MLKEM768 (4588) it is the x25519 bundled in the client's +/// 4588 key_share entry (`key_share_mlkem_x25519`); for X25519 (29) it is the +/// standalone `0x001d` entry (`key_share_x25519`). Any other group is +/// unsupported here (P256/P384 + HelloRetryRequest is #84) → `None` → splice. +fn select_client_x25519(group: u16, info: &ClientHelloInfo) -> Option<[u8; 32]> { + match group { + 4588 => info.key_share_mlkem_x25519, + 29 => info.key_share_x25519, + _ => None, + } +} + +/// An OS-CSPRNG-backed [`yip_utls::hello::RandomSource`] for `emit_server_hello` +/// (mirrors yip_utls's own private `OsRng`: a `getrandom` bridge that latches +/// the first error so the caller fail-closes instead of emitting predictable +/// bytes). NEVER seed this — a predictable rng here makes the ML-KEM +/// encapsulation predictable. +#[derive(Default)] +struct OsRandomSource { + error: bool, +} + +impl yip_utls::hello::RandomSource for OsRandomSource { + fn fill(&mut self, buf: &mut [u8]) { + if self.error { + return; + } + if getrandom::getrandom(buf).is_err() { + self.error = true; + } + } +} +``` + +(`getrandom = "0.2"` and `p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }` are ALREADY direct deps of `yip-rendezvous` — no Cargo.toml change needed. `p256`'s `pkcs8` feature provides `SigningKey::from_pkcs8_der` via the `DecodePrivateKey` trait used in Step 4b.) + +- [ ] **Step 4a: Thread `info` into the decision tuple** + +`info` is NOT in the `Accept` branch's scope today — the decision closure returns `Some((sni.to_owned(), ts_min, seal, shared, fields))`. The hand-rolled handshake needs the client's key shares + `legacy_session_id`, so add the parsed hello to the tuple. `ClientHelloInfo` derives `Clone`. Change the closure's return to `Some((sni.to_owned(), ts_min, seal, shared, fields, info.clone()))` and the destructure to: + +```rust + if let Some((sni, ts_min, seal, shared, Some(fields), info)) = decision { +``` + +(`fields` is the `Arc` already bound from `r.certs.fields_for(sni)` — do NOT re-fetch it. `r = cfg.reality.as_ref()`, so the cache is `r.certs`; splice uses `r.dest`; the pump gets `Arc::clone(cfg)`.) + +- [ ] **Step 4b: Rewrite the `Decision::Accept` branch** + +Replace the `Decision::Accept => { … }` arm's body. In scope: `sni` (String), `fields` (`Arc`), `shared`, `info` (`ClientHelloInfo`), `rec`, `r`, `tcp`, `cfg`. The new body (splice pre-write, drop post-write): + +```rust + Decision::Accept => { + // --- Pre-write: any failure SPLICES (connection still pristine). --- + let Some(template) = r.certs.template_for(&sni) else { + splice_to_dest(tcp, r.dest, &rec).await; + return; + }; + + let group = template.server_hello.key_share_group; + let Some(client_x25519) = select_client_x25519(group, &info) else { + splice_to_dest(tcp, r.dest, &rec).await; + return; + }; + + let dk = yip_utls::auth::derive_cert_key(&shared); + + // Forge the natural leaf (no exact-length padding); SPKI = dk. + let leaf_keypair = match rcgen::KeyPair::try_from(dk.pkcs8_der.as_slice()) { + Ok(k) => k, + Err(e) => { + eprintln!("tls-front: reality leaf keypair load failed ({sni}): {e}"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + let forged_leaf_der = match crate::reality_cert::forge_leaf(&fields, &leaf_keypair) { + Ok(cert) => cert.der().as_ref().to_vec(), + Err(e) => { + eprintln!("tls-front: reality forge_leaf failed ({sni}): {e}"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + + let ch_msg = match rec.get(5..) { + Some(m) => m, + None => { + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + + let mut rng = OsRandomSource::default(); + let (sh_msg, keys) = match yip_utls::emit_server_hello( + &template.server_hello, + ch_msg, + &info.legacy_session_id, + &client_x25519, + info.key_share_mlkem_ek.as_deref(), + &mut rng, + ) { + Ok(v) => v, + Err(e) => { + eprintln!("tls-front: reality emit_server_hello failed ({sni}): {e}"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + if rng.error { + // getrandom failed → fail closed (still pre-write). + eprintln!("tls-front: reality OS rng failed ({sni})"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + + // --- Commit point: write the ServerHello. From here, DROP on error. --- + let mut transcript_ch_sh = Vec::with_capacity(ch_msg.len() + sh_msg.len()); + transcript_ch_sh.extend_from_slice(ch_msg); + transcript_ch_sh.extend_from_slice(&sh_msg); + + let mut sh_record = Vec::with_capacity(5 + sh_msg.len()); + sh_record.push(0x16); // handshake + sh_record.extend_from_slice(&[0x03, 0x03]); // legacy record version + // Still pre-write (nothing on the wire yet) → splice. A + // ServerHello never exceeds u16, so this is unreachable in + // practice, but it keeps the fail-safe boundary honest. + let sh_len = match u16::try_from(sh_msg.len()) { + Ok(l) => l, + Err(_) => { + eprintln!("tls-front: reality ServerHello too large ({sni})"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + sh_record.extend_from_slice(&sh_len.to_be_bytes()); + sh_record.extend_from_slice(&sh_msg); + if let Err(e) = tcp.write_all(&sh_record).await { + eprintln!("tls-front: reality ServerHello write failed ({sni}): {e}"); + return; + } + + let signing_key = + match p256::ecdsa::SigningKey::from_pkcs8_der(&dk.pkcs8_der) { + Ok(k) => k, + Err(e) => { + eprintln!("tls-front: reality signing key load failed ({sni}): {e}"); + return; + } + }; + + let reality_stream = match tokio::time::timeout( + HANDSHAKE_TIMEOUT, + yip_utls::serve( + tcp, + &keys, + &template.encrypted_flight, + &template.cert_chain, + &forged_leaf_der, + &signing_key, + &transcript_ch_sh, + ), + ) + .await + { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + eprintln!("tls-front: reality serve failed ({sni}): {e}"); + return; + } + Err(_) => { + eprintln!("tls-front: reality serve timed out ({sni})"); + return; + } + }; + + super::conn::handle_connection(reality_stream, Arc::clone(cfg)).await; + return; + } +``` + +Notes for the implementer: +- Confirm `cfg.certs` is the `RealityCertCache` and that `template_for`/`fields_for` take `&str` and return `Option>` — adjust the borrow/deref (`&template.server_hello`, `&template.encrypted_flight`, `&template.cert_chain`) to match (`Arc` derefs to `ServerFlightTemplate`, so `&template.server_hello` works). +- `p256::pkcs8::DecodePrivateKey` must be in scope for `SigningKey::from_pkcs8_der` (`use p256::pkcs8::DecodePrivateKey as _;`). +- Remove the now-unused imports the old branch needed (`build_forged_acceptor_with_pkcs8`, `PrefixedStream` if only this branch used it — **check**: `PrefixedStream` may still be used by the splice path; grep before removing its import). `tokio_boring::accept` import for this branch goes away (keep it if `run_tls_front` still uses it). +- `tcp` is moved into `serve`; ensure nothing uses it afterward. + +- [ ] **Step 5: Delete the dead BoringSSL acceptor builders** + +In `reality_cert.rs`, remove `build_forged_acceptor`, `build_forged_acceptor_with_pkcs8`, `build_forged_acceptor_with_key`, and the `CacheEntry.acceptor` field. Update `apply_refresh` and the startup pre-warm so a cache entry is populated from `(fields, template)` **without** building an acceptor (the capture via `capture_dest_flight` and the degrade-to-splice-on-capture-failure are unchanged — only the acceptor construction is dropped). Keep `forge_leaf`, `extract_fields`, `fields_for`, `template_for`, and the capture/refresh/staleness machinery. Drop the `use boring::ssl::{SslAcceptor, …}` imports that only the removed builders used (keep any `boring` imports `extract_fields` still needs, e.g. `boring::x509`). + +Run: `cargo build -p yip-rendezvous-bin` +Expected: compiles (fix any remaining reference to the removed items — e.g. a test that called `build_forged_acceptor` should be removed or repointed at `forge_leaf`). + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p yip-rendezvous-bin --bin yip-rendezvous tls_front::tests::select_client_x25519_by_group` then the full `cargo test -p yip-rendezvous-bin` +Expected: PASS — the new pure-decision test, and every existing test (the un-authed splice path, `decide_authed`, anti-replay, `reality_cert` capture/refresh, `conn` decoy) stays green. If a `reality_cert` test exercised a removed acceptor builder, it was testing dead code — remove it (note this in the report) rather than resurrecting the builder. + +- [ ] **Step 7: Clippy, fmt, commit** + +```bash +cargo clippy -p yip-rendezvous-bin --all-targets -- -D warnings +cargo fmt +git add bin/yip-rendezvous/src/tls_front.rs bin/yip-rendezvous/src/reality_cert.rs +git commit -m "feat(reality.5d): serve hand-rolled 5b+5c flight on the authed path; drop BoringSSL forged acceptors" +``` + +--- + +### Task 4: netns money test — a real `verify=on` client tunnels through the 5d relay + +Prove the integration end-to-end: a real yipd client (`reality://…&verify=on`) tunnels to a peer through a relay whose authed path now hand-rolls the flight (5b+5c), and a wrong-key client fails closed. + +**Files:** +- Create: `bin/yipd/tests/run-netns-reality-5d.sh` (fork the closest existing REALITY relay netns script) +- Modify: the CI workflow that runs the other REALITY netns scripts, if they're wired in (add this one) + +**Interfaces:** +- Consumes: the shipped yipd `reality://host:port?pbk=&sid=&sni=[&verify=on]` client (4a/4b); the yip-rendezvous relay's `--reality-dest`/`--reality-private-key`/`--reality-short-id`/`--reality-server-name` flags (REALITY.3); the 5d authed path (Task 3). + +- [ ] **Step 1: Read the existing REALITY netns scripts to reuse the plumbing** + +Run: `ls bin/yipd/tests/run-netns-*.sh` and read the REALITY relay one (e.g. `run-netns-relay-tls.sh` and/or the REALITY probe script) — reuse its netns setup (namespaces, veth, the relay + two peers, UDP-blocking so peers must relay), its `--reality-*` relay flags, and its dest/decoy setup. + +- [ ] **Step 2: Provide a local mock `dest` the relay captures a template from** + +The relay pre-warms its `ServerFlightTemplate` by probing `--reality-dest` at startup (`capture_dest_flight`). For a hermetic test, run a local TLS 1.3 server as the dest inside the netns — an `openssl s_server -tls1_3 -www` with a self-signed cert on a fixed port is sufficient (the client offers both X25519MLKEM768 and X25519; openssl selects X25519 (group 29), so the captured `key_share_group` is 29 and the authed path keys group 29 — fully supported by 5b/5c). Point `--reality-dest` at it. (If the existing REALITY scripts already stand up a dest, reuse that; only ensure it speaks TLS 1.3.) + +- [ ] **Step 3: The money assertions** + +The script must assert, with non-zero exit on any failure: +1. **Tunnel works:** with the client dialing `reality://:?pbk=&sid=&sni=&verify=on`, peer A pings peer B *through the relay* (relay-forwarded packet count > 0), i.e. the client completed the hand-rolled handshake AND verified the 4b binding (`verify=on` only succeeds if the relay's hand-rolled `CertificateVerify` verifies). +2. **Wrong key fails closed:** a client dialing with a WRONG `pbk` (relay public key) gets no tunnel (the seal fails to open → the relay splices → no authed handshake → no relayed packets). Assert the ping FAILS / relay-forwarded count stays 0 for this variant. + +Model the ping/relay-count checks on the existing REALITY relay script's assertions (it already counts relay-forwarded packets). Print a clear `PASS`/`FAIL` and `exit 1` on failure. + +- [ ] **Step 4: Run the script under sudo** + +Run: `sudo bash bin/yipd/tests/run-netns-reality-5d.sh` +Expected: prints `PASS`, exit 0 — the `verify=on` client tunnels through the hand-rolled relay; the wrong-key variant gets no tunnel. + +(If the harness can't run in this environment — netns needs root + kernel support — capture the exact failure and report it; do not mark the task done on an unrun money test. The controller decides whether to run it or defer to the user's environment.) + +- [ ] **Step 5: Wire into CI (if the peers do) + commit** + +If the other `run-netns-*.sh` scripts are invoked by a CI workflow (grep `.github/workflows/` for `run-netns`), add this script alongside them. + +```bash +chmod +x bin/yipd/tests/run-netns-reality-5d.sh +git add bin/yipd/tests/run-netns-reality-5d.sh .github/workflows/ # if modified +git commit -m "test(reality.5d): netns money test — verify=on client tunnels through the hand-rolled relay" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the 5d delta (base = 5c tip / PR #86 head), focused on: the splice-vs-drop boundary (no post-ServerHello splice; every pre-write failure splices), no panic on the connection path, the group→x25519 selection, the OS rng being unseeded + fail-closed, and that the `reality_cert` cleanup didn't drop a still-live path. +- Push the branch; open a PR **stacked on #86** (base = `feat/reality-5c-server-flight-emit`). Leave it for the user; do NOT merge; no "not merging" line. +- Update the ledger + `yip-antidpi-status.md` (REALITY.5 COMPLETE — 5a/5b/5c/5d; the authed path is now end-to-end dest-faithful; the last anti-DPI item is done). + +## Self-Review notes + +- The `select_client_x25519` helper is the one pure, unit-tested decision; the template-missing and forge/emit failures are direct guards in the async branch (obvious splice), so they don't each need a pure helper. +- The `rng.error` check runs **before** the ServerHello write, so an OS-rng failure still splices (pre-write), consistent with the fail-safe boundary. +- `serve` moves `tcp`; the ServerHello is written to `tcp` *before* the `serve` call, so the write and the serve share the same stream in sequence (write, then hand the same `tcp` to `serve`) — the implementer must write the ServerHello to `tcp` and then pass that same `tcp` into `serve` (as the code in Step 4 does). From 0fc86b09f5223180f94ca6733c8441210fa3e8f4 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 13:51:25 -0400 Subject: [PATCH 20/27] feat(reality.5d): extract group-4588 x25519 tail from ClientHello (for server-side hybrid DH) --- bin/yip-rendezvous/src/reality.rs | 105 +++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/bin/yip-rendezvous/src/reality.rs b/bin/yip-rendezvous/src/reality.rs index bf2df93..69ae7f2 100644 --- a/bin/yip-rendezvous/src/reality.rs +++ b/bin/yip-rendezvous/src/reality.rs @@ -34,6 +34,12 @@ pub struct ClientHelloInfo { /// `key_share_x25519` above stays sourced from the standalone `0x001d` /// entry). pub key_share_mlkem_ek: Option>, + /// The x25519 key embedded in the trailing 32 bytes of 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 last 32 bytes. `None` when there is no valid 4588 entry (i.e., + /// entry is missing or not exactly 1216 bytes). + pub key_share_mlkem_x25519: Option<[u8; 32]>, } /// TLS extension type for `server_name` (SNI). @@ -100,7 +106,8 @@ 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, key_share_mlkem_ek) = parse_extensions(extensions)?; + let (sni, key_share_x25519, key_share_mlkem_ek, key_share_mlkem_x25519) = + parse_extensions(extensions)?; Some(ClientHelloInfo { sni, @@ -108,22 +115,31 @@ pub fn parse_client_hello(msg: &[u8]) -> Option { legacy_session_id, key_share_x25519, key_share_mlkem_ek, + key_share_mlkem_x25519, }) } /// 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. +/// x25519 entry and the group-4588 ML-KEM ek + x25519 tail). 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 \ + reason = "internal helper; the 4-tuple mirrors ClientHelloInfo's 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>)> { +fn parse_extensions( + mut buf: &[u8], +) -> Option<( + Option, + Option<[u8; 32]>, + Option>, + Option<[u8; 32]>, +)> { let mut sni = None; let mut key_share_x25519 = None; let mut key_share_mlkem_ek = None; + let mut key_share_mlkem_x25519 = None; while !buf.is_empty() { let ext_type = u16_be(buf.get(..2)?)?; let ext_len = usize::from(u16_be(buf.get(2..4)?)?); @@ -132,13 +148,21 @@ fn parse_extensions(mut buf: &[u8]) -> Option<(Option, Option<[u8; 32]>, EXT_SERVER_NAME => sni = parse_server_name(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); + if let Some((ek, tail)) = parse_key_share_mlkem768(ext_body) { + key_share_mlkem_ek = Some(ek); + key_share_mlkem_x25519 = Some(tail); + } } _ => {} } buf = buf.get(4 + ext_len..)?; } - Some((sni, key_share_x25519, key_share_mlkem_ek)) + Some(( + sni, + key_share_x25519, + key_share_mlkem_ek, + key_share_mlkem_x25519, + )) } /// Parse a `server_name` extension body: `list_len(u16) | name_type(u8) | @@ -177,14 +201,11 @@ 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> { +/// From the `key_share` extension body, find the group-4588 entry +/// (`mlkem_ek(1184) ‖ x25519(32)`, exactly 1216 bytes) and return +/// `(mlkem_ek: Vec, x25519_tail: [u8; 32])`. Any other length or a +/// missing entry → `None` (fail-closed, bounded `.get(..)`). +fn parse_key_share_mlkem768(body: &[u8]) -> Option<(Vec, [u8; 32])> { let shares_len = usize::from(u16_be(body.get(..2)?)?); let mut entries = body.get(2..2 + shares_len)?; while !entries.is_empty() { @@ -192,8 +213,9 @@ fn parse_key_share_mlkem_ek(body: &[u8]) -> Option> { 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()); + let ek = key_bytes.get(..MLKEM768_EK_LEN)?.to_vec(); + let tail: [u8; 32] = key_bytes.get(MLKEM768_EK_LEN..)?.try_into().ok()?; + return Some((ek, tail)); } entries = entries.get(4 + key_len..)?; } @@ -500,6 +522,7 @@ mod tests { legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; assert!(reality_auth_open( @@ -530,6 +553,7 @@ mod tests { legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; assert!(!reality_auth_open( @@ -560,6 +584,7 @@ mod tests { legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; // Client clock in the PAST (now > ts): at the boundary accepted, past it rejected. @@ -616,6 +641,7 @@ mod tests { legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; assert!(!reality_auth_open( @@ -646,6 +672,7 @@ mod tests { legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; assert!(!reality_auth_open( @@ -666,6 +693,7 @@ mod tests { legacy_session_id: vec![0u8; SESSION_ID_LEN], key_share_x25519: None, key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; assert!(!reality_auth_open(&reality_priv, &info, &[[0u8; 8]], 0, 60)); } @@ -679,6 +707,7 @@ mod tests { legacy_session_id: vec![0u8; 10], // not 32 bytes key_share_x25519: Some([1u8; 32]), key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; assert!(!reality_auth_open(&reality_priv, &info, &[[0u8; 8]], 0, 60)); } @@ -777,6 +806,7 @@ mod tests { legacy_session_id: session_id.to_vec(), key_share_x25519: Some(eph_pub), key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; let ts_from_recover = reality_auth_recover(&reality_priv, &info, &[short_id], now, 10) @@ -809,6 +839,7 @@ mod tests { legacy_session_id: vec![0u8; SESSION_ID_LEN], key_share_x25519: None, key_share_mlkem_ek: None, + key_share_mlkem_x25519: None, }; assert_eq!( reality_auth_recover_shared(&reality_priv, &info, &[[0u8; 8]], 0, 60), @@ -901,6 +932,44 @@ mod tests { assert_eq!(info.key_share_mlkem_ek, None); } + #[test] + fn parse_client_hello_extracts_mlkem_x25519_tail() { + let mlkem_ek = vec![0xABu8; 1184]; + 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"); + // The 4588 tail is the hybrid x25519, NOT the standalone 0x001d one. + assert_eq!(info.key_share_mlkem_x25519, Some(hybrid_x25519)); + assert_eq!(info.key_share_x25519, Some(standalone_x25519)); + assert_eq!(info.key_share_mlkem_ek.as_deref(), Some(&mlkem_ek[..])); + } + + #[test] + fn parse_client_hello_mlkem_x25519_wrong_length_is_none() { + // A 4588 entry that isn't exactly 1184+32 = 1216 bytes → tail is None + // (same gate as key_share_mlkem_ek). + 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("rest of hello still parses"); + assert_eq!(info.key_share_mlkem_x25519, 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 163673e8d623e47e44c56362b12ee249a89a0db5 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 13:56:38 -0400 Subject: [PATCH 21/27] refactor(reality.5d): genericize handle_connection pump over AsyncRead+AsyncWrite (accept RealityStream) --- bin/yip-rendezvous/src/conn.rs | 14 +++++++------- bin/yip-rendezvous/src/conn_tunnel.rs | 10 +++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/bin/yip-rendezvous/src/conn.rs b/bin/yip-rendezvous/src/conn.rs index 1c7b322..2d8fa37 100644 --- a/bin/yip-rendezvous/src/conn.rs +++ b/bin/yip-rendezvous/src/conn.rs @@ -79,9 +79,9 @@ const CLASSIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); /// anything else (a censor probe, a browser, garbage, silence) is /// transparently reverse-proxied to the decoy backend, so the relay looks /// like an ordinary web server to everyone but a real yip client. -pub async fn handle_connection(mut stream: tokio_boring::SslStream, cfg: Arc) +pub async fn handle_connection(mut stream: St, cfg: Arc) where - S: AsyncRead + AsyncWrite + Unpin + Send, + St: AsyncRead + AsyncWrite + Unpin + Send, { let now_ms = u64::try_from(cfg.base.elapsed().as_millis()).unwrap_or(u64::MAX); // The relay is blind to the real TCP peer identity, and the TLS-only @@ -143,14 +143,14 @@ where /// Read the first frame (up to CLASSIFY_TIMEOUT) and classify it. Returns /// `None` on idle-timeout/read-error (caller treats as decoy). All bytes read /// are accumulated in `buf` so they can be replayed to the decoy. -async fn read_and_classify( - stream: &mut tokio_boring::SslStream, +async fn read_and_classify( + stream: &mut St, cfg: &TlsFrontCfg, buf: &mut Vec, now_ms: u64, ) -> Option where - S: AsyncRead + AsyncWrite + Unpin + Send, + St: AsyncRead + AsyncWrite + Unpin + Send, { let deadline = tokio::time::sleep(CLASSIFY_TIMEOUT); tokio::pin!(deadline); @@ -180,9 +180,9 @@ where /// Proxy this connection to the decoy backend: replay the buffered bytes, then /// splice bidirectionally. The decoy's own behavior/timing governs from here. -async fn into_decoy(mut stream: tokio_boring::SslStream, cfg: &TlsFrontCfg, buffered: Vec) +async fn into_decoy(mut stream: St, cfg: &TlsFrontCfg, buffered: Vec) where - S: AsyncRead + AsyncWrite + Unpin + Send, + St: AsyncRead + AsyncWrite + Unpin + Send, { let Some(decoy_addr) = cfg.decoy else { // No decoy configured: minimal static fallback (documented weaker diff --git a/bin/yip-rendezvous/src/conn_tunnel.rs b/bin/yip-rendezvous/src/conn_tunnel.rs index 25b870a..070ac68 100644 --- a/bin/yip-rendezvous/src/conn_tunnel.rs +++ b/bin/yip-rendezvous/src/conn_tunnel.rs @@ -19,13 +19,9 @@ const CHANNEL_DEPTH: usize = 64; /// malformed frame is seen (fail-closed teardown). `prefix` carries any bytes /// already read past the first (Register) frame during classification — a /// pipelined second frame in the same TLS read must not be lost. -pub async fn run_tunnel( - mut stream: tokio_boring::SslStream, - cfg: Arc, - node: NodeId, - prefix: Vec, -) where - S: AsyncRead + AsyncWrite + Unpin + Send, +pub async fn run_tunnel(mut stream: St, cfg: Arc, node: NodeId, prefix: Vec) +where + St: AsyncRead + AsyncWrite + Unpin + Send, { let (tx, mut rx) = mpsc::channel::>(CHANNEL_DEPTH); // Keep an identity handle so the exit-time removal below can tell From a866d56f0718bc2c2d7423e486ec452ffa5702e3 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 14:21:17 -0400 Subject: [PATCH 22/27] feat(reality.5d): serve hand-rolled 5b+5c flight on the authed path; drop BoringSSL forged acceptors --- bin/yip-rendezvous/Cargo.toml | 15 +- bin/yip-rendezvous/src/reality_cert.rs | 398 ++++++------------------- bin/yip-rendezvous/src/reality_io.rs | 42 +-- bin/yip-rendezvous/src/tls_front.rs | 275 +++++++++++++++-- 4 files changed, 366 insertions(+), 364 deletions(-) diff --git a/bin/yip-rendezvous/Cargo.toml b/bin/yip-rendezvous/Cargo.toml index 9b8c1b6..20d7292 100644 --- a/bin/yip-rendezvous/Cargo.toml +++ b/bin/yip-rendezvous/Cargo.toml @@ -40,14 +40,13 @@ time = { version = "0.3", default-features = false, features = ["parsing", "macr # DER parsing, not the validate/verify/ring/aws-lc-rs signature-checking # features (this crate never chain-validates the forged/stolen certs). x509-parser = { version = "0.18.1", default-features = false } - -[dev-dependencies] -# REALITY.4b Task 3 test-only: parses the SPKI DER `boring` hands back for a -# presented leaf's public key, to compare it (as a SEC1 point) against -# `yip_utls::auth::derive_cert_key`'s `public_sec1` — the leaf-pubkey-match -# gate for `build_forged_acceptor_with_pkcs8`. Same version yip-utls already -# depends on for the identical derivation; dev-only here so it adds no -# footprint to the shipped binary. +# REALITY.5d: loads the REALITY.4b shared-secret-derived ECDSA-P256 signing +# key (`yip_utls::auth::derive_cert_key`'s PKCS#8 DER) on the authed +# connection path (`tls_front::run_reality_conn`'s `Decision::Accept` branch) +# to sign the hand-rolled `CertificateVerify`. Promoted from dev-dependency +# (REALITY.4b Task 3 test-only use) now that it is also a production +# dependency; same version yip-utls already depends on for the identical +# derivation. p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } [lints] diff --git a/bin/yip-rendezvous/src/reality_cert.rs b/bin/yip-rendezvous/src/reality_cert.rs index c3d84f1..c602cb6 100644 --- a/bin/yip-rendezvous/src/reality_cert.rs +++ b/bin/yip-rendezvous/src/reality_cert.rs @@ -1,8 +1,14 @@ -//! REALITY.3 §1: the stolen-cert authed acceptor. Fetches the real `dest` -//! site's live leaf certificate at startup, forges a leaf that mimics its -//! identity (subject/SAN/validity/serial/keyUsage/EKU/basicConstraints) -//! signed by a relay-ephemeral key, and serves it from a TLS-1.3-only -//! `SslAcceptor` — one per configured server_name. The outer TLS is +//! REALITY.3 §1 / REALITY.5d: the stolen-cert authed path. Fetches the real +//! `dest` site's live leaf certificate + structural flight template at +//! startup (`fetch_dest_leaf`, backed by `yip_utls::capture_dest_flight`), +//! and caches both per configured server_name (`RealityCertCache`). The +//! relay forges a leaf that mimics the stolen identity +//! (subject/SAN/validity/serial/keyUsage/EKU/basicConstraints, signed by a +//! per-connection key derived from that connection's REALITY shared secret — +//! REALITY.4b) via `forge_leaf`, and `tls_front::run_reality_conn` serves it +//! per-connection with the hand-rolled TLS 1.3 flight +//! (`yip_utls::server::emit_server_hello` + `yip_utls::stream::serve` — +//! REALITY.5b/5c/5d) rather than a generic `SslAcceptor`. The outer TLS is //! zero-CA-auth by design, so the forged chain intentionally does not //! validate against a public CA; the inner yip handshake is the real //! security. See the design spec's §1 + Threat model. @@ -210,7 +216,6 @@ pub fn extract_fields(leaf: &boring::x509::X509Ref) -> StolenFields { } } -use boring::ssl::SslMethod; use std::net::SocketAddr; use std::time::Duration; use tokio::net::TcpStream; @@ -254,129 +259,49 @@ pub async fn fetch_dest_leaf( .map_err(|_| format!("fetch_dest_leaf({sni}) timed out after {timeout:?}"))? } -use boring::pkey::PKey; -use boring::ssl::{SslAcceptor, SslVersion}; - -/// Build a TLS-1.3-ONLY `SslAcceptor` presenting a leaf forged from `fields` -/// and self-signed by `key`. Pinning min-proto to 1.3 keeps the Certificate -/// message encrypted (spec §1 advisor #2 — TLS 1.2 would send it in -/// cleartext). ALPN mirrors `build_acceptor`. Shared body for both -/// `build_forged_acceptor` (the cache's fixed ephemeral key) and -/// `build_forged_acceptor_with_pkcs8` (REALITY.4b's per-connection -/// shared-secret-derived key) — the two differ only in where `key` comes -/// from. -fn build_forged_acceptor_with_key( - fields: &StolenFields, - key: &rcgen::KeyPair, -) -> Result { - let cert = forge_leaf(fields, key).map_err(|e| format!("forge: {e}"))?; - let cert_der = cert.der().as_ref().to_vec(); - let x509 = boring::x509::X509::from_der(&cert_der).map_err(|e| e.to_string())?; - let pkey = PKey::private_key_from_der(&key.serialize_der()).map_err(|e| e.to_string())?; - - let mut b = - SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).map_err(|e| e.to_string())?; - b.set_min_proto_version(Some(SslVersion::TLS1_3)) - .map_err(|e| e.to_string())?; - b.set_certificate(&x509).map_err(|e| e.to_string())?; - b.set_private_key(&pkey).map_err(|e| e.to_string())?; - b.check_private_key().map_err(|e| e.to_string())?; - b.set_alpn_protos(b"\x02h2\x08http/1.1") - .map_err(|e| e.to_string())?; - Ok(b.build()) -} - -/// Build a TLS-1.3-ONLY `SslAcceptor` presenting the forged leaf, self-signed -/// by the cache's fixed ephemeral key (REALITY.3). See -/// `build_forged_acceptor_with_key` for the shared body. -pub fn build_forged_acceptor( - fields: &StolenFields, - key: &rcgen::KeyPair, -) -> Result { - build_forged_acceptor_with_key(fields, key) -} - -/// Build a TLS-1.3-ONLY `SslAcceptor` whose forged leaf is signed by -/// `pkcs8_der` (the REALITY.4b shared-secret-derived ECDSA-P256 key, -/// `yip_utls::auth::derive_cert_key(shared).pkcs8_der`) rather than the -/// cache's fixed ephemeral key — one built fresh per authed connection so the -/// presented leaf's public key proves the relay holds `reality_priv` for -/// THAT connection's `shared` (what the client, Task 2, pins against). Same -/// field-copying as `build_forged_acceptor` (`build_forged_acceptor_with_key`). -/// -/// `rcgen::KeyPair`'s `TryFrom<&[u8]>` impl auto-detects the signature -/// algorithm from PKCS#8 DER (probing Ed25519, then ECDSA-P256, then -/// ECDSA-P384, then RSA) — `derive_cert_key` always encodes ECDSA-P256, so -/// this lands on `PKCS_ECDSA_P256_SHA256` deterministically. -pub fn build_forged_acceptor_with_pkcs8( - fields: &StolenFields, - pkcs8_der: &[u8], -) -> Result { - let key = rcgen::KeyPair::try_from(pkcs8_der).map_err(|e| format!("derived key: {e}"))?; - build_forged_acceptor_with_key(fields, &key) -} - use std::collections::HashMap; use std::sync::{Arc, RwLock}; use std::time::Instant; -/// One forged acceptor per configured server_name, plus the ephemeral signing -/// key and each entry's last-successful-fetch instant (for the staleness -/// bound). An SNI absent from the map is splice-only (spec §1 degrade rule). -/// `fetched_at` is now read directly by `RealityCertCache::apply_refresh` -/// (exercised by its own `#[cfg(test)]` unit tests), so no dead_code -/// suppression is needed here any more. +/// The cached stolen-cert material for one configured server_name, plus its +/// last-successful-fetch instant (for the staleness bound). An SNI absent +/// from the map is splice-only (spec §1 degrade rule). `fetched_at` is read +/// directly by `RealityCertCache::apply_refresh` (exercised by its own +/// `#[cfg(test)]` unit tests), so no dead_code suppression is needed here. +/// +/// REALITY.5d: no acceptor is built or cached here any more — +/// `tls_front::run_reality_conn`'s authed branch forges the leaf and serves +/// the hand-rolled flight per connection, reading only `fields`/`template`. struct CacheEntry { - /// The fixed-ephemeral-key acceptor (REALITY.3). REALITY.4b's - /// `run_reality_conn` no longer serves this directly to a real - /// connection — it re-forges a per-connection acceptor from `fields` via - /// `build_forged_acceptor_with_pkcs8` instead — so this field is now only - /// read by this module's own `acceptor_for`/cache-bookkeeping unit tests. - /// Building it at prewarm/refresh time is still meaningful: it proves - /// `fields` forges successfully before the SNI is considered "warm". - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "REALITY.4b: run_reality_conn now re-forges per-connection from `fields` \ - via build_forged_acceptor_with_pkcs8; `acceptor` is retained (and still \ - built, proving `fields` forges) for acceptor_for's own cache-bookkeeping \ - unit tests" - ) - )] - acceptor: Arc, - /// The `StolenFields` this entry's acceptor was forged from — cached - /// (REALITY.4b) so `run_reality_conn`'s per-connection re-forge needs no - /// `dest` re-fetch; see `RealityCertCache::fields_for`. + /// The `StolenFields` this entry was captured from (REALITY.3) — read by + /// `RealityCertCache::fields_for` so `run_reality_conn`'s per-connection + /// leaf forge needs no `dest` re-fetch. fields: Arc, /// The structural `ServerFlightTemplate` captured by the SAME /// `fetch_dest_leaf` probe that produced `fields` (REALITY.5a Task 4) — - /// cached per SNI so later REALITY.5 sub-milestones (5b/5c/5d) can - /// reproduce dest's ServerHello/encrypted-flight/cert-chain shape on the - /// authed path without a fresh dest dial. Not yet read by any 5a code - /// path (`template_for` is dead-code-gated in non-test builds). + /// read by `RealityCertCache::template_for` so `run_reality_conn`'s + /// authed branch (REALITY.5d) can reproduce dest's + /// ServerHello/encrypted-flight/cert-chain shape without a fresh dest + /// dial. template: Arc, fetched_at: Instant, } pub struct RealityCertCache { - /// server_name -> live acceptor. Absent ⇒ splice-only. + /// server_name -> cached fields/template. Absent ⇒ splice-only. entries: RwLock>, /// The set of names we are configured to serve (for refresh iteration). server_names: Vec, - /// Relay-ephemeral signing key, generated once; reused across refreshes so - /// a re-forge for the same name keeps a stable key. - key: KeyPair, } impl RealityCertCache { - /// Fetch + forge for each server_name. A name whose fetch fails is left - /// out of the map (splice-only). Errors if at least one name was - /// REQUESTED and not a single one warmed (misconfiguration guard) — but - /// an empty `server_names` (the "decoy-only" REALITY config: no SNI is - /// ever forged, so `run_reality_conn` splices every connection — same - /// spirit as the existing no-short-ids decoy-only mode) is not itself a - /// failure and returns a validly-empty cache. + /// Fetch for each server_name. A name whose fetch fails is left out of + /// the map (splice-only). Errors if at least one name was REQUESTED and + /// not a single one warmed (misconfiguration guard) — but an empty + /// `server_names` (the "decoy-only" REALITY config: no SNI is ever + /// forged, so `run_reality_conn` splices every connection — same spirit + /// as the existing no-short-ids decoy-only mode) is not itself a failure + /// and returns a validly-empty cache. pub async fn prewarm( server_names: &[String], dest: SocketAddr, @@ -384,24 +309,19 @@ impl RealityCertCache { _max_stale: Duration, timeout: Duration, ) -> Result, String> { - let key = KeyPair::generate().map_err(|e| format!("keygen: {e}"))?; let mut entries = HashMap::new(); for name in server_names { match fetch_dest_leaf(dest, name, timeout).await { - Ok((fields, template)) => match build_forged_acceptor(&fields, &key) { - Ok(acc) => { - entries.insert( - name.clone(), - CacheEntry { - acceptor: Arc::new(acc), - fields: Arc::new(fields), - template: Arc::new(template), - fetched_at: Instant::now(), - }, - ); - } - Err(e) => eprintln!("reality-cert: forge {name} failed: {e} (splice-only)"), - }, + Ok((fields, template)) => { + entries.insert( + name.clone(), + CacheEntry { + fields: Arc::new(fields), + template: Arc::new(template), + fetched_at: Instant::now(), + }, + ); + } Err(e) => eprintln!("reality-cert: prewarm {name} failed: {e} (splice-only)"), } } @@ -411,34 +331,12 @@ impl RealityCertCache { Ok(Arc::new(Self { entries: RwLock::new(entries), server_names: server_names.to_vec(), - key, })) } - /// The forged acceptor for `sni`, or `None` (⇒ caller splices to dest). - /// REALITY.4b: `run_reality_conn` no longer calls this for a real - /// connection (it re-forges per-connection via `fields_for` + - /// `build_forged_acceptor_with_pkcs8` instead) — kept as a documented - /// convenience API, still exercised by this module's own unit tests, the - /// same treatment `reality::reality_auth_open` got in REALITY.3. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "REALITY.4b: run_reality_conn re-forges per-connection via fields_for + \ - build_forged_acceptor_with_pkcs8 instead of calling this; kept as a \ - documented convenience API and still exercised by this module's own unit \ - tests" - ) - )] - pub fn acceptor_for(&self, sni: &str) -> Option> { - let g = self.entries.read().expect("cert cache lock poisoned"); - g.get(sni).map(|e| Arc::clone(&e.acceptor)) - } - /// The cached `StolenFields` for `sni`, or `None` (⇒ caller splices to - /// dest) — REALITY.4b's per-connection re-forge reads this instead of - /// re-fetching `dest`. + /// dest) — `run_reality_conn`'s per-connection leaf forge reads this + /// instead of re-fetching `dest`. pub fn fields_for(&self, sni: &str) -> Option> { let g = self.entries.read().expect("cert cache lock poisoned"); g.get(sni).map(|e| Arc::clone(&e.fields)) @@ -446,39 +344,32 @@ impl RealityCertCache { /// The cached `ServerFlightTemplate` for `sni`, captured by the same /// probe that produced `fields_for(sni)` — REALITY.5a Task 4. `None` - /// means `sni` never pre-warmed (splice-only). Not yet consumed within - /// 5a; wired up by REALITY.5b's flight-shape reproduction. - #[cfg_attr(not(test), expect(dead_code, reason = "consumed by REALITY.5b"))] + /// means `sni` never pre-warmed (splice-only). Consumed by + /// `run_reality_conn`'s authed branch (REALITY.5d). pub fn template_for(&self, sni: &str) -> Option> { let g = self.entries.read().expect("cert cache lock poisoned"); g.get(sni).map(|e| Arc::clone(&e.template)) } /// Apply one refresh outcome for `name` to the cache. `new_entry` is - /// `Some((acceptor, fields))` on full success (fetch AND forge both - /// succeeded), `None` on ANY refresh failure (fetch failed, or fetch - /// succeeded but forge failed). `now`/`max_stale` are injected (rather - /// than reading `Instant::now()` internally) so this stays pure and - /// unit-testable without real time or network. This is the ONLY place - /// cache mutation + the staleness bound (spec §1) are decided. + /// `Some((fields, template))` on fetch success, `None` on fetch failure. + /// `now`/`max_stale` are injected (rather than reading `Instant::now()` + /// internally) so this stays pure and unit-testable without real time or + /// network. This is the ONLY place cache mutation + the staleness bound + /// (spec §1) are decided. fn apply_refresh( &self, name: &str, - new_entry: Option<( - Arc, - Arc, - Arc, - )>, + new_entry: Option<(Arc, Arc)>, now: Instant, max_stale: Duration, ) -> RefreshOutcome { let mut g = self.entries.write().expect("cert cache poisoned"); match new_entry { - Some((acceptor, fields, template)) => { + Some((fields, template)) => { g.insert( name.to_owned(), CacheEntry { - acceptor, fields, template, fetched_at: now, @@ -498,14 +389,13 @@ impl RealityCertCache { } /// Background refresh: every `refresh`, re-fetch each name. A tick has - /// exactly two outcomes per name: full success (fetch AND forge succeed) - /// replaces the acceptor and stamps `fetched_at`; ANY failure (fetch - /// failed, or fetch succeeded but forge failed) runs the staleness check - /// — keep last-good unless it is now older than `max_stale`, in which - /// case drop it (⇒ splice-only) rather than serve an ever-staler forgery - /// (spec §1 staleness bound). All cache-mutation/staleness logic lives in - /// the pure `apply_refresh` helper; this loop only fetches, forges, and - /// logs. + /// exactly two outcomes per name: fetch success replaces the cached + /// fields/template and stamps `fetched_at`; fetch failure runs the + /// staleness check — keep last-good unless it is now older than + /// `max_stale`, in which case drop it (⇒ splice-only) rather than serve + /// an ever-staler forgery (spec §1 staleness bound). All + /// cache-mutation/staleness logic lives in the pure `apply_refresh` + /// helper; this loop only fetches and logs. pub fn spawn_refresh( self: &Arc, dest: SocketAddr, @@ -521,13 +411,7 @@ impl RealityCertCache { tick.tick().await; for name in &this.server_names { let new_entry = match fetch_dest_leaf(dest, name, timeout).await { - Ok((fields, template)) => match build_forged_acceptor(&fields, &this.key) { - Ok(acc) => Some((Arc::new(acc), Arc::new(fields), Arc::new(template))), - Err(e) => { - eprintln!("reality-cert: refresh {name} forge failed: {e}"); - None - } - }, + Ok((fields, template)) => Some((Arc::new(fields), Arc::new(template))), Err(_) => None, }; let outcome = this.apply_refresh(name, new_entry, Instant::now(), max_stale); @@ -674,46 +558,6 @@ mod tests { assert!(template.cert_chain.leaf_der_len > 0); } - #[tokio::test] - async fn forged_acceptor_is_tls13_only_and_presents_copied_subject() { - let fields = StolenFields { - subject_cn: Some("acc.test".to_owned()), - dns_sans: vec!["acc.test".to_owned()], - ip_sans: Vec::new(), - not_before: time::macros::datetime!(2025-01-01 0:00 UTC), - not_after: time::macros::datetime!(2027-01-01 0:00 UTC), - serial: vec![0x10], - key_usages: vec![rcgen::KeyUsagePurpose::DigitalSignature], - eku: vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth], - is_ca: false, - aia_der: None, - }; - let key = rcgen::KeyPair::generate().unwrap(); - let acceptor = std::sync::Arc::new(build_forged_acceptor(&fields, &key).unwrap()); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let acc = std::sync::Arc::clone(&acceptor); - tokio::spawn(async move { - if let Ok((tcp, _)) = listener.accept().await { - let _ = tokio_boring::accept(&acc, tcp).await; - } - }); - - // A TLS-1.2-MAX client must FAIL against the 1.3-only acceptor. - let tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); - let mut b = boring::ssl::SslConnector::builder(boring::ssl::SslMethod::tls()).unwrap(); - b.set_verify(boring::ssl::SslVerifyMode::NONE); - b.set_max_proto_version(Some(boring::ssl::SslVersion::TLS1_2)) - .unwrap(); - let cfg = b.build().configure().unwrap(); - let res = tokio_boring::connect(cfg, "acc.test", tcp).await; - assert!( - res.is_err(), - "TLS 1.2 client must be rejected by the 1.3-only acceptor" - ); - } - /// Spawn a local TLS server (self-signed) that answers any SNI — stands in /// for a real `dest`. Returns its address. async fn spawn_local_dest() -> SocketAddr { @@ -727,8 +571,10 @@ mod tests { let key = KeyPair::generate().unwrap(); let cert = p.self_signed(&key).unwrap(); let x = boring::x509::X509::from_der(cert.der().as_ref()).unwrap(); - let pkey = PKey::private_key_from_der(&key.serialize_der()).unwrap(); - let mut b = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); + let pkey = boring::pkey::PKey::private_key_from_der(&key.serialize_der()).unwrap(); + let mut b = + boring::ssl::SslAcceptor::mozilla_intermediate_v5(boring::ssl::SslMethod::tls()) + .unwrap(); b.set_certificate(&x).unwrap(); b.set_private_key(&pkey).unwrap(); let acc = std::sync::Arc::new(b.build()); @@ -764,8 +610,8 @@ mod tests { ) .await .expect("boots with >=1 good SNI"); - assert!(cache.acceptor_for("good.test").is_some()); - assert!(cache.acceptor_for("not.configured").is_none()); // splice-only + assert!(cache.fields_for("good.test").is_some()); + assert!(cache.fields_for("not.configured").is_none()); // splice-only } /// REALITY.5a Task 4: `prewarm` must cache a `ServerFlightTemplate` @@ -833,7 +679,7 @@ mod tests { ) .await .expect("empty server_names must boot with an empty cache, not refuse to start"); - assert!(cache.acceptor_for("anything.test").is_none()); + assert!(cache.fields_for("anything.test").is_none()); } /// A throwaway `ServerFlightTemplate` for tests that only care about @@ -860,15 +706,10 @@ mod tests { } } - /// Build a throwaway forged acceptor (+ the `StolenFields`/ - /// `ServerFlightTemplate` it was forged/captured from) for tests that - /// only care about cache bookkeeping (insert/replace/evict), not TLS - /// behavior. - fn dummy_entry() -> ( - Arc, - Arc, - Arc, - ) { + /// Build a throwaway `StolenFields`/`ServerFlightTemplate` pair for tests + /// that only care about cache bookkeeping (insert/replace/evict), not the + /// content's actual shape. + fn dummy_entry() -> (Arc, Arc) { let fields = StolenFields { subject_cn: Some("dummy.test".to_owned()), dns_sans: vec!["dummy.test".to_owned()], @@ -881,22 +722,18 @@ mod tests { is_ca: false, aia_der: None, }; - let key = KeyPair::generate().unwrap(); - let acceptor = Arc::new(build_forged_acceptor(&fields, &key).unwrap()); - (acceptor, Arc::new(fields), Arc::new(dummy_template())) + (Arc::new(fields), Arc::new(dummy_template())) } /// A cache pre-seeded with a single entry for `name` stamped `fetched_at` /// — lets tests drive `apply_refresh` with an injected `now` instead of /// real time, and without any network access. fn cache_with_entry(name: &str, fetched_at: Instant) -> RealityCertCache { - let key = KeyPair::generate().unwrap(); - let (acceptor, fields, template) = dummy_entry(); + let (fields, template) = dummy_entry(); let mut entries = HashMap::new(); entries.insert( name.to_owned(), CacheEntry { - acceptor, fields, template, fetched_at, @@ -905,7 +742,6 @@ mod tests { RealityCertCache { entries: RwLock::new(entries), server_names: vec![name.to_owned()], - key, } } @@ -913,19 +749,19 @@ mod tests { fn apply_refresh_full_success_replaces_entry_and_stamps_now() { let t0 = Instant::now(); let cache = cache_with_entry("a.test", t0); - let (new_acc, new_fields, new_template) = dummy_entry(); + let (new_fields, new_template) = dummy_entry(); let later = t0 + Duration::from_secs(10); let outcome = cache.apply_refresh( "a.test", - Some((Arc::clone(&new_acc), new_fields, new_template)), + Some((new_fields, new_template)), later, Duration::from_secs(3600), ); assert_eq!(outcome, RefreshOutcome::Refreshed); assert!( - cache.acceptor_for("a.test").is_some(), + cache.fields_for("a.test").is_some(), "replaced entry must still be served" ); // Stamped at `later`: a failure just 1s after that must NOT be stale @@ -950,8 +786,8 @@ mod tests { assert_eq!(outcome, RefreshOutcome::KeptStale); assert!( - cache.acceptor_for("a.test").is_some(), - "last-good acceptor must still be served while within max_stale" + cache.fields_for("a.test").is_some(), + "last-good entry must still be served while within max_stale" ); } @@ -966,7 +802,7 @@ mod tests { assert_eq!(outcome, RefreshOutcome::Evicted); assert!( - cache.acceptor_for("a.test").is_none(), + cache.fields_for("a.test").is_none(), "stale entry must be evicted ⇒ splice-only" ); } @@ -1038,74 +874,6 @@ mod tests { ); } - /// REALITY.4b: `build_forged_acceptor_with_pkcs8`'s presented leaf's - /// public key MUST equal `derive_cert_key(shared).public_sec1` for the - /// SAME `shared` the pkcs8 key was derived from — this is the exact - /// property the client (Task 2) pins against, so it is the gate for the - /// whole per-connection re-forge mechanism, not merely a sanity check. - #[tokio::test] - async fn build_forged_acceptor_with_pkcs8_leaf_pubkey_matches_derived_key() { - let fields = StolenFields { - subject_cn: Some("bind.test".to_owned()), - dns_sans: vec!["bind.test".to_owned()], - ip_sans: Vec::new(), - not_before: time::macros::datetime!(2025-01-01 0:00 UTC), - not_after: time::macros::datetime!(2027-01-01 0:00 UTC), - serial: vec![0x42], - key_usages: vec![rcgen::KeyUsagePurpose::DigitalSignature], - eku: vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth], - is_ca: false, - aia_der: None, - }; - let shared = [0x77u8; 32]; - let dk = yip_utls::auth::derive_cert_key(&shared); - - let acceptor = build_forged_acceptor_with_pkcs8(&fields, &dk.pkcs8_der) - .expect("build acceptor from derived pkcs8 key"); - - // Extract the leaf the acceptor actually presents, over a real TLS - // handshake — not just re-deriving it locally — so this proves what - // a real client sees, not just what we intended to build. - let acceptor = Arc::new(acceptor); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let acc = Arc::clone(&acceptor); - tokio::spawn(async move { - if let Ok((tcp, _)) = listener.accept().await { - let _ = tokio_boring::accept(&acc, tcp).await; - } - }); - - let tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); - let mut b = boring::ssl::SslConnector::builder(boring::ssl::SslMethod::tls()).unwrap(); - b.set_verify(boring::ssl::SslVerifyMode::NONE); - let cfg = b.build().configure().unwrap(); - let stream = tokio_boring::connect(cfg, "bind.test", tcp) - .await - .expect("handshake against the per-connection acceptor must complete"); - let leaf = stream - .ssl() - .peer_certificate() - .expect("server presents a leaf cert"); - let leaf_pubkey_der = leaf.public_key().unwrap().public_key_to_der().unwrap(); - - // boring's `public_key_to_der` yields a SubjectPublicKeyInfo DER, not - // the raw SEC1 point `derive_cert_key` returns — parse it back with - // `p256` (already a dependency of `yip_utls::auth`, transitively - // available here) and compare the uncompressed SEC1 encodings, which - // is the representation-independent way to assert "same public key". - use p256::ecdsa::VerifyingKey; - use p256::pkcs8::DecodePublicKey; - let leaf_vk = VerifyingKey::from_public_key_der(&leaf_pubkey_der) - .expect("leaf pubkey is a valid P-256 SPKI"); - let leaf_sec1 = leaf_vk.to_encoded_point(false).as_bytes().to_vec(); - - assert_eq!( - leaf_sec1, dk.public_sec1, - "the acceptor's presented leaf public key must equal derive_cert_key(shared).public_sec1" - ); - } - /// A dest leaf with no AIA extension must extract to `aia_der: None` — /// AIA is optional per RFC 5280, and its absence must not be treated as /// an extraction failure. diff --git a/bin/yip-rendezvous/src/reality_io.rs b/bin/yip-rendezvous/src/reality_io.rs index 224c85d..ae3676a 100644 --- a/bin/yip-rendezvous/src/reality_io.rs +++ b/bin/yip-rendezvous/src/reality_io.rs @@ -1,35 +1,41 @@ //! Async I/O plumbing for the REALITY TLS front (REALITY.1 Task 2). //! //! The front must read the raw TLS `ClientHello` off the socket *before* -//! terminating TLS (to run REALITY auth on it), then hand the connection to -//! the TLS acceptor as if nothing had been read. Two primitives make that -//! possible: [`read_first_tls_record`] pulls the first TLS record off the -//! wire without interpreting it as TLS, and [`PrefixedStream`] replays an -//! already-consumed byte prefix so `tokio_boring::accept` can "re-read" the -//! `ClientHello` it needs from a socket that has already had it drained. -//! Wired into the async TLS front by `tls_front::run_reality_conn` -//! (REALITY.1 Task 3). +//! terminating TLS (to run REALITY auth on it). [`read_first_tls_record`] +//! pulls the first TLS record off the wire without interpreting it as TLS. +//! +//! [`PrefixedStream`] (test-only, `#[cfg(test)]`) replays an already-consumed +//! byte prefix so a TLS acceptor can "re-read" a `ClientHello` that was +//! already pulled off the socket. `run_reality_conn`'s authed path no longer +//! needs this at runtime (REALITY.5d): it hands the parsed `ClientHello` +//! bytes straight to `yip_utls::server::emit_server_hello` instead of +//! replaying them onto the socket for a generic TLS acceptor to re-parse — +//! kept here only as a test double for this module's own read/replay tests. +#[cfg(test)] use std::pin::Pin; +#[cfg(test)] use std::task::{Context, Poll}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, ReadBuf}; +use tokio::io::AsyncReadExt; use tokio::net::TcpStream; /// Wraps `inner` so a previously-consumed byte `prefix` is replayed on read -/// before delegating to `inner`. Lets a TLS acceptor "re-read" a -/// `ClientHello` that was already pulled off the socket for REALITY auth -/// inspection: `PrefixedStream::new(record_bytes, tcp)` handed to -/// `tokio_boring::accept` makes BoringSSL see the ClientHello followed -/// seamlessly by the rest of the live connection. +/// before delegating to `inner`. Test-only (see module docs): production +/// code used this to let a TLS acceptor "re-read" an already-drained +/// `ClientHello`, but REALITY.5d's hand-rolled flow no longer replays onto +/// the socket at all — retained here purely to exercise +/// [`read_first_tls_record`]'s replay-shaped output in this module's tests. /// /// `AsyncWrite` (and flush/shutdown) delegate straight to `inner` — /// `prefix` only affects reads. +#[cfg(test)] pub struct PrefixedStream { prefix: Vec, pos: usize, inner: S, } +#[cfg(test)] impl PrefixedStream { /// Wrap `inner`, replaying `prefix` first on every `AsyncRead` before /// falling through to `inner`. @@ -42,7 +48,8 @@ impl PrefixedStream { } } -impl AsyncRead for PrefixedStream { +#[cfg(test)] +impl tokio::io::AsyncRead for PrefixedStream { /// While the buffered `prefix` isn't fully drained, copy from it into /// `buf` (respecting `buf.remaining()`) and return without touching /// `inner` — even if that only partially fills `buf`. Once the prefix is @@ -50,7 +57,7 @@ impl AsyncRead for PrefixedStream { fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, + buf: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { let this = self.get_mut(); if this.pos < this.prefix.len() { @@ -64,7 +71,8 @@ impl AsyncRead for PrefixedStream { } } -impl AsyncWrite for PrefixedStream { +#[cfg(test)] +impl tokio::io::AsyncWrite for PrefixedStream { fn poll_write( self: Pin<&mut Self>, cx: &mut Context<'_>, diff --git a/bin/yip-rendezvous/src/tls_front.rs b/bin/yip-rendezvous/src/tls_front.rs index 466c9e0..aaa278f 100644 --- a/bin/yip-rendezvous/src/tls_front.rs +++ b/bin/yip-rendezvous/src/tls_front.rs @@ -8,12 +8,14 @@ use std::time::{Duration, Instant}; use boring::error::ErrorStack; use boring::pkey::PKey; use boring::ssl::{SslAcceptor, SslFiletype, SslMethod}; +use p256::pkcs8::DecodePrivateKey as _; use tokio::io::AsyncWriteExt; use tokio::net::TcpStream; use tokio::sync::{Mutex, Semaphore}; use yip_rendezvous::RendezvousServer; -use crate::reality_io::{read_first_tls_record, FirstRecord, PrefixedStream}; +use crate::reality::ClientHelloInfo; +use crate::reality_io::{read_first_tls_record, FirstRecord}; /// Upper bound on how long a TLS handshake may take before we give up on the /// connection. Without this, a client that sends a ClientHello and then @@ -57,8 +59,10 @@ pub struct RealityCfg { pub short_ids: Vec<[u8; 8]>, /// Allowed SNIs for the authenticated check; empty accepts any SNI. pub server_names: Vec, - /// Per-SNI forged acceptors (REALITY.3 §1). `None` from `acceptor_for` - /// ⇒ splice-only for that SNI. + /// Per-SNI stolen-cert fields + captured flight template (REALITY.3 §1 / + /// REALITY.5a). `None` from `fields_for`/`template_for` ⇒ splice-only for + /// that SNI; otherwise `run_reality_conn`'s authed branch forges a leaf + /// and serves the hand-rolled flight per connection (REALITY.5d). pub certs: Arc, /// Anti-replay dedup on the auth seal (REALITY.3 §2). pub replay: Arc, @@ -178,6 +182,40 @@ pub async fn run_tls_front( } } +/// Which client X25519 public key feeds the server-side TLS DH, by negotiated +/// group: for X25519MLKEM768 (4588) it is the x25519 bundled in the client's +/// 4588 key_share entry (`key_share_mlkem_x25519`); for X25519 (29) it is the +/// standalone `0x001d` entry (`key_share_x25519`). Any other group is +/// unsupported here (P256/P384 + HelloRetryRequest is #84) → `None` → splice. +fn select_client_x25519(group: u16, info: &ClientHelloInfo) -> Option<[u8; 32]> { + match group { + 4588 => info.key_share_mlkem_x25519, + 29 => info.key_share_x25519, + _ => None, + } +} + +/// An OS-CSPRNG-backed [`yip_utls::hello::RandomSource`] for +/// `emit_server_hello` (mirrors yip_utls's own private `OsRng`: a `getrandom` +/// bridge that latches the first error so the caller fail-closes instead of +/// emitting predictable bytes). NEVER seed this — a predictable rng here +/// makes the ML-KEM encapsulation predictable. +#[derive(Default)] +struct OsRandomSource { + error: bool, +} + +impl yip_utls::hello::RandomSource for OsRandomSource { + fn fill(&mut self, buf: &mut [u8]) { + if self.error { + return; + } + if getrandom::getrandom(buf).is_err() { + self.error = true; + } + } +} + /// The REALITY branch of the per-connection task (`cfg.reality` is `Some`, /// checked by the caller): peek the raw `ClientHello` before terminating /// TLS, decide REALITY auth fully before acting, then either hand the @@ -250,42 +288,140 @@ async fn run_reality_conn(mut tcp: TcpStream, cfg: &Arc) { )?; let seal = <[u8; 32]>::try_from(info.legacy_session_id.as_slice()).ok()?; let fields = r.certs.fields_for(sni); - Some((sni.to_owned(), ts_min, seal, shared, fields)) + Some((sni.to_owned(), ts_min, seal, shared, fields, info.clone())) }); - if let Some((sni, ts_min, seal, shared, Some(fields))) = decision { + if let Some((sni, ts_min, seal, shared, Some(fields), info)) = decision { match decide_authed(&r.replay, seal, ts_min, now_min, true) { Decision::Accept => { - // The relay ALWAYS binds: re-forge the leaf per-connection, - // signed by the key derived from THIS connection's `shared` - // (REALITY.4b) — not the cache's fixed ephemeral key — so the - // presented leaf proves possession of `reality_priv` for the - // client (Task 2) to pin against. + // --- Pre-write: any failure SPLICES (connection still pristine). --- + let Some(template) = r.certs.template_for(&sni) else { + splice_to_dest(tcp, r.dest, &rec).await; + return; + }; + + let group = template.server_hello.key_share_group; + let Some(client_x25519) = select_client_x25519(group, &info) else { + splice_to_dest(tcp, r.dest, &rec).await; + return; + }; + + // The relay ALWAYS binds: derive the leaf-signing key from + // THIS connection's `shared` (REALITY.4b) — not a fixed + // cache key — so the presented leaf proves possession of + // `reality_priv` for the client (Task 2) to pin against. let dk = yip_utls::auth::derive_cert_key(&shared); - let acceptor = match crate::reality_cert::build_forged_acceptor_with_pkcs8( - &fields, - &dk.pkcs8_der, + + // Forge the natural leaf (no exact-length padding); SPKI = dk. + let leaf_keypair = match rcgen::KeyPair::try_from(dk.pkcs8_der.as_slice()) { + Ok(k) => k, + Err(e) => { + eprintln!("tls-front: reality leaf keypair load failed ({sni}): {e}"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + let forged_leaf_der = match crate::reality_cert::forge_leaf(&fields, &leaf_keypair) + { + Ok(cert) => cert.der().as_ref().to_vec(), + Err(e) => { + eprintln!("tls-front: reality forge_leaf failed ({sni}): {e}"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + + let ch_msg = match rec.get(5..) { + Some(m) => m, + None => { + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + + let mut rng = OsRandomSource::default(); + let (sh_msg, keys) = match yip_utls::server::emit_server_hello( + &template.server_hello, + ch_msg, + &info.legacy_session_id, + &client_x25519, + info.key_share_mlkem_ek.as_deref(), + &mut rng, ) { - Ok(acc) => acc, + Ok(v) => v, Err(e) => { - eprintln!("tls-front: reality per-connection re-forge failed ({sni}): {e}"); + eprintln!("tls-front: reality emit_server_hello failed ({sni}): {e}"); splice_to_dest(tcp, r.dest, &rec).await; return; } }; - let stream = PrefixedStream::new(rec, tcp); - match tokio::time::timeout( + if rng.error { + // getrandom failed → fail closed (still pre-write). + eprintln!("tls-front: reality OS rng failed ({sni})"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + + // --- Commit point: write the ServerHello. From here, DROP on error. --- + let mut transcript_ch_sh = Vec::with_capacity(ch_msg.len() + sh_msg.len()); + transcript_ch_sh.extend_from_slice(ch_msg); + transcript_ch_sh.extend_from_slice(&sh_msg); + + let mut sh_record = Vec::with_capacity(5 + sh_msg.len()); + sh_record.push(0x16); // handshake + sh_record.extend_from_slice(&[0x03, 0x03]); // legacy record version + // Still pre-write (nothing on the wire yet) → splice. A + // ServerHello never exceeds u16, so this is unreachable in + // practice, but it keeps the fail-safe boundary honest. + let sh_len = match u16::try_from(sh_msg.len()) { + Ok(l) => l, + Err(_) => { + eprintln!("tls-front: reality ServerHello too large ({sni})"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + sh_record.extend_from_slice(&sh_len.to_be_bytes()); + sh_record.extend_from_slice(&sh_msg); + if let Err(e) = tcp.write_all(&sh_record).await { + eprintln!("tls-front: reality ServerHello write failed ({sni}): {e}"); + return; + } + + let signing_key = match p256::ecdsa::SigningKey::from_pkcs8_der(&dk.pkcs8_der) { + Ok(k) => k, + Err(e) => { + eprintln!("tls-front: reality signing key load failed ({sni}): {e}"); + return; + } + }; + + let reality_stream = match tokio::time::timeout( HANDSHAKE_TIMEOUT, - tokio_boring::accept(&acceptor, stream), + yip_utls::stream::serve( + tcp, + &keys, + &template.encrypted_flight, + &template.cert_chain, + &forged_leaf_der, + &signing_key, + &transcript_ch_sh, + ), ) .await { - Ok(Ok(s)) => super::conn::handle_connection(s, Arc::clone(cfg)).await, + Ok(Ok(s)) => s, Ok(Err(e)) => { - eprintln!("tls-front: reality forged handshake failed ({sni}): {e}") + eprintln!("tls-front: reality serve failed ({sni}): {e}"); + return; } - Err(_) => eprintln!("tls-front: reality forged handshake timed out"), - } + Err(_) => { + eprintln!("tls-front: reality serve timed out ({sni})"); + return; + } + }; + + super::conn::handle_connection(reality_stream, Arc::clone(cfg)).await; return; } Decision::Splice => {} // fall through to splice @@ -505,6 +641,65 @@ mod tests { rec } + /// A self-signed cert/key PEM pair for `relay.test`, like + /// `write_self_signed`, but additionally carrying the near-universal + /// server-leaf extensions (`KeyUsage: + /// digitalSignature|keyEncipherment`, `ExtendedKeyUsage: serverAuth`, + /// explicit `BasicConstraints: CA:FALSE`) that + /// `reality_cert::extract_fields` always copies into a forged leaf + /// regardless of whether the SPECIFIC dest cert actually has them (its + /// own doc comment: "a server leaf's usages are near-universal" — + /// best-effort mimicry, not conditioned on the real cert's content). + /// `write_self_signed`'s bare `rcgen::generate_simple_self_signed` cert + /// omits all three (rcgen's own `CertificateParams::default()`: + /// `is_ca: NoCa`, empty `key_usages`/`extended_key_usages`) — fine for + /// the plain-handshake tests that share it, but a REAL destination + /// site's CA-issued leaf virtually always carries these three + /// extensions, so using the bare cert as `spawn_cert_source`'s "dest" + /// understates the captured leaf's size relative to what `forge_leaf` + /// always re-adds, artificially starving `RealityCertCache::prewarm`'s + /// captured `record_lengths` slack for the REALITY.5d authed + /// end-to-end test below. A test-fixture realism fix, not a production + /// behavior change — `forge_leaf`/`extract_fields` are unmodified. + fn write_realistic_leaf(dir: &std::path::Path) -> (String, String) { + let mut params = rcgen::CertificateParams::new(vec!["relay.test".to_owned()]).unwrap(); + params.key_usages = vec![ + rcgen::KeyUsagePurpose::DigitalSignature, + rcgen::KeyUsagePurpose::KeyEncipherment, + ]; + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + params.is_ca = rcgen::IsCa::ExplicitNoCa; + // A real dest leaf virtually always carries a Certificate Transparency + // SCT list extension (RFC 6962, OID 1.3.6.1.4.1.11129.2.4.2) — + // `StolenFields` deliberately does NOT copy SCTs into the forged leaf + // (they are bound to the real CT-log keys and unreproducible by an + // ephemeral-key forgery — see `StolenFields`'s doc comment), so a real + // forged flight is reliably SMALLER than the real dest flight it must + // fit within (`emit_server_flight`'s captured `record_lengths` + // budget). Without this, this synthetic test dest's from-scratch + // self-signed cert has near-zero slack over the forged leaf (both are + // "the same fields, independently ECDSA-self-signed" — the only + // delta is a few bytes of DER signature-length jitter), making the + // budget check flaky. Filler bytes, not a real SCT — nothing here + // ever parses this extension's content. + params + .custom_extensions + .push(rcgen::CustomExtension::from_oid_content( + &[1, 3, 6, 1, 4, 1, 11129, 2, 4, 2], + vec![0u8; 200], + )); + let key = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&key).unwrap(); + let cert_path = dir.join("cert.pem"); + let key_path = dir.join("key.pem"); + std::fs::write(&cert_path, cert.pem()).unwrap(); + std::fs::write(&key_path, key.serialize_pem()).unwrap(); + ( + cert_path.to_str().unwrap().to_owned(), + key_path.to_str().unwrap().to_owned(), + ) + } + /// A local TLS server (self-signed) that answers any SNI — a stand-in /// `dest` purely for `RealityCertCache::prewarm` to fetch a leaf from. /// Separate from `spawn_dest_banner` (the splice target), which is @@ -512,11 +707,11 @@ mod tests { /// /// Multiple `reality_*` tests call `start_reality_front` (and so this /// helper) concurrently; `unique_tmp_dir` keeps each call's cert/key - /// files distinct so they can't race `write_self_signed` against the + /// files distinct so they can't race `write_realistic_leaf` against the /// same files (mismatched-pair `KEY_VALUES_MISMATCH` flakes). async fn spawn_cert_source() -> SocketAddr { let dir = unique_tmp_dir("reality-certsrc"); - let (cert, key) = write_self_signed(&dir); + let (cert, key) = write_realistic_leaf(&dir); let acceptor = Arc::new(build_acceptor(&cert, &key).unwrap()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -690,6 +885,38 @@ mod tests { } } + // ---- select_client_x25519 (pure decision) ---- + + fn info_with_shares(x0: Option<[u8; 32]>, x4588: Option<[u8; 32]>) -> ClientHelloInfo { + ClientHelloInfo { + sni: Some("example.com".to_string()), + client_random: [0u8; 32], + legacy_session_id: vec![0u8; 32], + key_share_x25519: x0, + key_share_mlkem_ek: None, + key_share_mlkem_x25519: x4588, + } + } + + #[test] + fn select_client_x25519_by_group() { + let info = info_with_shares(Some([1u8; 32]), Some([2u8; 32])); + // group 4588 → the 4588-entry tail; group 29 → the 0x001d entry. + assert_eq!(select_client_x25519(4588, &info), Some([2u8; 32])); + assert_eq!(select_client_x25519(29, &info), Some([1u8; 32])); + // unsupported group → None (→ splice). + assert_eq!(select_client_x25519(23, &info), None); + // missing share for the selected group → None. + assert_eq!( + select_client_x25519(4588, &info_with_shares(Some([1u8; 32]), None)), + None + ); + assert_eq!( + select_client_x25519(29, &info_with_shares(None, Some([2u8; 32]))), + None + ); + } + // ---- decide_authed (pure routing decision) ---- /// A seal that is `Fresh` the first time routes to `Accept`; the second, From 4d70812d0a03fb8c11fee6e7ec45e89f6d699f70 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 14:34:49 -0400 Subject: [PATCH 23/27] =?UTF-8?q?test(reality.5d):=20netns=20money=20test?= =?UTF-8?q?=20=E2=80=94=20verify=3Don=20client=20tunnels=20through=20the?= =?UTF-8?q?=20hand-rolled=20relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forks run-netns-reality-relay.sh into a focused run-netns-reality-5d.sh proving the REALITY.5d wiring end-to-end: a real yipd client dialing reality://...&verify=on completes the relay's hand-rolled server flight (tls_front::run_reality_conn's Decision::Accept branch — 5b emit_server_hello + 5c emit_server_flight/serve, replacing the old BoringSSL SslAcceptor), verifies the REALITY.4b binding against the forged leaf, and tunnels to a peer THROUGH the relay (relay-forwarded>0). A wrong-pbk client gets no tunnel: the relay's seal-open fails so it splices to --reality-dest pre-ServerHello-write, never entering the hand-rolled emit path. Ran under sudo twice, exit 0 both times: verify=on ping succeeded (relay-forwarded=2106), wrong-pbk ping failed with no retry-storm (1 verification attempt in ~16s). Wired into .github/workflows/integration.yml alongside the sibling run-netns-reality-relay.sh step, same job/binaries. --- .github/workflows/integration.yml | 31 ++ bin/yipd/tests/run-netns-reality-5d.sh | 475 +++++++++++++++++++++++++ 2 files changed, 506 insertions(+) create mode 100755 bin/yipd/tests/run-netns-reality-5d.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7117698..e830370 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -434,3 +434,34 @@ jobs: echo "::error::two UDP-blocked peers failed to tunnel via the REALITY relay under verify=on (ping failed, relay-forwarded stayed 0, a wrong pbk/relay key was wrongly accepted, or the client retry-stormed on a verify failure) — REALITY.4a/4b relay-dial regression" exit 1 fi + + - name: Run the REALITY.5d hand-rolled-flight verify=on money test under sudo + # REALITY.5d's headline money test: same topology and assertions as + # the 4a/4b relay-over-REALITY test above (verify=on tunnel; + # wrong-pbk fail-closed), but proving the relay's AUTHED path now + # hand-rolls the entire server flight (`tls_front::run_reality_conn` + # `Decision::Accept` — REALITY.5b `emit_server_hello` + REALITY.5c + # `emit_server_flight`/`serve`) instead of driving a BoringSSL + # `SslAcceptor`. A passing verify=on ping here proves the hand-rolled + # flight completed AND the client's REALITY.4b binding check + # verified against the relay's forged leaf; the wrong-pbk variant + # proves the relay still splices to `--reality-dest` + # pre-ServerHello-write (never entering the hand-rolled emit path) + # when the seal fails to open. See run-netns-reality-5d.sh for the + # full topology and assertion set. Same binary requirements as the + # 4a/4b test above (release yipd, debug yip-rendezvous-bin). + run: | + sudo bash bin/yipd/tests/run-netns-reality-5d.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/relay-reality-5d-money-test.log + # Honesty guard, same reasoning as the oracles above: this job + # always runs as sudo and always builds both binaries, so a SKIP + # here means the harness itself is broken. + if grep -q "^SKIP run-netns-reality-5d" /tmp/relay-reality-5d-money-test.log; then + echo "::error::REALITY.5d money test skipped — expected root + built yipd/yip-rendezvous binaries in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/relay-reality-5d-money-test.log; then + echo "::error::verify=on client failed to tunnel via the REALITY relay's hand-rolled 5b+5c authed flight (ping failed, relay-forwarded stayed 0, or a wrong pbk was wrongly accepted) — REALITY.5d wiring regression" + exit 1 + fi diff --git a/bin/yipd/tests/run-netns-reality-5d.sh b/bin/yipd/tests/run-netns-reality-5d.sh new file mode 100755 index 0000000..4bfd3e1 --- /dev/null +++ b/bin/yipd/tests/run-netns-reality-5d.sh @@ -0,0 +1,475 @@ +#!/usr/bin/env bash +# REALITY.5d netns money test: two UDP-blocked yipd peers bring a tunnel up +# THROUGH a REALITY relay whose AUTHED path (`tls_front::run_reality_conn`'s +# `Decision::Accept` branch) now hand-rolls the entire server flight instead +# of driving a BoringSSL `SslAcceptor`: +# - forges a leaf certificate keyed on this connection's derived `shared` +# secret (REALITY.4b binding, unchanged from before 5d), +# - emits a byte-matching ServerHello (REALITY.5b `emit_server_hello`), +# - serves the encrypted EncryptedExtensions/Certificate/CertificateVerify/ +# Finished flight framed to the `dest`-captured record-length template +# (REALITY.5c `emit_server_flight`/`serve`). +# 5a/5b/5c only proved this in isolation (round-trip unit/property tests); +# 5d (this branch, tasks 1-3) wired it into the live relay in place of the +# old forged-cert BoringSSL acceptor, WITHOUT changing the client or the +# relay's external protocol behavior. This script is the end-to-end proof: +# a real yipd client with `verify=on` completes the hand-rolled handshake, +# verifies the 4b binding against the forged leaf, and tunnels to a peer +# THROUGH the relay — and a wrong-key client gets nothing (fail-closed +# splice to `dest`, no relayed bytes). +# +# Forked from the closest existing script, `run-netns-reality-relay.sh` +# (REALITY.4a/4b money test written against the *old* BoringSSL-backed authed +# path) — same netns/veth topology, same UDP-blocking, same relay-forwarded +# assertion, same pinned REALITY keypair derivation. That script continues to +# pass unmodified (Task 3 changed the authed path's *implementation*, not its +# externally observable behavior), so it already exercises the hand-rolled +# 5d flight transparently — this script is deliberately narrower: only the +# two REALITY.5d money assertions (verify=on tunnel works; wrong pbk fails +# closed), not the sibling's third wrong-relay-key variant (already covered +# there, not required here). +# +# REALITY_PUB is the X25519 public key matching the pinned REALITY_PRIV +# below. The relay derives its shared secret via +# `x25519_dalek::StaticSecret::from(priv_bytes)` (which CLAMPS the scalar), +# so the client must pin the public key derived the same way. Reused +# verbatim from `run-netns-reality-relay.sh` (same derivation, same pinned +# value, independently cross-checked there). +# +# Usage: run-netns-reality-5d.sh +# +# Topology: three netns, A / B / R (the REALITY relay): +# A --10.95.0.0/24-- R --10.96.0.0/24-- B +# Two point-to-point veth pairs (A<->R, B<->R); R does NOT forward IPv4 +# between them (relay_only mode never attempts a direct route anyway). A and +# B each list the OTHER by `public_key` ONLY (no endpoint). +# +# UDP is DROPped (iptables OUTPUT/INPUT) inside A's and B's netns before +# either daemon starts — belt-and-suspenders on top of relay_only mode +# already never emitting UDP on this path. +# +# `--reality-dest` requires a REAL TLS 1.3 server to steal a leaf +# certificate's fields from at startup (`RealityCertCache::prewarm` / +# `capture_dest_flight`) — an unreachable/closed dest makes the relay refuse +# to start entirely. DEST here is a local `openssl s_server -tls1_3` +# self-signed TLS listener inside R's own netns (loopback-only, never dialed +# by A/B). The client offers both X25519MLKEM768 (group 4588) and X25519 +# (group 29) key shares; openssl selects X25519 (group 29), so the captured +# template's `key_share_group` is 29 and the hand-rolled authed path keys +# group 29 too — fully supported by 5b/5c (`select_client_x25519`). +# +# Assert (non-zero exit on any failure): +# 1. money test (verify=on): ping A->B across the tunnel succeeds +# (generous budget: TLS handshake to the relay + Register + inner +# Noise-IK handshake, all serial), and the relay's stderr shows +# relay-forwarded= with N>0 — proving the hand-rolled 5b+5c flight +# completed AND the client's `verify=on` check passed end-to-end +# against the relay's forged, 4b-bound leaf. +# 2. wrong-pbk test: with A reconfigured to a bogus (all-zero) `pbk=`, A +# restarted, ping A->B FAILS within a bounded timeout — the relay's +# seal-open fails against the wrong pubkey, so it splices A's +# connection to DEST (pre-ServerHello-write splice boundary, Task 3) +# instead of ever entering the hand-rolled emit path, and no tunnel +# forms. +# +# Root-gated SKIP + trap-based cleanup, mirroring the sibling scripts. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +RDV="${2:?Usage: $0 }" + +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-reality-5d: needs root (netns + TUN)" + exit 0 +fi +for tool in openssl iptables ip; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "SKIP run-netns-reality-5d: required tool '$tool' not found" + exit 0 + fi +done + +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-reality-5d-test.XXXXXX)" + +NS_A="yip5dA" +NS_B="yip5dB" +NS_R="yip5dR" + +VETH_A_N="v5dA1"; VETH_A_R="v5dA0" # A<->R pair: A-side, R-side +VETH_B_N="v5dB1"; VETH_B_R="v5dB0" # B<->R pair: B-side, R-side + +IP_A="10.95.0.2" +IP_R_A="10.95.0.1" # R's address on A's subnet +IP_B="10.96.0.2" +IP_R_B="10.96.0.1" # R's address on B's subnet +PREFIX="24" + +PORT_A="51820" +PORT_B="51820" +RDV_UDP_PORT="51821" # bound by yip-rendezvous but never reachable/used — + # UDP is blocked in A/B and relay_only mode never + # tries it anyway. +RDV_TCP_PORT="8443" +DEST_PORT="9443" # the local openssl s_server standing in for + # --reality-dest's real upstream (loopback in R only) +TUN_DEV="yip0" + +# The pinned REALITY relay keypair (see header comment for the derivation; +# reused verbatim from run-netns-reality-relay.sh). +REALITY_PRIV="2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a" +REALITY_PUB="07aaff3e9fc167275544f4c3a6a17cd837f2ec6e78cd8a57b1e3dfb3cc035a76" +if [ "${#REALITY_PRIV}" -ne 64 ] || [ "${#REALITY_PUB}" -ne 64 ]; then + echo "[error] pinned REALITY_PRIV/REALITY_PUB must each be 64 hex chars" + exit 1 +fi +SHORT_ID="00112233445566ff" +if [ "${#SHORT_ID}" -ne 16 ]; then + echo "[error] pinned SHORT_ID must be 16 hex chars" + exit 1 +fi +SNI="www.microsoft.com" + +PID_A="" +PID_B="" +PID_RDV="" +PID_DEST="" + +cleanup() { + echo "[cleanup] killing daemons and removing namespaces" + [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true + [ -n "$PID_RDV" ] && kill "$PID_RDV" 2>/dev/null || true + [ -n "$PID_DEST" ] && kill "$PID_DEST" 2>/dev/null || true + sleep 0.2 + [ -n "$PID_A" ] && kill -9 "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill -9 "$PID_B" 2>/dev/null || true + [ -n "$PID_RDV" ] && kill -9 "$PID_RDV" 2>/dev/null || true + [ -n "$PID_DEST" ] && kill -9 "$PID_DEST" 2>/dev/null || true + ip netns del "$NS_A" 2>/dev/null || true + ip netns del "$NS_B" 2>/dev/null || true + ip netns del "$NS_R" 2>/dev/null || true + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT + +# ── 1. generate keypairs + a shared obf_psk ─────────────────────────────── +echo "[setup] generating keypairs + obf_psk" +GENKEY_A="$("$YIPD" --genkey)" +GENKEY_B="$("$YIPD" --genkey)" + +PRIV_A="$(echo "$GENKEY_A" | grep '^private=' | cut -d= -f2)" +PUB_A="$(echo "$GENKEY_A" | grep '^public=' | cut -d= -f2)" +PRIV_B="$(echo "$GENKEY_B" | grep '^private=' | cut -d= -f2)" +PUB_B="$(echo "$GENKEY_B" | grep '^public=' | cut -d= -f2)" +OBF_PSK="$(openssl rand -hex 32)" + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B" + +# ── 2. write config files (rendezvous=reality://, peers by public_key ONLY) ─ +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" + +write_cfg_a() { + local pbk="$1" + cat > "$CFG_A" < "$CFG_B" <R" +ip link add "$VETH_A_R" type veth peer name "$VETH_A_N" +ip link set "$VETH_A_N" netns "$NS_A" +ip link set "$VETH_A_R" netns "$NS_R" +ip netns exec "$NS_A" ip addr add "${IP_A}/${PREFIX}" dev "$VETH_A_N" +ip netns exec "$NS_A" ip link set "$VETH_A_N" up +ip netns exec "$NS_A" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_A}/${PREFIX}" dev "$VETH_A_R" +ip netns exec "$NS_R" ip link set "$VETH_A_R" up + +echo "[setup] wiring B<->R" +ip link add "$VETH_B_R" type veth peer name "$VETH_B_N" +ip link set "$VETH_B_N" netns "$NS_B" +ip link set "$VETH_B_R" netns "$NS_R" +ip netns exec "$NS_B" ip addr add "${IP_B}/${PREFIX}" dev "$VETH_B_N" +ip netns exec "$NS_B" ip link set "$VETH_B_N" up +ip netns exec "$NS_B" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_B}/${PREFIX}" dev "$VETH_B_R" +ip netns exec "$NS_R" ip link set "$VETH_B_R" up +ip netns exec "$NS_R" ip link set lo up + +# Belt-and-suspenders: R does not forward IPv4 between A's and B's subnets +# (relay_only mode never attempts a direct route anyway — see header). +ip netns exec "$NS_R" sysctl -q -w net.ipv4.ip_forward=0 +ip netns exec "$NS_R" sysctl -q -w net.ipv4.conf.all.forwarding=0 + +# ── 4. block UDP in A's and B's netns — ONLY TCP/TLS can carry traffic ──── +echo "[setup] DROPping all UDP in A and B (proves relay-over-REALITY is the carrier)" +ip netns exec "$NS_A" iptables -A OUTPUT -p udp -j DROP +ip netns exec "$NS_A" iptables -A INPUT -p udp -j DROP +ip netns exec "$NS_B" iptables -A OUTPUT -p udp -j DROP +ip netns exec "$NS_B" iptables -A INPUT -p udp -j DROP + +# ── 5. start the local DEST TLS 1.3 server (self-signed, loopback in R only) ─ +# `--reality-dest` needs a real TLS 1.3 server to steal leaf-certificate +# fields AND the server-flight record-length template from at startup +# (`capture_dest_flight`); cert verification is disabled on that fetch, so a +# throwaway self-signed cert is fine. This is NEVER dialed by A/B (they only +# ever reach the relay's REALITY front) — it is purely so the relay itself +# can boot with `--reality-server-name` set and its `ServerFlightTemplate` +# pre-warmed. `-tls1_3` pins the dest to TLS 1.3 (required — 5b/5c only +# handle a TLS 1.3 server flight); openssl selects X25519 (group 29) from the +# client's offered groups, matching what `select_client_x25519` expects. +echo "[setup] generating self-signed cert for the local REALITY dest stand-in" +DEST_CERT="$TMPDIR_TEST/dest-cert.pem" +DEST_KEY="$TMPDIR_TEST/dest-key.pem" +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$DEST_KEY" -out "$DEST_CERT" \ + -days 1 -subj '/CN=dest.test' >"$TMPDIR_TEST/openssl-dest-req.log" 2>&1 + +LOG_DEST="$TMPDIR_TEST/dest.log" +echo "[start] starting local DEST TLS 1.3 server in R (127.0.0.1:${DEST_PORT})" +ip netns exec "$NS_R" openssl s_server \ + -accept "127.0.0.1:${DEST_PORT}" \ + -cert "$DEST_CERT" -key "$DEST_KEY" \ + -tls1_3 \ + -naccept 50 -quiet \ + >"$LOG_DEST" 2>&1 < /dev/null & +PID_DEST=$! + +echo "[wait] waiting for DEST TLS server to accept connections" +DEST_WAIT=0 +while ! ip netns exec "$NS_R" bash -c "exec 3<>/dev/tcp/127.0.0.1/${DEST_PORT}" 2>/dev/null; do + DEST_WAIT=$((DEST_WAIT + 1)) + if [ "$DEST_WAIT" -ge 100 ]; then + echo "[error] DEST TLS server never started listening" + cat "$LOG_DEST" || true + exit 1 + fi + sleep 0.1 +done + +# ── 6. start yip-rendezvous in R, REALITY front on :8443 ────────────────── +LOG_RDV="$TMPDIR_TEST/rdv.log" +echo "[start] starting yip-rendezvous in R (udp:0.0.0.0:${RDV_UDP_PORT} [unused], reality-tls:0.0.0.0:${RDV_TCP_PORT})" +ip netns exec "$NS_R" "$RDV" "0.0.0.0:${RDV_UDP_PORT}" \ + --listen-tcp "0.0.0.0:${RDV_TCP_PORT}" \ + --obf-psk "$OBF_PSK" \ + --reality-dest "127.0.0.1:${DEST_PORT}" \ + --reality-private-key "$REALITY_PRIV" \ + --reality-short-id "$SHORT_ID" \ + --reality-server-name "$SNI" \ + >"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.5 +if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous (REALITY front) died at startup — likely a prewarm/cert-fetch failure" + cat "$LOG_RDV" || true + exit 1 +fi + +# ── 7. start yipd in A and B ──────────────────────────────────────────────── +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +dump_logs() { + echo "=== dest log ===" + cat "$LOG_DEST" || true + echo "=== rendezvous log ===" + cat "$LOG_RDV" || true + echo "=== yip5dA log ===" + cat "$LOG_A" || true + echo "=== yip5dB log ===" + cat "$LOG_B" || true +} + +echo "[start] starting yip5dA" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yip5dB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 8. wait for TUN devices to appear in A and B ────────────────────────── +TUN_WAIT=30 +INTERVAL=0.25 + +echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)" +elapsed=0 +while true; do + A_UP=0; B_UP=0 + ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true + ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then + echo "[wait] both TUN devices are up" + break + fi + + if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yip5dA daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yip5dB daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous died unexpectedly"; dump_logs; exit 1 + fi + + elapsed=$(awk "BEGIN {print $elapsed + $INTERVAL}") + if awk "BEGIN {exit ($elapsed >= $TUN_WAIT) ? 0 : 1}"; then + echo "[error] timed out waiting for TUN devices"; dump_logs; exit 1 + fi + sleep "$INTERVAL" +done + +# ── 9. assign each TUN its node_addr/128 + the mesh-prefix route ───────── +echo "[setup] assigning node_addr/128 + fd00::/8 route on each TUN" +assign_mesh() { + local ns="$1" addr="$2" + ip netns exec "$ns" ip -6 addr add "${addr}/128" dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip -6 route add fd00::/8 dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$ns" ip link set "$TUN_DEV" up +} +assign_mesh "$NS_A" "$ADDR_A" +assign_mesh "$NS_B" "$ADDR_B" + +echo "[check] interface state in yip5dA:" +ip netns exec "$NS_A" ip -6 addr show "$TUN_DEV" +echo "[check] interface state in yip5dB:" +ip netns exec "$NS_B" ip -6 addr show "$TUN_DEV" + +# ── 10. MONEY TEST 1 (verify=on): ping A->B, tolerating warm-up loss while +# both serial handshakes complete (outer REALITY TLS to the relay + Register, +# then inner Noise-IK) — both A and B dial with `verify=on` explicit in their +# `rendezvous=reality://...` URL, so a successful ping here proves the +# relay's HAND-ROLLED server flight (5b ServerHello + 5c encrypted flight, +# Task 3's `run_reality_conn` `Decision::Accept` branch) completed AND the +# client's `verify=on` check (REALITY.4b binding, Task 2) passed end-to-end +# against the forged leaf ─────────────────────────────────────────────────── +echo "[test] pinging ${ADDR_B} from yip5dA (verify=on; expect REALITY+Register+Noise-IK warm-up, then success)" +set +e +ip netns exec "$NS_A" ping -6 -c 30 -W 2 "$ADDR_B" +PING_STATUS=$? +set -e +if [ "$PING_STATUS" -ne 0 ]; then + echo "[FAIL] ping A->B did not succeed over the hand-rolled REALITY relay flight (exit $PING_STATUS)" + dump_logs + exit 1 +fi +echo "[PASS] ping A->B succeeded over the REALITY relay with verify=on (hand-rolled 5b+5c flight)" + +# ── 11. assert the relay actually carried it: relay-forwarded=, N>0 ──── +# Give the server one more sweep interval to emit a final relay-forwarded +# line reflecting the traffic that just flowed. +sleep 5.5 +FINAL_COUNT="$(grep -oE 'relay-forwarded=[0-9]+' "$LOG_RDV" | tail -1 | cut -d= -f2)" +echo "[check] server's final relay-forwarded count: ${FINAL_COUNT:-}" +if [ -z "${FINAL_COUNT:-}" ] || [ "$FINAL_COUNT" -eq 0 ]; then + echo "[FAIL] relay-forwarded count is 0 (or missing) — traffic did not go through the REALITY relay" + dump_logs + exit 1 +fi +echo "[PASS] relay-forwarded=${FINAL_COUNT} (>0): the REALITY relay carried the traffic through the hand-rolled authed path" +echo "[PASS] netns REALITY.5d money test PASSED: a real verify=on yipd client completed the hand-rolled 5b+5c server flight, verified the 4b binding, and tunneled through the relay (relay-forwarded=${FINAL_COUNT})" + +# ── 12. MONEY TEST 2 (negative): wrong pbk (client-side) + verify=on -> NO +# tunnel, fail-closed ─────────────────────────────────────────────────────── +# Rewrite A's config with a bogus (all-zero) pbk (verify=on stays explicit, +# inherited from write_cfg_a) and restart A. The relay's seal-open must fail +# against the wrong pubkey (reality_auth_recover_shared returns None), so +# `decide_authed` never reaches `Decision::Accept` and the connection is +# spliced to DEST BEFORE any ServerHello write (the pre-write splice +# boundary Task 3 preserves) — the hand-rolled emit path is never entered at +# all. No Register is ever accepted, so the inner Noise-IK handshake never +# gets a chance to run. Separately, A's OWN `verify=on` check also fails +# closed against the spliced DEST's real (unrelated) leaf certificate — +# `yip_utls::Error::RealityVerify` — so A logs a verification failure and +# backs off on the long jittered `verify_fail_backoff` (not the fast +# reconnect ladder): the ping must fail within a bounded timeout AND A must +# not retry-storm. +echo "[test] wrong-pubkey negative test (verify=on): restarting yip5dA with an all-zero pbk" +kill "$PID_A" 2>/dev/null || true +wait "$PID_A" 2>/dev/null || true +PID_A="" + +ZERO_PUB="0000000000000000000000000000000000000000000000000000000000000000" +if [ "${#ZERO_PUB}" -ne 64 ]; then + echo "[error] ZERO_PUB must be 64 hex chars" + exit 1 +fi +write_cfg_a "$ZERO_PUB" + +LOG_A_WRONG="$TMPDIR_TEST/yipA-wrongpbk.log" +echo "[start] restarting yip5dA with rendezvous pbk=${ZERO_PUB}&verify=on" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A_WRONG" 2>&1 & +PID_A=$! +sleep 1 + +echo "[test] pinging ${ADDR_B} from yip5dA (expect FAILURE: wrong pbk never authenticates)" +set +e +timeout 15 ip netns exec "$NS_A" ping -6 -c 10 -W 2 "$ADDR_B" +WRONG_PING_STATUS=$? +set -e +if [ "$WRONG_PING_STATUS" -eq 0 ]; then + echo "[FAIL] ping A->B succeeded with a WRONG pbk — the relay accepted an unauthenticated connection as a real relay client" + echo "=== yip5dA (wrong pbk) log ===" + cat "$LOG_A_WRONG" || true + dump_logs + exit 1 +fi +echo "[PASS] ping A->B failed as expected with a wrong pbk (relay spliced to DEST pre-ServerHello, no hand-rolled flight ever emitted, no tunnel formed)" + +# Assert no retry-storm: with verify=on, a RealityVerify failure backs off on +# `verify_fail_backoff` (300s+ jitter base) — vastly longer than this test's +# ~16s window (1s startup sleep + 15s bounded ping) — so A must have logged +# only a handful of relay-verification attempts, not the dozens a fast +# INITIAL_BACKOFF_MS/MAX_BACKOFF_MS (100ms..5s) ladder would produce in the +# same window. +WRONG_PBK_ATTEMPTS="$(grep -c 'relay verification failed' "$LOG_A_WRONG" || true)" +echo "[check] yip5dA (wrong pbk) relay-verification-failed count: ${WRONG_PBK_ATTEMPTS}" +if [ "$WRONG_PBK_ATTEMPTS" -lt 1 ]; then + echo "[FAIL] expected at least one logged RealityVerify failure (wrong pbk) — the client never even attempted verification" + cat "$LOG_A_WRONG" || true + exit 1 +fi +if [ "$WRONG_PBK_ATTEMPTS" -gt 3 ]; then + echo "[FAIL] yip5dA retry-stormed on a verify failure: ${WRONG_PBK_ATTEMPTS} attempts in ~16s (expected <=3 — verify_fail_backoff is 300s+, not the fast ladder)" + cat "$LOG_A_WRONG" || true + exit 1 +fi +echo "[PASS] yip5dA did not retry-storm on the wrong-pbk verify failure (${WRONG_PBK_ATTEMPTS} attempt(s) in ~16s)" + +echo "[PASS] netns REALITY.5d wrong-pubkey negative test PASSED (verify=on fail-closed against the hand-rolled authed path, no retry-storm)" From 3eb76cecfc92851e6c26db040afcb2ea93b64818 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 20:28:23 -0400 Subject: [PATCH 24/27] refactor(yip-utls): Error::MessageTooLarge + u24_len helper (5c follow-up) emit_server_flight's handshake-message/CertificateEntry/certificate_list u24 length guards reused handshake::Error::RecordTooLarge (a record-layer variant) and duplicated the u32::try_from + >0xFF_FFFF ceiling check 3x. Extract u24_len(usize)->Result<[u8;3],Error> guarding the real u24 ceiling, returning the message-framing-layer Error::MessageTooLarge. Unit-tested. --- crates/yip-utls/src/error.rs | 14 ++++++++++- crates/yip-utls/src/stream.rs | 47 ++++++++++++++++++++++++----------- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/crates/yip-utls/src/error.rs b/crates/yip-utls/src/error.rs index afe1982..6716d3f 100644 --- a/crates/yip-utls/src/error.rs +++ b/crates/yip-utls/src/error.rs @@ -40,6 +40,14 @@ pub enum Error { /// or a malformed `record_lengths`). REALITY.5c fail-safe: 5d degrades /// this connection to splice-only. FlightTooLarge, + /// A hand-rolled TLS 1.3 handshake MESSAGE (or a `CertificateEntry` DER, + /// or the `certificate_list`) exceeds the 24-bit length its wire prefix + /// can encode (`> 0xFF_FFFF`). Distinct from a record-layer overflow + /// ([`crate::handshake::Error::RecordTooLarge`], the AEAD record's u16 + /// length): this is the message-framing layer. Unreachable with any real + /// forged flight (~KiB); guards [`crate::stream::emit_server_flight`] + /// against a pathological template. + MessageTooLarge, } impl fmt::Display for Error { @@ -58,6 +66,9 @@ impl fmt::Display for Error { "REALITY/TLS server flight exceeds the captured dest record framing" ) } + Error::MessageTooLarge => { + write!(f, "REALITY/TLS handshake message exceeds its 24-bit length prefix") + } } } } @@ -72,7 +83,8 @@ impl std::error::Error for Error { | Error::Clock | Error::RealityVerify(_) | Error::UnsupportedGroup(_) - | Error::FlightTooLarge => None, + | Error::FlightTooLarge + | Error::MessageTooLarge => None, } } } diff --git a/crates/yip-utls/src/stream.rs b/crates/yip-utls/src/stream.rs index 07bea1c..18d3013 100644 --- a/crates/yip-utls/src/stream.rs +++ b/crates/yip-utls/src/stream.rs @@ -1225,27 +1225,35 @@ pub struct ServerFlight { pub app_keys: ApplicationKeys, } +/// The 3-byte big-endian TLS `uint24` length prefix for `n`, guarding the real +/// `0xFF_FFFF` ceiling (not just `u32::MAX`, which `u32::try_from` alone would +/// permit and then silently truncate). `Err(MessageTooLarge)` on overflow — +/// the handshake-MESSAGE framing layer, distinct from the record-layer u16. +fn u24_len(n: usize) -> Result<[u8; 3], Error> { + if n > 0xFF_FFFF { + return Err(Error::MessageTooLarge); + } + let bytes = u32::try_from(n) + .map_err(|_| Error::MessageTooLarge)? + .to_be_bytes(); + Ok([bytes[1], bytes[2], bytes[3]]) +} + /// Wrap a handshake-message body as `type ‖ u24 len ‖ body`. fn handshake_message(msg_type: u8, body: &[u8]) -> Result, Error> { - let len = u32::try_from(body.len()).map_err(|_| handshake::Error::RecordTooLarge)?; - if body.len() > 0xFF_FFFF { - return Err(handshake::Error::RecordTooLarge.into()); - } + let len = u24_len(body.len())?; let mut out = Vec::with_capacity(4 + body.len()); out.push(msg_type); - out.extend_from_slice(&len.to_be_bytes()[1..]); // u24 + out.extend_from_slice(&len); // u24 out.extend_from_slice(body); Ok(out) } /// A single `CertificateEntry`: `u24 cert_data_len ‖ der ‖ u16 ext_len(0)`. fn certificate_entry(der: &[u8]) -> Result, Error> { - let len = u32::try_from(der.len()).map_err(|_| handshake::Error::RecordTooLarge)?; - if der.len() > 0xFF_FFFF { - return Err(handshake::Error::RecordTooLarge.into()); - } + let len = u24_len(der.len())?; let mut out = Vec::with_capacity(3 + der.len() + 2); - out.extend_from_slice(&len.to_be_bytes()[1..]); // u24 + out.extend_from_slice(&len); // u24 out.extend_from_slice(der); out.extend_from_slice(&[0x00, 0x00]); // empty entry extensions Ok(out) @@ -1277,11 +1285,7 @@ pub fn emit_server_flight( } let mut cert_body = Vec::with_capacity(1 + 3 + cert_list.len()); cert_body.push(0x00); // certificate_request_context length = 0 - let list_len = u32::try_from(cert_list.len()).map_err(|_| handshake::Error::RecordTooLarge)?; - if cert_list.len() > 0xFF_FFFF { - return Err(handshake::Error::RecordTooLarge.into()); - } - cert_body.extend_from_slice(&list_len.to_be_bytes()[1..]); // u24 + cert_body.extend_from_slice(&u24_len(cert_list.len())?); // u24 cert_body.extend_from_slice(&cert_list); let certificate = handshake_message(0x0b, &cert_body)?; @@ -3383,6 +3387,19 @@ mod tests { assert_eq!(types, vec![0x08, 0x0b, 0x0f, 0x14]); } + #[test] + fn u24_len_encodes_and_guards_the_ceiling() { + assert_eq!(super::u24_len(0).unwrap(), [0x00, 0x00, 0x00]); + assert_eq!(super::u24_len(1).unwrap(), [0x00, 0x00, 0x01]); + assert_eq!(super::u24_len(0x01_02_03).unwrap(), [0x01, 0x02, 0x03]); + // Exactly the u24 ceiling is allowed; one over → MessageTooLarge. + assert_eq!(super::u24_len(0xFF_FFFF).unwrap(), [0xFF, 0xFF, 0xFF]); + assert!(matches!( + super::u24_len(0x1_00_00_00), + Err(Error::MessageTooLarge) + )); + } + #[test] fn emit_server_flight_rejects_malformed_or_over_capacity_framing() { let (keys, signing_key, leaf_der, transcript_ch_sh) = emit_flight_fixture(); From 52c84cf44efaa9b5357c1bca61421414701a1d1e Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 20:31:11 -0400 Subject: [PATCH 25/27] fix(yip-rendezvous): hoist CertVerify signing-key load pre-write; splice partial-record stalls (5d follow-ups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move p256 SigningKey::from_pkcs8_der above the ServerHello write so its (unreachable) failure splices, keeping every fallible step on the pre-write side of the fail-safe boundary. - read_first_tls_record now enforces the HANDSHAKE_TIMEOUT deadline itself (per read via timeout_at) instead of the caller wrapping it in a cancelling timeout(). A client that stalls AFTER a partial record now yields the consumed bytes as Passthrough → spliced to dest (a real upstream holds a half-sent record), not dropped. Only a zero-byte connection drops (Empty). --- bin/yip-rendezvous/src/reality_io.rs | 101 +++++++++++++++++++++------ bin/yip-rendezvous/src/tls_front.rs | 38 +++++----- 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/bin/yip-rendezvous/src/reality_io.rs b/bin/yip-rendezvous/src/reality_io.rs index ae3676a..5b7c95c 100644 --- a/bin/yip-rendezvous/src/reality_io.rs +++ b/bin/yip-rendezvous/src/reality_io.rs @@ -131,17 +131,22 @@ enum Read { Short(usize), } -/// Fill `buf` from `tcp`, tracking how many bytes were read before a `0`-byte -/// read (EOF) or an I/O error cuts it short. A read error is treated the same -/// as EOF here: either way, whatever prefix was consumed is what must be -/// replayed to preserve REALITY indistinguishability. -async fn read_full(tcp: &mut TcpStream, buf: &mut [u8]) -> Read { +/// Fill `buf` from `tcp` by `deadline`, tracking how many bytes were read +/// before a `0`-byte read (EOF), an I/O error, OR the deadline cuts it short. +/// EOF, error, and timeout are treated identically: whatever prefix was +/// consumed is what must be replayed to preserve REALITY indistinguishability +/// — a real upstream server also just holds a half-sent record. The deadline +/// is enforced per read (`timeout_at`), so a stall after some bytes still +/// returns `Short(n)` with `n` intact (a whole-future `timeout` wrapper would +/// cancel the read and lose `n`). +async fn read_full(tcp: &mut TcpStream, buf: &mut [u8], deadline: tokio::time::Instant) -> Read { let mut n = 0; while n < buf.len() { - match tcp.read(&mut buf[n..]).await { - Ok(0) => return Read::Short(n), - Ok(k) => n += k, - Err(_) => return Read::Short(n), + match tokio::time::timeout_at(deadline, tcp.read(&mut buf[n..])).await { + Ok(Ok(0)) => return Read::Short(n), // EOF + Ok(Ok(k)) => n += k, + Ok(Err(_)) => return Read::Short(n), // I/O error + Err(_) => return Read::Short(n), // deadline elapsed mid-read } } Read::Full @@ -162,17 +167,22 @@ async fn read_full(tcp: &mut TcpStream, buf: &mut [u8]) -> Read { /// header or body yields `Passthrough` with whatever prefix was consumed, and /// only a connection that yielded nothing at all is [`FirstRecord::Empty`]. /// -/// Does not itself enforce a timeout: the caller wraps this in -/// `tokio::time::timeout` (mirroring `HANDSHAKE_TIMEOUT` in -/// `tls_front.rs`) so a stalled client can't park the read forever. +/// Enforces its own `deadline` (per read via `timeout_at`, so a stall after +/// some bytes still returns `Passthrough` with the consumed prefix — a bare +/// `tokio::time::timeout` wrapper on this whole call would instead cancel the +/// read and lose those bytes, dropping a connection a real upstream would have +/// held and answered). Pass `now + HANDSHAKE_TIMEOUT` from `tls_front.rs`. /// /// A `ClientHello` that is TLS-record-fragmented across multiple records /// will only have its first fragment returned here. Task 3 treats an /// unparseable/partial hello as un-authed and splices it to the decoy /// (fail-safe), so this is acceptable for REALITY.1. -pub async fn read_first_tls_record(tcp: &mut TcpStream) -> FirstRecord { +pub async fn read_first_tls_record( + tcp: &mut TcpStream, + deadline: tokio::time::Instant, +) -> FirstRecord { let mut header = [0u8; 5]; - match read_full(tcp, &mut header).await { + match read_full(tcp, &mut header, deadline).await { Read::Short(0) => return FirstRecord::Empty, Read::Short(n) => return FirstRecord::Passthrough(header[..n].to_vec()), Read::Full => {} @@ -185,7 +195,7 @@ pub async fn read_first_tls_record(tcp: &mut TcpStream) -> FirstRecord { } let mut body = vec![0u8; len]; - match read_full(tcp, &mut body).await { + match read_full(tcp, &mut body, deadline).await { Read::Full => { let mut record = header.to_vec(); record.extend_from_slice(&body); @@ -237,7 +247,11 @@ mod tests { .await .expect("write synthetic record"); - let got = read_first_tls_record(&mut reader).await; + let got = read_first_tls_record( + &mut reader, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + ) + .await; assert!( matches!(&got, FirstRecord::Complete(r) if *r == record), "must return Complete(header ++ body)" @@ -251,6 +265,37 @@ mod tests { assert_eq!(leftover, extra, "extra bytes must be left untouched"); } + #[tokio::test] + async fn read_first_tls_record_partial_then_stall_yields_passthrough_consumed() { + // A client that sends a partial record then goes silent (no EOF) must + // yield the consumed prefix as Passthrough once the deadline elapses — + // NOT be dropped. `writer` stays open (in scope) so there is no EOF; + // only the deadline ends the read. + let (mut writer, mut reader) = tcp_pair().await; + + // A well-formed 5-byte header claiming a 37-byte body, then only 10 of + // those body bytes — then stall. + let mut partial = vec![0x16, 0x03, 0x01, 0x00, 37]; + partial.extend_from_slice(&[0xABu8; 10]); + writer.write_all(&partial).await.expect("write partial record"); + + let got = read_first_tls_record( + &mut reader, + tokio::time::Instant::now() + std::time::Duration::from_millis(150), + ) + .await; + + match got { + FirstRecord::Passthrough(bytes) => assert_eq!( + bytes, partial, + "a partial-then-stall must replay exactly the consumed prefix" + ), + FirstRecord::Complete(_) => panic!("expected Passthrough(consumed), got Complete"), + FirstRecord::Empty => panic!("expected Passthrough(consumed), got Empty"), + } + drop(writer); // keep the connection alive until after the read + } + #[tokio::test] async fn read_first_tls_record_oversized_length_claim_yields_passthrough_header() { let (mut writer, mut reader) = tcp_pair().await; @@ -268,7 +313,11 @@ mod tests { // fails fast on EOF instead of hanging the test. drop(writer); - let got = read_first_tls_record(&mut reader).await; + let got = read_first_tls_record( + &mut reader, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + ) + .await; assert!( matches!(&got, FirstRecord::Passthrough(b) if *b == header), "a length claim above MAX_RECORD_BODY_LEN must yield Passthrough(header only), \ @@ -287,7 +336,11 @@ mod tests { .expect("write partial header"); drop(writer); - let got = read_first_tls_record(&mut reader).await; + let got = read_first_tls_record( + &mut reader, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + ) + .await; assert!( matches!(&got, FirstRecord::Passthrough(b) if *b == partial_header), "EOF partway through the header must yield Passthrough(bytes actually consumed)" @@ -315,7 +368,11 @@ mod tests { let mut expected = header; expected.extend_from_slice(partial_body); - let got = read_first_tls_record(&mut reader).await; + let got = read_first_tls_record( + &mut reader, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + ) + .await; assert!( matches!(&got, FirstRecord::Passthrough(b) if *b == expected), "a complete header followed by a truncated body then FIN must yield \ @@ -328,7 +385,11 @@ mod tests { let (writer, mut reader) = tcp_pair().await; drop(writer); - let got = read_first_tls_record(&mut reader).await; + let got = read_first_tls_record( + &mut reader, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + ) + .await; assert!( matches!(got, FirstRecord::Empty), "an immediate EOF with nothing consumed must yield Empty" diff --git a/bin/yip-rendezvous/src/tls_front.rs b/bin/yip-rendezvous/src/tls_front.rs index aaa278f..23da555 100644 --- a/bin/yip-rendezvous/src/tls_front.rs +++ b/bin/yip-rendezvous/src/tls_front.rs @@ -237,15 +237,14 @@ async fn run_reality_conn(mut tcp: TcpStream, cfg: &Arc) { return; // unreachable: caller only takes this branch when Some }; - // A stalled read has no buffered bytes to replay — drop, same slowloris - // bound the non-REALITY path gets from HANDSHAKE_TIMEOUT. - let outcome = - match tokio::time::timeout(HANDSHAKE_TIMEOUT, read_first_tls_record(&mut tcp)).await { - Ok(outcome) => outcome, - Err(_) => return, - }; - - let rec = match outcome { + // `read_first_tls_record` enforces the deadline itself (per read), so a + // client that stalls AFTER sending a partial record yields the consumed + // bytes as `Passthrough` and gets spliced to `dest` — a real upstream + // would likewise hold and answer a half-sent record, not drop it. Only a + // connection that produced literally nothing before the deadline is + // dropped (`Empty`). + let deadline = tokio::time::Instant::now() + HANDSHAKE_TIMEOUT; + let rec = match read_first_tls_record(&mut tcp, deadline).await { FirstRecord::Complete(rec) => rec, FirstRecord::Passthrough(bytes) => { splice_to_dest(tcp, r.dest, &bytes).await; @@ -362,6 +361,19 @@ async fn run_reality_conn(mut tcp: TcpStream, cfg: &Arc) { return; } + // Load the CertVerify signing key while still pre-write, so its + // (unreachable — `dk.pkcs8_der` already parsed as an rcgen KeyPair + // above) failure splices rather than drops, keeping every fallible + // step on the pre-write/splice side of the commit boundary. + let signing_key = match p256::ecdsa::SigningKey::from_pkcs8_der(&dk.pkcs8_der) { + Ok(k) => k, + Err(e) => { + eprintln!("tls-front: reality signing key load failed ({sni}): {e}"); + splice_to_dest(tcp, r.dest, &rec).await; + return; + } + }; + // --- Commit point: write the ServerHello. From here, DROP on error. --- let mut transcript_ch_sh = Vec::with_capacity(ch_msg.len() + sh_msg.len()); transcript_ch_sh.extend_from_slice(ch_msg); @@ -388,14 +400,6 @@ async fn run_reality_conn(mut tcp: TcpStream, cfg: &Arc) { return; } - let signing_key = match p256::ecdsa::SigningKey::from_pkcs8_der(&dk.pkcs8_der) { - Ok(k) => k, - Err(e) => { - eprintln!("tls-front: reality signing key load failed ({sni}): {e}"); - return; - } - }; - let reality_stream = match tokio::time::timeout( HANDSHAKE_TIMEOUT, yip_utls::stream::serve( From 559d5ffa3d26fa6779be0d9b0b5818e6ae0dbe6a Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 20:35:09 -0400 Subject: [PATCH 26/27] test(reality.5d): group-29 dest authed e2e test; drop stale log-dump in netns wrong-pbk branch (follow-ups) - reality_authed_group29_dest_client_verify_succeeds: an X25519-only cert source forces the captured template to key_share_group=29, so the authed path feeds the standalone 0x001d share (not the 4588 tail) into emit_server_hello; a verify=on client must still complete + verify the 4b binding. Deterministic, independent of BoringSSL's hybrid-group support. - start_reality_front_with takes the cert_src addr (2 callers updated). - netns wrong-pbk FAIL branch: drop the redundant dump_logs (it cats the stale good-run LOG_A, not LOG_A_WRONG). --- bin/yip-rendezvous/src/tls_front.rs | 88 ++++++++++++++++++++++++-- bin/yipd/tests/run-netns-reality-5d.sh | 2 +- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/bin/yip-rendezvous/src/tls_front.rs b/bin/yip-rendezvous/src/tls_front.rs index 23da555..1f900ae 100644 --- a/bin/yip-rendezvous/src/tls_front.rs +++ b/bin/yip-rendezvous/src/tls_front.rs @@ -732,6 +732,40 @@ mod tests { addr } + /// Like [`spawn_cert_source`], but restricts the acceptor's supported + /// groups to X25519 (group `29`) only, so `capture_dest_flight`'s + /// Chrome-faithful probe (which offers both X25519MLKEM768/`4588` and + /// X25519/`29`) negotiates group `29` — yielding a `ServerFlightTemplate` + /// with `key_share_group == 29`. Lets the authed end-to-end test below + /// exercise the group-29 server KEX/`select_client_x25519` wiring + /// deterministically, independent of which hybrid groups the BoringSSL + /// version happens to support. + async fn spawn_cert_source_x25519() -> SocketAddr { + let dir = unique_tmp_dir("reality-certsrc-x25519"); + let (cert, key) = write_realistic_leaf(&dir); + let mut b = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); + b.set_certificate_chain_file(&cert).unwrap(); + b.set_private_key_file(&key, SslFiletype::PEM).unwrap(); + b.check_private_key().unwrap(); + b.set_alpn_protos(b"\x02h2\x08http/1.1").unwrap(); + // X25519 only ⇒ the probe's hybrid 4588 offer is declined, group 29 wins. + b.set_curves_list("X25519").unwrap(); + let acceptor = Arc::new(b.build()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + if let Ok((tcp, _)) = listener.accept().await { + let acceptor = Arc::clone(&acceptor); + tokio::spawn(async move { + let _ = tokio_boring::accept(&acceptor, tcp).await; + }); + } + } + }); + addr + } + /// General REALITY front spinner: parameterized on `priv_key`/`short_ids`/ /// `server_names` so both the un-authed splice tests (empty `short_ids`/ /// `server_names` ⇒ no client can ever authenticate) and the REALITY.4b @@ -742,6 +776,7 @@ mod tests { priv_key: [u8; 32], short_ids: Vec<[u8; 8]>, server_names: Vec, + cert_src: SocketAddr, ) -> SocketAddr { let dir = unique_tmp_dir("reality"); let (cert, key) = write_self_signed(&dir); @@ -752,7 +787,6 @@ mod tests { // `prewarm` refuses to start with zero warmed names when at least one // was requested, so an un-authed-only caller still passes a // real-but-irrelevant name here (never reached — auth fails first). - let cert_src = spawn_cert_source().await; let warm_names = if server_names.is_empty() { vec!["cache.test".to_owned()] } else { @@ -792,7 +826,8 @@ mod tests { async fn start_reality_front(dest: SocketAddr) -> SocketAddr { // No client can authenticate ⇒ everything forwards (empty short_ids // AND empty server_names — see `start_reality_front_with`). - start_reality_front_with(dest, [7u8; 32], Vec::new(), Vec::new()).await + let cert_src = spawn_cert_source().await; + start_reality_front_with(dest, [7u8; 32], Vec::new(), Vec::new(), cert_src).await } async fn assert_gets_banner(front: SocketAddr, first_bytes: &[u8], banner: &[u8]) { @@ -873,9 +908,15 @@ mod tests { let short_id = [9u8; 8]; let sni = "auth.test"; - let front = - start_reality_front_with(dest, reality_priv, vec![short_id], vec![sni.to_owned()]) - .await; + let cert_src = spawn_cert_source().await; + let front = start_reality_front_with( + dest, + reality_priv, + vec![short_id], + vec![sni.to_owned()], + cert_src, + ) + .await; for attempt in 0..2 { let tcp = tokio::net::TcpStream::connect(front).await.unwrap(); @@ -889,6 +930,43 @@ mod tests { } } + /// The authed end-to-end path when `dest` negotiated **group 29 (X25519)** + /// rather than the hybrid 4588 — i.e. `select_client_x25519` must feed the + /// standalone `0x001d` share (not the 4588 tail) into `emit_server_hello`'s + /// server KEX. Deterministically forced by an X25519-only cert source. A + /// `verify=on` client must still complete the hand-rolled handshake and + /// verify the 4b binding. + #[tokio::test] + async fn reality_authed_group29_dest_client_verify_succeeds() { + let dest = spawn_dest_banner(b"SHOULD-NEVER-BE-SPLICED").await; + + let reality_priv = [77u8; 32]; + let reality_pub = + *x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(reality_priv)) + .as_bytes(); + let short_id = [3u8; 8]; + let sni = "group29.test"; + + let cert_src = spawn_cert_source_x25519().await; + let front = start_reality_front_with( + dest, + reality_priv, + vec![short_id], + vec![sni.to_owned()], + cert_src, + ) + .await; + + let tcp = tokio::net::TcpStream::connect(front).await.unwrap(); + let result = yip_utls::connect(tcp, sni, &reality_pub, short_id, true).await; + assert!( + result.is_ok(), + "verify=on client must complete the hand-rolled handshake against a \ + group-29 dest template: {:?}", + result.err() + ); + } + // ---- select_client_x25519 (pure decision) ---- fn info_with_shares(x0: Option<[u8; 32]>, x4588: Option<[u8; 32]>) -> ClientHelloInfo { diff --git a/bin/yipd/tests/run-netns-reality-5d.sh b/bin/yipd/tests/run-netns-reality-5d.sh index 4bfd3e1..8608ddd 100755 --- a/bin/yipd/tests/run-netns-reality-5d.sh +++ b/bin/yipd/tests/run-netns-reality-5d.sh @@ -447,7 +447,7 @@ if [ "$WRONG_PING_STATUS" -eq 0 ]; then echo "[FAIL] ping A->B succeeded with a WRONG pbk — the relay accepted an unauthenticated connection as a real relay client" echo "=== yip5dA (wrong pbk) log ===" cat "$LOG_A_WRONG" || true - dump_logs + # (Not dump_logs: it cats the stale good-run $LOG_A, not $LOG_A_WRONG.) exit 1 fi echo "[PASS] ping A->B failed as expected with a wrong pbk (relay spliced to DEST pre-ServerHello, no hand-rolled flight ever emitted, no tunnel formed)" From 14d90ed849665aee076d461ba7b2b5afcb9034d1 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 21:02:11 -0400 Subject: [PATCH 27/27] style(reality.5): rustfmt the follow-up cleanups (build-test fix) --- bin/yip-rendezvous/src/reality_io.rs | 7 +++++-- crates/yip-utls/src/error.rs | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bin/yip-rendezvous/src/reality_io.rs b/bin/yip-rendezvous/src/reality_io.rs index 5b7c95c..4cac623 100644 --- a/bin/yip-rendezvous/src/reality_io.rs +++ b/bin/yip-rendezvous/src/reality_io.rs @@ -143,7 +143,7 @@ async fn read_full(tcp: &mut TcpStream, buf: &mut [u8], deadline: tokio::time::I let mut n = 0; while n < buf.len() { match tokio::time::timeout_at(deadline, tcp.read(&mut buf[n..])).await { - Ok(Ok(0)) => return Read::Short(n), // EOF + Ok(Ok(0)) => return Read::Short(n), // EOF Ok(Ok(k)) => n += k, Ok(Err(_)) => return Read::Short(n), // I/O error Err(_) => return Read::Short(n), // deadline elapsed mid-read @@ -277,7 +277,10 @@ mod tests { // those body bytes — then stall. let mut partial = vec![0x16, 0x03, 0x01, 0x00, 37]; partial.extend_from_slice(&[0xABu8; 10]); - writer.write_all(&partial).await.expect("write partial record"); + writer + .write_all(&partial) + .await + .expect("write partial record"); let got = read_first_tls_record( &mut reader, diff --git a/crates/yip-utls/src/error.rs b/crates/yip-utls/src/error.rs index 6716d3f..35ca4e3 100644 --- a/crates/yip-utls/src/error.rs +++ b/crates/yip-utls/src/error.rs @@ -67,7 +67,10 @@ impl fmt::Display for Error { ) } Error::MessageTooLarge => { - write!(f, "REALITY/TLS handshake message exceeds its 24-bit length prefix") + write!( + f, + "REALITY/TLS handshake message exceeds its 24-bit length prefix" + ) } } }