REALITY.5c: encrypted server-flight emission + server-side stream (byte-match dest record framing) #86

Merged
vxfemboy merged 10 commits from feat/reality-5c-server-flight-emit into main 2026-07-19 01:19:34 +00:00
vxfemboy commented 2026-07-18 15:13:20 +00:00 (Migrated from github.com)

REALITY.5c — encrypted server-flight emission + server-side stream

Stacked on #85 (REALITY.5b). Base is feat/reality-5b-serverhello-emit; review the 5c delta here, merge after #85. PR 3 of 4 for REALITY.5 (#76).

Completes the server side of the hand-rolled TLS 1.3 handshake begun in 5b: the relay emits its encrypted flight (EncryptedExtensions / Certificate / CertificateVerify / Finished) sealed under the 5b handshake keys and framed to byte-match the borrowed dest's captured record lengths, then serves the data phase. Together with 5b's cleartext ServerHello, the relay's entire authed server flight is now indistinguishable to a passive DPI from a real Chrome↔dest session — replacing the BoringSSL SslAcceptor the authed path used.

What's here (all yip-utls)

  • HandshakeKeys::server_hs_traffic — exposed (base secret for the server Finished).
  • record_seal_padded — a TLS 1.3 record with trailing record padding; record_seal now delegates to it (pad_len = 0), so the existing seal KATs still cover the zero-padding path.
  • sign_certificate_verify — the ECDSA-P256 signing mirror of the shipped 4b verify_certificate_verify (identical RFC 8446 §4.4.3 signed content). This signature is the REALITY.4b relay-verification binding, server side.
  • emit_server_flight — assembles EE / Certificate / CertificateVerify / Finished, seals them under the handshake keys, and greedily frames them across record_lengths, prefixed with the middlebox CCS. Returns ServerFlight { wire, app_keys }. Fail-safe Error::FlightTooLarge when the flight can't fit the captured framing → 5d degrades to splice.
  • Role-agnostic RealityStream — renamed the direction fields (client_*/server_*egress_*/ingress_*) and added client()/server() constructors, so one record state machine serves both roles.
  • serve — the mirror of connect: emits the flight, writes it, drains the client's CCS + Finished (contents unchecked — the outer TLS is zero client-auth by design), and returns the server-role stream on the derived application keys.

Fidelity strategy

A passive DPI sees only the encrypted record framing, so 5c matches record_lengths exactly; EE/CertVerify/Finished are natural-sized and the difference is absorbed by TLS 1.3 record padding. Sizing the forged leaf to dest's leaf_der_len is 5d's forging job. record_lengths (from 5a's capture_dest_flight, which breaks at the server Finished) covers only the handshake-key records — so 5c seals the whole flight under the handshake keys, no app-key sealing and no NewSessionTicket.

Correctness gate

The shipped client connect(verify = on) round-trips against serve in-process: the mock run_mock_tls13_server_with_cert was refactored to emit its flight through emit_server_flight/serve, so the four connect_verify_* tests now exercise real 5c code end-to-end — the client completes the handshake, verifies the 4b binding, and application data flows both directions (application keys agree). The reject tests (WrongLeafKey, BadSignature) prove the binding still fails closed through the new emission path.

A framing bug the gate caught (and fixed)

A pure front-greedy split left trailing pure-padding records sealed under the handshake keys after the record containing Finished. Since connect stops reading at find_finished_end and then switches to the application keys, those leftover handshake-key records would fail to open in the data phase. Fixed by reserving one content byte per later record (chunk_len = cap.min(remaining.saturating_sub(records_after))) so the Finished tail always lands in the last record and the client reads every record. A regression test (..._with_flight_split_finished_last, record_lengths = [4096, 600]) reproduces the exact failure and now passes; the four verify tests run multi-record [600, 4096].

Scope / non-goals

  • yip-utls only. No yip-rendezvous wiring, no rcgen leaf forging, no epoll pump — all 5d. No post-Finished NST. No P256/P384 + HelloRetryRequest (#84).

5d hand-off notes

  • Size the forged leaf to dest's leaf_der_len; over-capacity → FlightTooLarge → splice (fail-safe).
  • serve's CCS-skip drain is unbounded (a hostile client sending endless ChangeCipherSpec could hang it — a pre-existing crate pattern, connect included); 5d must apply a read timeout at the caller, like 4a's relay-dial HANDSHAKE_TIMEOUT.
  • Carry-forward from 5b still applies: key the group-4588 X25519 DH against the bundled tail / wire an OS-CSPRNG-backed RandomSource.

Tests

  • record_seal_padded KATs; sign_certificate_verify sign↔verify (accept / wrong-key / tampered-transcript); emit_server_flight byte-framing (outer lengths == record_lengths, re-open recovers the four messages) + fail-safe (empty / < 17 / over-capacity → Err); the multi-record round-trip gate + regression test. Full yip-utls suite green (69 lib tests), clippy -D warnings clean.

Deferred cleanups (non-blocking, from the final review)

  • handshake::Error::RecordTooLarge is overloaded for the handshake-message u24 guard — a dedicated MessageTooLarge would read clearer.
  • The u24 length guard (u32::try_from + > 0xFF_FFFF) is duplicated 3× — extract a small helper.

Final whole-branch review (opus): READY TO MERGE — transcript/key-agreement correct, the 4b binding is exercised in both accept and reject directions, the framing fix is provably complete (every feasible template lands Finished last or fails closed), no fail-open / no reachable panic / no seq or direction mix-up, forbid-unsafe / no as / no bare #[allow].

## REALITY.5c — encrypted server-flight emission + server-side stream **Stacked on #85 (REALITY.5b).** Base is `feat/reality-5b-serverhello-emit`; review the 5c delta here, merge after #85. PR 3 of 4 for REALITY.5 (#76). Completes the server side of the hand-rolled TLS 1.3 handshake begun in 5b: the relay emits its **encrypted** flight (`EncryptedExtensions / Certificate / CertificateVerify / Finished`) sealed under the 5b handshake keys and **framed to byte-match the borrowed `dest`'s captured record lengths**, then serves the data phase. Together with 5b's cleartext ServerHello, the relay's entire authed server flight is now indistinguishable to a passive DPI from a real Chrome↔`dest` session — replacing the BoringSSL `SslAcceptor` the authed path used. ### What's here (all `yip-utls`) - **`HandshakeKeys::server_hs_traffic`** — exposed (base secret for the server `Finished`). - **`record_seal_padded`** — a TLS 1.3 record with trailing record padding; `record_seal` now delegates to it (`pad_len = 0`), so the existing seal KATs still cover the zero-padding path. - **`sign_certificate_verify`** — the ECDSA-P256 signing mirror of the shipped 4b `verify_certificate_verify` (identical RFC 8446 §4.4.3 signed content). This signature **is** the REALITY.4b relay-verification binding, server side. - **`emit_server_flight`** — assembles `EE / Certificate / CertificateVerify / Finished`, seals them under the handshake keys, and **greedily frames them across `record_lengths`**, prefixed with the middlebox CCS. Returns `ServerFlight { wire, app_keys }`. Fail-safe `Error::FlightTooLarge` when the flight can't fit the captured framing → 5d degrades to splice. - **Role-agnostic `RealityStream`** — renamed the direction fields (`client_*`/`server_*` → `egress_*`/`ingress_*`) and added `client()`/`server()` constructors, so one record state machine serves both roles. - **`serve`** — the mirror of `connect`: emits the flight, writes it, drains the client's CCS + `Finished` (contents unchecked — the outer TLS is zero client-auth by design), and returns the server-role stream on the derived application keys. ### Fidelity strategy A passive DPI sees only the **encrypted record framing**, so 5c matches `record_lengths` **exactly**; `EE`/`CertVerify`/`Finished` are natural-sized and the difference is absorbed by TLS 1.3 record padding. Sizing the forged **leaf** to `dest`'s `leaf_der_len` is 5d's forging job. `record_lengths` (from 5a's `capture_dest_flight`, which breaks at the server `Finished`) covers only the handshake-key records — so 5c seals the whole flight under the **handshake** keys, no app-key sealing and no NewSessionTicket. ### Correctness gate The shipped client `connect(verify = on)` round-trips against `serve` in-process: the mock `run_mock_tls13_server_with_cert` was refactored to emit its flight **through `emit_server_flight`/`serve`**, so the four `connect_verify_*` tests now exercise real 5c code end-to-end — the client completes the handshake, **verifies the 4b binding**, and application data flows both directions (application keys agree). The reject tests (`WrongLeafKey`, `BadSignature`) prove the binding still **fails closed** through the new emission path. ### A framing bug the gate caught (and fixed) A pure front-greedy split left trailing pure-padding records sealed under the handshake keys *after* the record containing `Finished`. Since `connect` stops reading at `find_finished_end` and then switches to the application keys, those leftover handshake-key records would fail to open in the data phase. Fixed by reserving one content byte per later record (`chunk_len = cap.min(remaining.saturating_sub(records_after))`) so the `Finished` tail always lands in the **last** record and the client reads every record. A regression test (`..._with_flight_split_finished_last`, `record_lengths = [4096, 600]`) reproduces the exact failure and now passes; the four verify tests run multi-record `[600, 4096]`. ### Scope / non-goals - `yip-utls` only. No `yip-rendezvous` wiring, no rcgen leaf forging, no epoll pump — all **5d**. No post-`Finished` NST. No P256/P384 + HelloRetryRequest (#84). ### 5d hand-off notes - Size the forged leaf to `dest`'s `leaf_der_len`; over-capacity → `FlightTooLarge` → splice (fail-safe). - `serve`'s CCS-skip drain is unbounded (a hostile client sending endless `ChangeCipherSpec` could hang it — a pre-existing crate pattern, `connect` included); 5d must apply a read timeout at the caller, like 4a's relay-dial `HANDSHAKE_TIMEOUT`. - Carry-forward from 5b still applies: key the group-4588 X25519 DH against the bundled tail / wire an OS-CSPRNG-backed `RandomSource`. ### Tests - `record_seal_padded` KATs; `sign_certificate_verify` sign↔verify (accept / wrong-key / tampered-transcript); `emit_server_flight` byte-framing (outer lengths == `record_lengths`, re-open recovers the four messages) + fail-safe (`empty` / `< 17` / over-capacity → `Err`); the multi-record round-trip gate + regression test. Full `yip-utls` suite green (69 lib tests), clippy `-D warnings` clean. ### Deferred cleanups (non-blocking, from the final review) - `handshake::Error::RecordTooLarge` is overloaded for the handshake-message u24 guard — a dedicated `MessageTooLarge` would read clearer. - The u24 length guard (`u32::try_from` + `> 0xFF_FFFF`) is duplicated 3× — extract a small helper. Final whole-branch review (opus): **READY TO MERGE** — transcript/key-agreement correct, the 4b binding is exercised in both accept and reject directions, the framing fix is provably complete (every feasible template lands `Finished` last or fails closed), no fail-open / no reachable panic / no seq or direction mix-up, `forbid-unsafe` / no `as` / no bare `#[allow]`.
Sign in to join this conversation.
No description provided.