diff --git a/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.gif b/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.gif new file mode 100644 index 00000000..5fa40395 Binary files /dev/null and b/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.gif differ diff --git a/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.tape b/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.tape new file mode 100644 index 00000000..f39d622d --- /dev/null +++ b/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.tape @@ -0,0 +1,32 @@ +Output demos/story-145/AC-001-002-direction-carry-drain.webm + +Set FontFamily "Menlo" +Set FontSize 18 +Set Width 1200 +Set Height 600 +Set Theme "Catppuccin Mocha" +Set WindowBar Colorful +Set BorderRadius 8 +Set Padding 20 +Set Framerate 30 +Set PlaybackSpeed 1.0 + +# AC-145-001 / AC-145-002 +# Direction-parameterized carry drain + C2S/S2C carry isolation +# +# proptest_vp039_direction_isolation: delivers fragmented ClientHello (C2S) and +# fragmented ServerHello (S2C) in interleaved order; each direction drains its +# own carry buffer independently; both client_hello_seen and server_hello_seen +# end true; parse_errors == 0; both carry buffers drain to zero. + +Hide +Type "cd /Users/zious/Documents/GITHUB/wirerust/.worktrees/story-145-tls-serverhello-symmetry" +Enter +Show + +Type "# AC-145-001/002: fragmented ServerHello S2C carry drain + direction isolation" +Enter +Sleep 1s +Type "cargo test --test tls_analyzer_tests story_145::proptest_vp039_direction_isolation" +Enter +Sleep 5s diff --git a/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.webm b/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.webm new file mode 100644 index 00000000..91cb14fe Binary files /dev/null and b/docs/demo-evidence/STORY-145/AC-001-002-direction-carry-drain.webm differ diff --git a/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.gif b/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.gif new file mode 100644 index 00000000..a8003a5f Binary files /dev/null and b/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.gif differ diff --git a/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.tape b/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.tape new file mode 100644 index 00000000..468ca6f8 --- /dev/null +++ b/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.tape @@ -0,0 +1,34 @@ +Output demos/story-145/AC-003-cross-flow-isolation.webm + +Set FontFamily "Menlo" +Set FontSize 18 +Set Width 1200 +Set Height 600 +Set Theme "Catppuccin Mocha" +Set WindowBar Colorful +Set BorderRadius 8 +Set Padding 20 +Set Framerate 30 +Set PlaybackSpeed 1.0 + +# AC-145-003: Cross-flow isolation +# +# test_BC_2_07_041_cross_flow_isolation: two concurrent flows analyzed by one +# TlsAnalyzer instance. +# Flow A: complete single-record ClientHello (SNI=a.example) + complete S2C ServerHello +# Flow B: fragmented 2-record ClientHello (SNI=b.example) + fragmented 2-record S2C ServerHello +# +# Asserts: both flows have server_hello_seen==true; sni_counts has exactly 2 +# entries (no cross-flow bleed); ja3s_counts >= 1; parse_errors == 0. + +Hide +Type "cd /Users/zious/Documents/GITHUB/wirerust/.worktrees/story-145-tls-serverhello-symmetry" +Enter +Show + +Type "# AC-145-003: two concurrent flows with fragmented ServerHellos -- no cross-flow bleed" +Enter +Sleep 1s +Type "cargo test --test tls_analyzer_tests story_145::test_BC_2_07_041_cross_flow_isolation" +Enter +Sleep 5s diff --git a/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.webm b/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.webm new file mode 100644 index 00000000..2044ce64 Binary files /dev/null and b/docs/demo-evidence/STORY-145/AC-003-cross-flow-isolation.webm differ diff --git a/docs/demo-evidence/STORY-145/AC-005-single-record-regression.gif b/docs/demo-evidence/STORY-145/AC-005-single-record-regression.gif new file mode 100644 index 00000000..5a6a9f1c Binary files /dev/null and b/docs/demo-evidence/STORY-145/AC-005-single-record-regression.gif differ diff --git a/docs/demo-evidence/STORY-145/AC-005-single-record-regression.tape b/docs/demo-evidence/STORY-145/AC-005-single-record-regression.tape new file mode 100644 index 00000000..ce7b9b04 --- /dev/null +++ b/docs/demo-evidence/STORY-145/AC-005-single-record-regression.tape @@ -0,0 +1,42 @@ +Output demos/story-145/AC-005-single-record-regression.webm + +Set FontFamily "Menlo" +Set FontSize 18 +Set Width 1200 +Set Height 600 +Set Theme "Catppuccin Mocha" +Set WindowBar Colorful +Set BorderRadius 8 +Set Padding 20 +Set Framerate 30 +Set PlaybackSpeed 1.0 + +# AC-145-005: Single-record ServerHello regression check +# +# Verifies that adding S2C carry-buffer drain logic does NOT break analysis +# of a normal (non-fragmented) single-record ServerHello. +# +# Part 1: unit test -- test_parse_server_hello sends a complete single-record +# ServerHello in S2C direction and asserts server_hello_seen == true. +# +# Part 2: CLI smoke -- wirerust analyze on a real TLS 1.2 pcap still +# produces a triage report with no errors. + +Hide +Type "cd /Users/zious/Documents/GITHUB/wirerust/.worktrees/story-145-tls-serverhello-symmetry" +Enter +Show + +Type "# AC-145-005: single-record ServerHello regression -- unit test" +Enter +Sleep 1s +Type "cargo test --test tls_analyzer_tests test_parse_server_hello" +Enter +Sleep 5s + +Type "# AC-145-005: CLI smoke -- real TLS 1.2 pcap analyzed without error" +Enter +Sleep 1s +Type "./target/release/wirerust analyze tests/fixtures/tls12-aes256gcm.pcap" +Enter +Sleep 3s diff --git a/docs/demo-evidence/STORY-145/AC-005-single-record-regression.webm b/docs/demo-evidence/STORY-145/AC-005-single-record-regression.webm new file mode 100644 index 00000000..36d5a4b8 Binary files /dev/null and b/docs/demo-evidence/STORY-145/AC-005-single-record-regression.webm differ diff --git a/docs/demo-evidence/STORY-145/evidence-report.md b/docs/demo-evidence/STORY-145/evidence-report.md new file mode 100644 index 00000000..2a11bab5 --- /dev/null +++ b/docs/demo-evidence/STORY-145/evidence-report.md @@ -0,0 +1,119 @@ +# Demo Evidence Report — STORY-145 + +**Story:** TLS ServerHello direction-symmetry handshake reassembly +**Date:** 2026-06-30 +**Product:** wirerust (CLI, Rust) +**Recording tool:** VHS 0.11.0 + ffmpeg 8.1 + +--- + +## Summary + +STORY-145 extends the TLS handshake-message reassembly carry-buffer mechanism +(introduced in STORY-144 for ClientHello/ClientToServer) to the ServerToClient +direction. A ServerHello fragmented across multiple TLS records is now +reassembled so JA3S extraction and detection work correctly. All four STORY-145 +acceptance tests pass; three VHS terminal recordings provide per-AC visual evidence. + +--- + +## Per-AC Demo Recordings + +| AC | Test / command | Recording | Duration | Size | Result | +|----|----------------|-----------|----------|------|--------| +| AC-145-001, AC-145-002 | `proptest_vp039_direction_isolation` | [WebM](AC-001-002-direction-carry-drain.webm) [GIF](AC-001-002-direction-carry-drain.gif) | 14s | 136K / 537K | ok | +| AC-145-003 | `test_BC_2_07_041_cross_flow_isolation` | [WebM](AC-003-cross-flow-isolation.webm) [GIF](AC-003-cross-flow-isolation.gif) | 15s | 136K / 472K | ok | +| AC-145-005 | `test_parse_server_hello` + CLI pcap smoke | [WebM](AC-005-single-record-regression.webm) [GIF](AC-005-single-record-regression.gif) | 23s | 266K / 1.3M | ok | + +--- + +## AC Coverage Detail + +### AC-145-001 + AC-145-002: Direction-parameterized carry drain & C2S/S2C isolation + +**Recording:** `AC-001-002-direction-carry-drain.webm` + +**What is shown:** `proptest_vp039_direction_isolation` — a property-based test +that runs at multiple random split points. Three parallel analyzer instances +receive the same fragmented ClientHello (C2S) and fragmented ServerHello (S2C): +one interleaved, one C2S-only, one S2C-only. After full delivery the test asserts: + +- `client_hello_seen == true` (C2S carry drain — STORY-144 path) +- `server_hello_seen == true` (S2C carry drain — **STORY-145 path**) +- `sni_counts` non-empty (SNI extracted from fragmented ClientHello) +- `ja3s_counts` non-empty (JA3S extracted from fragmented ServerHello) +- `parse_errors == 0` +- both `client_hs_carry_len == 0` and `server_hs_carry_len == 0` after full delivery + +Terminal output visible: `test story_145::proptest_vp039_direction_isolation ... ok` +`test result: ok. 1 passed; 0 failed` + +--- + +### AC-145-003: Cross-flow isolation (two concurrent fragmented-ServerHello flows) + +**Recording:** `AC-003-cross-flow-isolation.webm` + +**What is shown:** `test_BC_2_07_041_cross_flow_isolation` — one `TlsAnalyzer` +instance processes two flows simultaneously: + +- **Flow A** (seed=10): complete single-record ClientHello (`a.example`) + complete S2C ServerHello +- **Flow B** (seed=20): fragmented 2-record ClientHello (`b.example`) + fragmented 2-record S2C ServerHello + +Assertions verified: both `server_hello_seen == true`; `sni_counts` has exactly +2 entries (a.example + b.example, no cross-flow bleed); `ja3s_counts >= 1`; all +carry buffers drain to zero; `parse_errors == 0`. + +Terminal output visible: `test story_145::test_BC_2_07_041_cross_flow_isolation ... ok` + +--- + +### AC-145-005: Single-record ServerHello regression check + +**Recording:** `AC-005-single-record-regression.webm` + +**What is shown (two parts):** + +1. **Unit test:** `test_parse_server_hello` — delivers a complete single-record + ServerHello in `Direction::ServerToClient` and asserts `server_hello_seen == true`. + Confirms the new carry-drain code path does not break non-fragmented delivery. + +2. **CLI smoke test:** `wirerust analyze tests/fixtures/tls12-aes256gcm.pcap` — + real TLS 1.2 capture produces a valid WIRERUST TRIAGE REPORT (Packets: 9, + TLS: 9) with no parse errors in the output. + +Terminal output visible: `test test_parse_server_hello ... ok` followed by the +CLI triage report. + +--- + +## AC-145-004: Not separately recorded + +AC-145-004 (overflow guard — `server_hs_carry` clears on Step-1 overflow and +recovers on subsequent complete ServerHello) is covered by +`test_vp039_server_carry_overflow_clear_and_recover` and +`test_vp039_server_body_len_spoof`, both of which pass in the full test suite. +These are overflow/error-path tests exercising internal guard invariants; they +produce no user-observable CLI output distinguishable from a normal pass. +Full suite evidence: `cargo test --test tls_analyzer_tests story_145` → +`4 passed; 0 failed`. + +--- + +## VHS Tape Scripts + +| Script | AC | +|--------|----| +| [AC-001-002-direction-carry-drain.tape](AC-001-002-direction-carry-drain.tape) | AC-145-001, AC-145-002 | +| [AC-003-cross-flow-isolation.tape](AC-003-cross-flow-isolation.tape) | AC-145-003 | +| [AC-005-single-record-regression.tape](AC-005-single-record-regression.tape) | AC-145-005 | + +--- + +## Demo Toolchain + +| Tool | Version | +|------|---------| +| VHS | 0.11.0 | +| ffmpeg | 8.1 | +| Rust / cargo | stable (incremental build, pre-compiled) | diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index cb7eefe8..c33cfa36 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -17,12 +17,12 @@ use std::collections::HashMap; use chrono::DateTime; use md5::{Digest, Md5}; -// `parse_tls_message_handshake` is used by the ClientToServer carry drain loop -// (AC-144-002). The drain loop dispatches complete ClientHello messages via this -// function once the carry buffer holds a full handshake message (ADR-011 Decision 4). +// `parse_tls_message_handshake` is used by both direction carry drain loops +// (AC-144-002 / AC-145-001). ClientToServer dispatches ClientHello (0x01); +// ServerToClient dispatches ServerHello (0x02) (ADR-011 Decision 4). use tls_parser::{ - Err as NomErr, TlsCipherSuite, TlsCipherSuiteID, TlsExtension, TlsExtensionType, TlsMessage, - TlsMessageHandshake, parse_tls_extensions, parse_tls_message_handshake, parse_tls_plaintext, + TlsCipherSuite, TlsCipherSuiteID, TlsExtension, TlsExtensionType, TlsMessage, + TlsMessageHandshake, parse_tls_extensions, parse_tls_message_handshake, }; use crate::analyzer::AnalysisSummary; @@ -791,18 +791,15 @@ impl TlsAnalyzer { // the capture-relative timestamp to any emitted Findings. let last_ts = self.flows.get(flow_key).map(|s| s.last_ts).unwrap_or(0); - // AC-144-002 / BC-2.07.038: direction-parameterized handshake carry path. + // AC-144-002 / AC-145-001 / BC-2.07.038 / BC-2.07.041: direction-parameterized + // handshake carry path. // - // STORY-144 scope: ClientToServer direction only. - // STORY-145 scope: ServerToClient direction (adds `server_hs_carry` arm). - // - // For ClientToServer: the record payload (no 5-byte TLS record header) - // is appended to `client_hs_carry`; the drain loop dispatches complete - // ClientHello messages via `parse_tls_message_handshake` (ADR-011 Decision 4). - // - // For ServerToClient (STORY-144): the pre-existing `parse_tls_plaintext` path - // is retained verbatim until STORY-145 replaces it with `server_hs_carry`. - // This preserves all existing ServerHello tests (AC-144-005). + // Both ClientToServer and ServerToClient use the same cursor-based drain loop + // design (SEC-001 O(carry_len) guarantee). ClientToServer accumulates into + // `client_hs_carry` and dispatches ClientHello (msg_type 0x01); ServerToClient + // accumulates into `server_hs_carry` and dispatches ServerHello (msg_type 0x02). + // Overflow/spoof-guard invariants are identical for both directions + // (BC-2.07.041 v1.2 Invariant 2; ADR-011 Decision 4/5). match direction { Direction::ClientToServer => { // ── ClientToServer carry path (AC-144-002) ────────────────────── @@ -970,30 +967,126 @@ impl TlsAnalyzer { } Direction::ServerToClient => { - // ── ServerToClient: pre-existing parse_tls_plaintext path ───── + // ── ServerToClient carry path (AC-145-001) ────────────────────── // - // STORY-145 will replace this with the server_hs_carry drain loop. - // Retained verbatim here to preserve all existing ServerHello tests - // (AC-144-005; BC-2.07.001 v1.9 Invariant 5). - match parse_tls_plaintext(&record_bytes) { - Ok((_rem, plaintext)) => { - for msg in &plaintext.msg { - if let TlsMessage::Handshake(TlsMessageHandshake::ServerHello(sh)) = - msg - { - if let Some(state) = self.flows.get_mut(flow_key) { - state.server_hello_seen = true; - } - self.handle_server_hello(sh, flow_key, last_ts); - } + // Symmetric to the ClientToServer carry path above. The record + // payload is appended to `server_hs_carry`; the drain loop + // dispatches complete ServerHello messages (msg_type 0x02) via + // `parse_tls_message_handshake` (ADR-011 Decision 4). All + // overflow and spoof-guard invariants are identical to the + // ClientToServer direction (BC-2.07.041 v1.2 Invariant 2). + let record_payload = &record_bytes[5..]; + + // Step 1: Overflow check BEFORE append. + let carry_len_before = self + .flows + .get(flow_key) + .map(|s| s.server_hs_carry.len()) + .unwrap_or(0); + + if carry_len_before + record_payload.len() > MAX_BUF { + if let Some(state) = self.flows.get_mut(flow_key) { + state.server_hs_carry.clear(); + } + self.handshake_reassembly_overflows = + self.handshake_reassembly_overflows.saturating_add(1); + continue; + } + + // Step 2: Append payload to server_hs_carry. + if let Some(state) = self.flows.get_mut(flow_key) { + state.server_hs_carry.extend_from_slice(record_payload); + } + + // Step 3: Drain loop — consume complete handshake messages. + // + // Same SEC-001 cursor-based O(carry_len) design as the + // ClientToServer direction above. + let mut consumed: usize = 0; + let mut decision4_fired = false; + loop { + let (carry_len, msg_type, body_len) = { + let state = match self.flows.get(flow_key) { + Some(s) => s, + None => break, + }; + let carry = &state.server_hs_carry; + if carry.len() - consumed < 4 { + break; + } + let mt = carry[consumed]; + let bl = ((carry[consumed + 1] as usize) << 16) + | ((carry[consumed + 2] as usize) << 8) + | (carry[consumed + 3] as usize); + (carry.len(), mt, bl) + }; + + // Decision-4: body_len > MAX_BUF → body_len-spoof guard. + if body_len > MAX_BUF { + if let Some(state) = self.flows.get_mut(flow_key) { + state.server_hs_carry.clear(); } + self.handshake_reassembly_overflows = + self.handshake_reassembly_overflows.saturating_add(1); + decision4_fired = true; + break; } - Err(NomErr::Incomplete(_)) => { - self.parse_errors += 1; + + // Incomplete: body not yet fully arrived. + if carry_len - consumed < 4 + body_len { + break; } - Err(_) => { - self.parse_errors += 1; + + // Dispatch on msg_type: + // 0x02 → ServerHello via parse_tls_message_handshake. + // Ok(ServerHello): set server_hello_seen, call handle_server_hello. + // Err or Ok(non-SH): parse_errors+1. + // Other: consume silently (BC-2.07.038 Inv-1). + match msg_type { + 0x02 => { + let msg_bytes: Vec = { + let state = match self.flows.get(flow_key) { + Some(s) => s, + None => break, + }; + state.server_hs_carry[consumed..consumed + 4 + body_len] + .to_vec() + }; + match parse_tls_message_handshake(&msg_bytes) { + Ok(( + _rem, + TlsMessage::Handshake(TlsMessageHandshake::ServerHello( + ref sh, + )), + )) => { + if let Some(state) = self.flows.get_mut(flow_key) { + state.server_hello_seen = true; + } + self.handle_server_hello(sh, flow_key, last_ts); + } + Ok(_) => { + self.parse_errors += 1; + } + Err(_) => { + self.parse_errors += 1; + } + } + } + _ => { + // Other handshake types: consume silently + // (BC-2.07.038 Invariant 1). + } } + + consumed += 4 + body_len; + } + + // Single drain after the loop: O(carry_len) total. + if !decision4_fired + && consumed > 0 + && let Some(state) = self.flows.get_mut(flow_key) + { + state.server_hs_carry.drain(..consumed); } } } diff --git a/tests/tls_analyzer_tests.proptest-regressions b/tests/tls_analyzer_tests.proptest-regressions new file mode 100644 index 00000000..6b6ef18b --- /dev/null +++ b/tests/tls_analyzer_tests.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 7b4caf8781bc52de4ab0a4b399787785145b1b29c1325df86d59d87db2740e15 # shrinks to c2s_split = 1, s2c_split = 1 diff --git a/tests/tls_analyzer_tests.rs b/tests/tls_analyzer_tests.rs index 2a517eae..faf13532 100644 --- a/tests/tls_analyzer_tests.rs +++ b/tests/tls_analyzer_tests.rs @@ -10419,3 +10419,538 @@ mod story_144 { } } } + +// ── STORY-145: ServerHello Carry Symmetry + Per-Flow / Per-Direction Isolation ── +// +// Wave 66. Behavioral Contracts: BC-2.07.041 v1.2, BC-2.07.002 v1.6. +// +// Red-Gate harnesses (VP-039 Sub-E scope — 2 new tests): +// 1. proptest_vp039_direction_isolation (Sub-E): interleaved C2S/S2C fragmented +// hellos for the same flow; assert both hellos seen, no cross-direction bleed. +// 2. test_BC_2_07_041_cross_flow_isolation (Sub-E-ext): two distinct FlowKeys; +// Flow A complete single-record, Flow B fragmented; assert sni_counts contains +// both hostnames with no cross-flow bleed. +// +// Namespace isolation: DF-TEST-NAMESPACE-001 — all STORY-145 tests live inside +// this `mod story_145` wrapper. No new flat-root tests are added for this story. +// +// Test count: 4 (1 proptest + 3 unit). +mod story_145 { + use proptest::prelude::*; + use std::net::IpAddr; + use wirerust::analyzer::tls::TlsAnalyzer; + use wirerust::reassembly::flow::FlowKey; + use wirerust::reassembly::handler::{Direction, StreamHandler}; + + // ── Local test helpers ──────────────────────────────────────────────────── + // + // DF-TEST-NAMESPACE-001 / STORY-145 helper note: helpers are re-declared + // locally per mod. Before creating any helper below, the story note + // instructs grepping for the real name at the flat root. + // + // Reconciliation results (grep run during stub generation): + // build_server_hello → EXISTS at flat root (line 137) + // build_client_hello → EXISTS at flat root (line 16) + // wrap_as_tls_record → ONLY in mod story_144 (private) — re-declared here + // make_test_flow_key → ONLY in mod story_144 (private) — re-declared here + // + // GREEN-BY-DESIGN self-check (BC-5.38.005 invariant 1): + // "If I include this real implementation, will the test for this function + // pass trivially without any implementer work?" + // — No: helpers are pure construction utilities; no test assertions are + // inside them. They are ≤ 5 lines and contain no branching logic on + // domain state beyond type construction. Declared as real bodies under + // GREEN-BY-DESIGN / WIRING-EXEMPT because they are builder helpers with + // zero domain branching (constructors, not behaviors). + + /// Create a `FlowKey` varied by `seed` so cross-flow and independent-flow + /// tests can use distinct keys without collision. + /// + /// Re-declared locally per DF-TEST-NAMESPACE-001 (identical to mod story_144 copy). + fn make_test_flow_key(seed: u8) -> FlowKey { + FlowKey::new( + IpAddr::from([10, 145, 0, seed]), + 49000u16.wrapping_add(seed as u16), + IpAddr::from([10, 145, 1, seed]), + 443, + ) + } + + /// Returns the RAW handshake-message bytes for a ServerHello (0x002f), + /// with NO TLS record header prefix (5 bytes stripped). + /// + /// `build_server_hello` at the flat root returns a COMPLETE TLS record + /// (5-byte header + handshake body). This wrapper strips the header so + /// fragmentation tests can re-frame the bytes via `wrap_as_tls_record`. + /// + /// Reconciliation: `build_server_hello` exists at flat root; used here. + fn build_server_hello() -> Vec { + super::build_server_hello(0x002f)[5..].to_vec() + } + + /// Returns the RAW handshake-message bytes for a ClientHello with the given + /// SNI, with NO TLS record header prefix (5 bytes stripped). + /// + /// Reconciliation: `build_client_hello` exists at flat root; used here. + fn build_client_hello_with_sni(sni: &str) -> Vec { + super::build_client_hello(sni, &[0x002f])[5..].to_vec() + } + + /// Wrap `payload` bytes in a 5-byte TLS record header for the given content type. + /// + /// Reconciliation: `wrap_as_tls_record` does NOT exist at flat root; re-declared + /// locally here (identical to mod story_144 copy). + fn wrap_as_tls_record(content_type: u8, payload: &[u8]) -> Vec { + let len = payload.len(); + let len_hi = (len >> 8) as u8; + let len_lo = (len & 0xff) as u8; + let mut record = vec![content_type, 0x03, 0x03, len_hi, len_lo]; + record.extend_from_slice(payload); + record + } + + // ── VP-039 Sub-E: direction isolation ──────────────────────────────────── + + // VP-039 Sub-E (proptest): interleaved C2S and S2C fragmented hello + // deliveries must each accumulate into their own carry buffer, and both + // `client_hello_seen` and `server_hello_seen` must be true after all records + // are delivered. + // + // The test runs THREE analyzers in parallel: + // - `interleaved` — C2S and S2C fragments interleaved on the SAME flow + // - `c2s_only` — same C2S fragments delivered alone + // - `s2c_only` — same S2C fragments delivered alone + // Then asserts: + // (1) interleaved.client_hello_seen == c2s_only.client_hello_seen (equivalence) + // (2) interleaved.server_hello_seen == s2c_only.server_hello_seen (equivalence) + // (3) interleaved.server_hello_seen == true (explicit truth — non-vacuous Red Gate) + // (4) interleaved.parse_errors == c2s_only.parse_errors + s2c_only.parse_errors + // (5) SNI extracted, JA3S extracted (both == true after complete delivery) + // + // STORY-145 wired the `ServerToClient` carry drain path; all assertions pass. + // The unified per-direction carry-buffer loop appends incoming bytes to the + // server_hs_carry, then drains via `parse_tls_message_handshake` until the carry + // is empty — single-record and fragmented S2C delivery share the same path. + // + // Saturating clamp (not prop_assume): prevents case discard for short ServerHello. + // + // Traces to: BC-2.07.041 v1.2 Invariant 2; BC-2.07.002 v1.6 Precondition 2; + // AC-145-001, AC-145-002, AC-145-004. + proptest! { + #[test] + fn proptest_vp039_direction_isolation( + // split_c2s and split_s2c are raw strategy outputs; they are clamped + // below to 1..n-1 after the hello bytes are generated. + split_c2s in prop_oneof![1usize..4usize, 4usize..256usize], + split_s2c in prop_oneof![1usize..4usize, 4usize..256usize], + ) { + let c2s_hello = build_client_hello_with_sni("client.example.com"); + let s2c_hello = build_server_hello(); + + let c2s_n = c2s_hello.len(); + let s2c_n = s2c_hello.len(); + + // Clamp to [1, n-1] — a function of the actual message length, not a + // fixed small constant. prop_assume would discard too many cases for + // the s2c hello (which may be short), so we saturating-clamp instead. + let k_c2s = split_c2s.min(c2s_n - 1).max(1); + let k_s2c = split_s2c.min(s2c_n - 1).max(1); + + let flow_key = make_test_flow_key(1); + let ts: u32 = 100; + + // --- Interleaved run --- + let mut interleaved = TlsAnalyzer::new(); + + // c2s partial delivery 1 + let rec_c2s_1 = wrap_as_tls_record(0x16, &c2s_hello[..k_c2s]); + interleaved.on_data(&flow_key, Direction::ClientToServer, &rec_c2s_1, 0u64, ts); + + // s2c partial delivery 1 (interleaved) + let rec_s2c_1 = wrap_as_tls_record(0x16, &s2c_hello[..k_s2c]); + interleaved.on_data(&flow_key, Direction::ServerToClient, &rec_s2c_1, 0u64, ts); + + // c2s completing delivery + let rec_c2s_2 = wrap_as_tls_record(0x16, &c2s_hello[k_c2s..]); + interleaved.on_data(&flow_key, Direction::ClientToServer, &rec_c2s_2, 0u64, ts); + + // s2c completing delivery + let rec_s2c_2 = wrap_as_tls_record(0x16, &s2c_hello[k_s2c..]); + interleaved.on_data(&flow_key, Direction::ServerToClient, &rec_s2c_2, 0u64, ts); + + // --- Independent c2s-only run --- + let fk_c2s = make_test_flow_key(2); + let mut c2s_only = TlsAnalyzer::new(); + c2s_only.on_data(&fk_c2s, Direction::ClientToServer, + &wrap_as_tls_record(0x16, &c2s_hello[..k_c2s]), 0u64, ts); + c2s_only.on_data(&fk_c2s, Direction::ClientToServer, + &wrap_as_tls_record(0x16, &c2s_hello[k_c2s..]), 0u64, ts); + + // --- Independent s2c-only run --- + let fk_s2c = make_test_flow_key(3); + let mut s2c_only = TlsAnalyzer::new(); + s2c_only.on_data(&fk_s2c, Direction::ServerToClient, + &wrap_as_tls_record(0x16, &s2c_hello[..k_s2c]), 0u64, ts); + s2c_only.on_data(&fk_s2c, Direction::ServerToClient, + &wrap_as_tls_record(0x16, &s2c_hello[k_s2c..]), 0u64, ts); + + // Invariant: interleaved run sees the same hellos as independent runs. + // Use FLAT accessors — no state_for_testing (TlsFlowState is private): + // client_hello_seen_for_testing — NEW seam (STORY-144/146 deliverable) + // server_hello_seen_for_testing — EXISTING seam (tls.rs:991) + prop_assert_eq!( + interleaved.client_hello_seen_for_testing(&flow_key), + c2s_only.client_hello_seen_for_testing(&fk_c2s), + "interleaved c2s hello detection must match independent c2s run" + ); + prop_assert_eq!( + interleaved.server_hello_seen_for_testing(&flow_key), + s2c_only.server_hello_seen_for_testing(&fk_s2c), + "interleaved s2c hello detection must match independent s2c run" + ); + // parse_errors is AGGREGATE on TlsAnalyzer — read via accessor, NOT off state + prop_assert_eq!( + interleaved.parse_error_count(), + c2s_only.parse_error_count() + s2c_only.parse_error_count(), + "interleaved parse_errors must equal sum of independent parse_errors" + ); + + // Explicit truth assertions — non-vacuous guards. + // Without these, the equivalence checks above would pass trivially if both + // sides returned false (regression guard for ServerToClient carry drain). + // BC-2.07.041 Sub-E property: BOTH directions must succeed after complete + // fragmented delivery. + prop_assert!( + interleaved.client_hello_seen_for_testing(&flow_key), + "client_hello_seen must be true after complete interleaved C2S delivery" + ); + prop_assert!( + interleaved.server_hello_seen_for_testing(&flow_key), + "server_hello_seen must be true after complete interleaved S2C delivery \ + (regression: ServerToClient carry-buffer drain loop not dispatching)" + ); + // SNI (from ClientHello) and JA3S (from ServerHello) must be extracted. + prop_assert!( + !interleaved.sni_counts().is_empty(), + "sni_counts must be non-empty after C2S ClientHello reassembly" + ); + prop_assert!( + !interleaved.ja3s_counts().is_empty(), + "ja3s_counts must be non-empty after S2C ServerHello reassembly \ + (regression: ServerToClient carry-buffer drain loop not dispatching)" + ); + prop_assert_eq!( + interleaved.parse_error_count(), 0u64, + "interleaved fragmented delivery must produce zero parse errors" + ); + + // AC-145-001 post-condition: both carry buffers must be drained empty + // after all records have been delivered. A non-zero carry here means + // the drain loop consumed fewer bytes than it appended — regression + // against the server-direction carry path. + prop_assert_eq!( + interleaved.client_hs_carry_len_for_testing(&flow_key), 0, + "carry_len_client must be 0 after complete C2S delivery (AC-145-001)" + ); + prop_assert_eq!( + interleaved.server_hs_carry_len_for_testing(&flow_key), 0, + "carry_len_server must be 0 after complete S2C delivery (AC-145-001)" + ); + } + } + + // ── VP-039 Sub-E-ext: cross-flow isolation ──────────────────────────────── + + // test_BC_2_07_041_cross_flow_isolation (unit): two distinct FlowKeys. + // Flow A: complete single-record ClientHello (SNI = "a.example") + + // complete single-record ServerHello (S2C). + // Flow B: fragmented two-record ClientHello (SNI = "b.example") + + // fragmented two-record ServerHello (S2C). + // + // After delivery: sni_counts must have exactly 2 entries, one for each SNI; + // both flow_a and flow_b must have server_hello_seen==true (ServerHello + // carry drain applies to each flow independently). No cross-flow bleed. + // + // STORY-145 wired the ServerToClient carry drain path; both flows pass. + // - Flow A's complete single-record ServerHello: the record payload is appended + // to an empty server_hs_carry; the drain loop calls `parse_tls_message_handshake` + // on the complete message and empties the carry → server_hello_seen == true. + // - Flow B's FRAGMENTED ServerHello: two partial records accumulate in + // server_hs_carry; the drain loop dispatches once the complete message arrives + // → server_hello_seen == true. + // + // Traces to: BC-2.07.041 v1.2 Invariants 1, 4; Postconditions 1, 4–5; + // BC-2.07.002 v1.6 Precondition 2; AC-145-003, AC-145-004. + #[test] + #[allow(non_snake_case)] + fn test_BC_2_07_041_cross_flow_isolation() { + let mut analyzer = TlsAnalyzer::new(); + let flow_a = make_test_flow_key(10); + let flow_b = make_test_flow_key(20); + let ts: u32 = 300; + + // ── Flow A ──────────────────────────────────────────────────────────── + // C2S: complete single-record ClientHello (SNI = "a.example"). + let a_client_hello = build_client_hello_with_sni("a.example"); + let a_c2s_rec = wrap_as_tls_record(0x16, &a_client_hello); + analyzer.on_data(&flow_a, Direction::ClientToServer, &a_c2s_rec, 0u64, ts); + + // S2C: complete single-record ServerHello (unified carry-buffer drain loop). + let a_server_hello = build_server_hello(); + let a_s2c_rec = wrap_as_tls_record(0x16, &a_server_hello); + analyzer.on_data(&flow_a, Direction::ServerToClient, &a_s2c_rec, 0u64, ts); + + // ── Flow B ──────────────────────────────────────────────────────────── + // C2S: fragmented two-record ClientHello (SNI = "b.example"). + let b_client_hello = build_client_hello_with_sni("b.example"); + let c2s_split = b_client_hello.len() / 2; + let b_c2s_rec1 = wrap_as_tls_record(0x16, &b_client_hello[..c2s_split]); + let b_c2s_rec2 = wrap_as_tls_record(0x16, &b_client_hello[c2s_split..]); + analyzer.on_data(&flow_b, Direction::ClientToServer, &b_c2s_rec1, 0u64, ts); + analyzer.on_data(&flow_b, Direction::ClientToServer, &b_c2s_rec2, 0u64, ts); + + // S2C: fragmented two-record ServerHello (requires server_hs_carry drain — + // the STORY-145 Red Gate path). + let b_server_hello = build_server_hello(); + let s2c_split = b_server_hello.len() / 2; + let b_s2c_rec1 = wrap_as_tls_record(0x16, &b_server_hello[..s2c_split]); + let b_s2c_rec2 = wrap_as_tls_record(0x16, &b_server_hello[s2c_split..]); + analyzer.on_data(&flow_b, Direction::ServerToClient, &b_s2c_rec1, 0u64, ts); + analyzer.on_data(&flow_b, Direction::ServerToClient, &b_s2c_rec2, 0u64, ts); + + // ── Assertions ──────────────────────────────────────────────────────── + + // Both flows must have client_hello_seen == true (STORY-144 carry drain). + assert!( + analyzer.client_hello_seen_for_testing(&flow_a), + "flow_a: client_hello_seen must be true after single-record C2S delivery" + ); + assert!( + analyzer.client_hello_seen_for_testing(&flow_b), + "flow_b: client_hello_seen must be true after fragmented C2S delivery" + ); + + // flow_a: server_hello_seen must be true (single-record S2C, carry drain loop). + assert!( + analyzer.server_hello_seen_for_testing(&flow_a), + "flow_a: server_hello_seen must be true after single-record S2C delivery" + ); + + // flow_b: server_hello_seen must be true (fragmented S2C, carry drain loop). + assert!( + analyzer.server_hello_seen_for_testing(&flow_b), + "flow_b: server_hello_seen must be true after fragmented S2C delivery \ + (regression: ServerToClient carry-buffer drain loop not dispatching)" + ); + + // No parse errors from any delivery path. + assert_eq!( + analyzer.parse_error_count(), + 0, + "cross-flow delivery must not produce parse errors" + ); + + // sni_counts: exactly 2 entries (one per flow); no cross-flow bleed. + let sni_counts = analyzer.sni_counts(); + assert_eq!( + sni_counts.len(), + 2, + "sni_counts must have exactly 2 entries (one per flow SNI); \ + cross-flow bleed or missing dispatch would produce wrong count. \ + got: {sni_counts:?}" + ); + assert_eq!( + sni_counts.get("a.example").copied().unwrap_or(0), + 1, + "a.example must appear exactly once in sni_counts" + ); + assert_eq!( + sni_counts.get("b.example").copied().unwrap_or(0), + 1, + "b.example must appear exactly once in sni_counts" + ); + + // ja3s_counts: both flows contributed a ServerHello → 1 or 2 entries + // (1 if both flows chose the same cipher fingerprint, 2 otherwise). + // The invariant is ja3s_counts.len() >= 1 (at least one JA3S was computed). + // Both flows delivered a complete ServerHello via the carry-buffer drain loop. + let ja3s_counts = analyzer.ja3s_counts(); + assert!( + !ja3s_counts.is_empty(), + "ja3s_counts must have at least 1 entry after ServerHello delivery to both flows \ + (regression: carry-buffer drain loop not dispatching flow_b's fragmented ServerHello)" + ); + + // Carries fully drained after complete delivery. + assert_eq!( + analyzer.client_hs_carry_len_for_testing(&flow_a), + 0, + "flow_a client_hs_carry must be empty after single-record C2S delivery" + ); + assert_eq!( + analyzer.client_hs_carry_len_for_testing(&flow_b), + 0, + "flow_b client_hs_carry must be empty after complete fragmented C2S delivery" + ); + assert_eq!( + analyzer.server_hs_carry_len_for_testing(&flow_a), + 0, + "flow_a server_hs_carry must be empty after single-record S2C delivery" + ); + assert_eq!( + analyzer.server_hs_carry_len_for_testing(&flow_b), + 0, + "flow_b server_hs_carry must be empty after complete fragmented S2C delivery \ + (regression: carry-buffer drain loop not consuming all bytes)" + ); + + // Active flows: both remain in the map (neither closed). + assert_eq!( + analyzer.active_flows_len_for_testing(), + 2, + "both flows must remain active (on_flow_close not called)" + ); + } + + // ── VP-039 Sub-C-S2C: server-direction Step-1 overflow guard ───────────── + + /// VP-039 Sub-C-S2C (unit): accumulating ServerToClient records past MAX_BUF + /// triggers the Step-1 pre-append overflow guard, clears `server_hs_carry`, + /// increments `handshake_reassembly_overflows`, and leaves the flow in + /// clear-and-recover state — a subsequent complete ServerHello is dispatched. + /// + /// This mirrors `test_vp039_carry_overflow_clear_and_recover` in mod story_144 + /// but drives `Direction::ServerToClient` with handshake msg_type `0x02`. + /// + /// Non-tautology argument: if the Step-1 guard at tls.rs:987-994 were removed, + /// `server_hs_carry` would grow past MAX_BUF=65536 without being cleared, the + /// overflow_count assertion would fail (counter stays at `overflows_before`), + /// and the `server_hs_carry_len == 0` assertion would fail (carry still holds + /// ~65504 bytes). + /// + /// Traces to: BC-2.07.039 v2.4 Postcondition 6 (clear-and-recover); + /// BC-2.07.041 v1.2 Invariant 2; AC-145-001. + #[test] + fn test_vp039_server_carry_overflow_clear_and_recover() { + let fk = make_test_flow_key(50); + let mut analyzer = TlsAnalyzer::new(); + + // Record 1: send a 4-byte handshake header with body_len=65500 (< MAX_BUF). + // msg_type=0x02 (ServerHello), body_len = 65500 = 0x00FFDC. + // Carry after record 1: 4 bytes. + let header_only: Vec = vec![0x02, 0x00, 0xFF, 0xDC]; + let record1 = wrap_as_tls_record(0x16, &header_only); + analyzer.on_data(&fk, Direction::ServerToClient, &record1, 0, 0); + + // Records 2–4: add 3 × 18,432 bytes (= 55,296 bytes) to server_hs_carry. + // After these records: carry = 4 + 55,296 = 55,300 bytes. No overflow yet. + for _ in 0..3 { + let chunk: Vec = vec![0xBB; 18_432]; + let rec = wrap_as_tls_record(0x16, &chunk); + analyzer.on_data(&fk, Direction::ServerToClient, &rec, 0, 0); + } + + // Record 5: 10,240 bytes → carry = 55,300 + 10,240 = 65,540 > 65,536. + // Step-1 pre-append guard fires: server_hs_carry cleared, overflow_count+1. + let overflows_before = analyzer.handshake_reassembly_overflow_count(); + let parse_errors_before = analyzer.parse_error_count(); + let findings_before = analyzer.all_findings_len_for_testing(); + + let overflow_trigger: Vec = vec![0xCC; 10_240]; + let record5 = wrap_as_tls_record(0x16, &overflow_trigger); + analyzer.on_data(&fk, Direction::ServerToClient, &record5, 0, 0); + + assert_eq!( + analyzer.handshake_reassembly_overflow_count(), + overflows_before + 1, + "server Step-1 guard: overflow_count must increment by exactly 1" + ); + assert_eq!( + analyzer.server_hs_carry_len_for_testing(&fk), + 0, + "server Step-1 guard: server_hs_carry must be cleared (len==0) after overflow" + ); + assert_eq!( + analyzer.parse_error_count(), + parse_errors_before, + "server Step-1 guard: parse_errors must NOT change on carry overflow" + ); + assert_eq!( + analyzer.all_findings_len_for_testing(), + findings_before, + "server Step-1 guard: findings must NOT change on carry overflow" + ); + + // Clear-and-recover: a subsequent complete single-record ServerHello must + // be dispatched normally (carry was cleared, not sticky-abandoned). + let sh_bytes = build_server_hello(); + let record_sh = wrap_as_tls_record(0x16, &sh_bytes); + analyzer.on_data(&fk, Direction::ServerToClient, &record_sh, 0, 0); + + assert!( + analyzer.server_hello_seen_for_testing(&fk), + "clear-and-recover: server_hello_seen must be true after post-overflow ServerHello" + ); + assert_eq!( + analyzer.server_hs_carry_len_for_testing(&fk), + 0, + "clear-and-recover: server_hs_carry must be empty after fully consumed ServerHello" + ); + } + + // ── VP-039 Sub-C-S2C: server-direction Decision-4 body_len-spoof guard ──── + + /// VP-039 Sub-C-S2C (unit): a ServerToClient handshake header whose declared + /// `body_len > MAX_BUF` triggers Decision-4, clears `server_hs_carry`, and + /// increments `handshake_reassembly_overflows` without inflating `parse_errors`. + /// + /// This mirrors `test_vp039_body_len_spoof` in mod story_144 but drives + /// `Direction::ServerToClient` with handshake msg_type `0x02` (ServerHello). + /// + /// Non-tautology argument: if the Decision-4 guard at tls.rs:1025-1033 were + /// removed, the drain loop would not clear on a spoofed body_len; it would + /// instead fall through to the "incomplete" check (`carry_len - consumed < + /// 4 + body_len`) and break without incrementing the counter. The + /// overflow_count assertion would fail (counter stays at `overflows_before`). + /// + /// Traces to: BC-2.07.038 v2.7 Invariant 5 / ADR-011 Decision 4; + /// BC-2.07.041 v1.2 Invariant 2; AC-145-001. + #[test] + fn test_vp039_server_body_len_spoof() { + let fk = make_test_flow_key(51); + let mut analyzer = TlsAnalyzer::new(); + + // A 0x16 record whose ServerHello handshake header declares body_len=65537 + // (> MAX_BUF=65536). 65537 = 0x010001 → 3-byte BE: [0x01, 0x00, 0x01]. + // msg_type=0x02 (ServerHello). + let spoof_header: Vec = vec![0x02, 0x01, 0x00, 0x01]; // body_len = 65537 + let record = wrap_as_tls_record(0x16, &spoof_header); + + let overflows_before = analyzer.handshake_reassembly_overflow_count(); + let parse_errors_before = analyzer.parse_error_count(); + let findings_before = analyzer.all_findings_len_for_testing(); + + analyzer.on_data(&fk, Direction::ServerToClient, &record, 0, 0); + + assert_eq!( + analyzer.handshake_reassembly_overflow_count(), + overflows_before + 1, + "server body_len spoof: Decision-4 must fire, overflow_count+1" + ); + assert_eq!( + analyzer.server_hs_carry_len_for_testing(&fk), + 0, + "server body_len spoof: server_hs_carry must be cleared after Decision-4" + ); + assert_eq!( + analyzer.parse_error_count(), + parse_errors_before, + "server body_len spoof: parse_errors must NOT change (Decision-4)" + ); + assert_eq!( + analyzer.all_findings_len_for_testing(), + findings_before, + "server body_len spoof: findings must NOT change (Decision-4)" + ); + } +}