diff --git a/CLAUDE.md b/CLAUDE.md index 6376a69..71434e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ The server also **watches the cert/key files referenced by the config** (grouped ``` TCP accept (SO_REUSEPORT per worker thread) - └─ sni.rs peek() — 1 syscall, 512-byte stack buf → PeekInfo { sni, ja3, ja4 } + └─ sni.rs peek() — MSG_PEEK ClientHello (reassembled when fragmented) → PeekInfo { sni, ja3, ja4, complete } └─ protection.rs check() → Block (emit 'blocked', drop) | Allow └─ router.rs RouteTable.resolve(sni) → Route └─ [suspended.rs register, emit 'suspended', await oneshot] @@ -53,8 +53,10 @@ TCP accept (SO_REUSEPORT per worker thread) ## Key design decisions -### MSG_PEEK for SNI/JA3 -A single `stream.peek(&mut buf[..512])` reads the ClientHello without consuming any bytes. Cost: 1 syscall, 512-byte stack buffer, zero heap allocation. This gives us both the SNI (for routing) and the JA3 fingerprint (for protection) before the TLS handshake begins. The alternative — a custom TLS acceptor that extracts SNI internally — would require modifying rustls internals. +### MSG_PEEK for SNI/JA3/JA4 +`stream.peek()` reads the ClientHello without consuming any bytes, giving SNI (routing) and the JA3/JA4 fingerprints (protection) before the TLS handshake begins. The alternative — a custom TLS acceptor that extracts SNI internally — would require modifying rustls internals. + +Because a single peek returns only whatever bytes are currently buffered, `peek()` **reassembles**: it reads the declared ClientHello length from the record+handshake headers and re-peeks (into a growing buffer, bounded by `MAX_CLIENT_HELLO` and `REASSEMBLY_TIMEOUT`) until the whole hello is present, setting `PeekInfo::complete`. This closes a blocklist-bypass: without it, a client fragments the ClientHello so symphony computes a different/empty fingerprint while rustls later accepts the full handshake. `protection.rs` fails closed on `!complete` when a JA3/JA4 blocklist is configured (`BlockReason::IncompleteHandshake`). ### Source-address & fingerprint forwarding (`upstream.rs` + `proxy_conn.rs`) Per route, `sourceAddressHeader` picks how the client IP reaches the upstream: PROXY v1 (text), diff --git a/README.md b/README.md index 22dc651..7b563ee 100644 --- a/README.md +++ b/README.md @@ -547,7 +547,16 @@ ja4Blocklist: [ ] ``` -Matching is case-insensitive. JA4 fingerprints are always emitted as lowercase. +Matching is case-insensitive. JA4 fingerprints are always emitted as lowercase. A blocklist +entry that isn't a structurally valid JA4 (or JA3) string is rejected at construction rather +than silently installed as an entry that could never match. + +**Fail-closed reassembly:** when a `ja3Blocklist` or `ja4Blocklist` is configured, symphony +reassembles the full ClientHello (bounded by size and a short timeout) before fingerprinting. +A connection whose ClientHello can't be fully reassembled — e.g. a client that fragments it to +expose SNI while withholding later extensions — is **blocked** (`blocked` reason +`incomplete_handshake`) rather than allowed on a partial/empty fingerprint. Without enforcement +configured, fingerprinting stays best-effort and an incomplete hello is not blocked. **License scope:** Symphony implements **core JA4** (TLS client fingerprinting) only. Core JA4 is BSD-licensed. The JA4+ suite of variants (JA4S, JA4H, JA4SSH, etc.) carries a separate FoxIO proprietary license and is **not implemented** here. diff --git a/__test__/protection.spec.ts b/__test__/protection.spec.ts index 49526d9..9a33bf0 100644 --- a/__test__/protection.spec.ts +++ b/__test__/protection.spec.ts @@ -605,3 +605,69 @@ describe('Protection – updateConfig hot-swap', () => { assert.equal(info.cidrBlocklist.length, 0, 'Expected cidrBlocklist to be empty after removal'); }); }); + +describe('Protection – fingerprint blocklist validation', () => { + const cert = generateSelfSignedCert('localhost'); + + function build(protection: any): SymphonyProxy { + return new SymphonyProxy({ + listeners: [{ host: '127.0.0.1', port: 0, protection }], + routes: [ + { + sni: 'localhost', + upstreams: [{ kind: 'tcp', host: '127.0.0.1', port: 1 }], + terminateTls: true, + cert: { certChain: cert.cert, privateKey: cert.key }, + }, + ], + }); + } + + it('rejects a malformed ja4Blocklist entry at construction', () => { + assert.throws(() => build({ ja4Blocklist: ['not-a-ja4'] }), /invalid ja4Blocklist entry/); + // Wrong length / bad separators / non-hex hash are all rejected. + assert.throws(() => build({ ja4Blocklist: ['t13d1516h2_8daaf6152771'] }), /invalid ja4Blocklist/); + assert.throws( + () => build({ ja4Blocklist: ['t13d1516h2_8daaf615277g_02713d6af862'] }), + /invalid ja4Blocklist/ + ); + }); + + it('accepts a well-formed ja4Blocklist entry', () => { + const proxy = build({ ja4Blocklist: ['t13d1516h2_8daaf6152771_02713d6af862'] }); + assert.ok(proxy); + }); + + it('rejects a malformed ja3Blocklist entry at construction', () => { + assert.throws(() => build({ ja3Blocklist: ['deadbeef'] }), /invalid ja3Blocklist entry/); + assert.throws(() => build({ ja3Blocklist: ['z'.repeat(32)] }), /invalid ja3Blocklist entry/); + }); + + it('accepts a well-formed ja3Blocklist entry', () => { + const proxy = build({ ja3Blocklist: ['0123456789abcdef0123456789abcdef'] }); + assert.ok(proxy); + }); + + it('accepts an uppercase ja4Blocklist entry (normalized before validation)', () => { + // Matching is documented as case-insensitive; validation must normalize first so a + // fully uppercase — but otherwise well-formed — fingerprint isn't wrongly rejected. + const proxy = build({ ja4Blocklist: ['T13D1516H2_8DAAF6152771_02713D6AF862'] }); + assert.ok(proxy); + }); + + it('rejects a ja4Blocklist entry symphony could never produce', () => { + // symphony only computes JA4 for TLS-over-TCP ('t') at versions it actually speaks + // (00/10/11/12/13) — a 'q'/'d' transport prefix or an unreachable version passes the + // old length/charset-only validation but can never match, so it must be rejected too. + assert.throws( + () => build({ ja4Blocklist: ['q13i070500_1234567890ab_abcdef012345'] }), + /invalid ja4Blocklist/, + 'QUIC prefix should be rejected', + ); + assert.throws( + () => build({ ja4Blocklist: ['t99d1516h2_8daaf6152771_02713d6af862'] }), + /invalid ja4Blocklist/, + 'unreachable version should be rejected', + ); + }); +}); diff --git a/src/protection.rs b/src/protection.rs index beefbcb..343bb8a 100644 --- a/src/protection.rs +++ b/src/protection.rs @@ -180,6 +180,9 @@ pub enum BlockReason { CidrBlocked, Ja3Blocked, Ja4Blocked, + /// A fingerprint blocklist is configured but the ClientHello could not be fully + /// reassembled, so the fingerprint can't be trusted — fail closed. + IncompleteHandshake, NoSni, RateLimited, TooManyConnections, @@ -193,6 +196,7 @@ impl BlockReason { Self::CidrBlocked => "cidr_blocked", Self::Ja3Blocked => "ja3_blocked", Self::Ja4Blocked => "ja4_blocked", + Self::IncompleteHandshake => "incomplete_handshake", Self::NoSni => "no_sni", Self::RateLimited => "rate_limited", Self::TooManyConnections => "too_many_connections", @@ -252,7 +256,16 @@ impl ProtectionState { } } - // 3. JA3 blocklist + // 3. Fingerprint enforcement. When a JA3/JA4 blocklist is configured, a ClientHello + // we couldn't fully reassemble must fail closed: otherwise a client fragments the + // hello so we compute a different/empty fingerprint here while rustls accepts the + // complete handshake — bypassing the blocklist. + let fingerprint_enforced = !cfg.ja3_blocklist.is_empty() || !cfg.ja4_blocklist.is_empty(); + if fingerprint_enforced && !peek_info.complete { + return Decision::Block(BlockReason::IncompleteHandshake); + } + + // 3a. JA3 blocklist if !cfg.ja3_blocklist.is_empty() && peek_info.ja3.len() == 32 { if let Some(bytes) = hex_to_bytes16(&peek_info.ja3) { if cfg.ja3_blocklist.contains(&bytes) { @@ -594,7 +607,7 @@ mod tests { } fn peek_with_ja3(hex: &str) -> PeekInfo { - PeekInfo { ja3: hex.to_string(), ..Default::default() } + PeekInfo { ja3: hex.to_string(), complete: true, ..Default::default() } } #[test] @@ -1357,4 +1370,43 @@ mod tests { "IP must be admitted fresh after re-enable; stale deadline must not resurrect" ); } + + // ── Fragmented-hello fail-closed tests ────────────────────────────────────── + + fn peek(ja4: &str, complete: bool) -> PeekInfo { + PeekInfo { sni: Some("x".into()), ja3: String::new(), ja4: ja4.into(), complete } + } + + fn state_with_ja4_blocklist() -> Arc { + let mut cfg = ProtectionConfig::default(); + cfg.ja4_blocklist.insert("t13d1516h2_8daaf6152771_02713d6af862".into()); + ProtectionState::new(cfg) + } + + #[test] + fn incomplete_hello_fails_closed_under_fingerprint_enforcement() { + let state = state_with_ja4_blocklist(); + let ip = "1.2.3.4".parse().unwrap(); + // A truncated ClientHello whose (untrusted) fingerprint happens not to match the + // blocklist must still be blocked — otherwise fragmentation bypasses enforcement. + let decision = state.check(ip, &peek("t13d0000zz_000000000000_000000000000", false)); + assert!(matches!(decision, Decision::Block(BlockReason::IncompleteHandshake))); + } + + #[test] + fn complete_hello_not_on_blocklist_is_allowed() { + let state = state_with_ja4_blocklist(); + let ip = "1.2.3.5".parse().unwrap(); + let decision = state.check(ip, &peek("t13d1516h2_ffffffffffff_ffffffffffff", true)); + assert!(matches!(decision, Decision::Allow(_))); + } + + #[test] + fn incomplete_hello_allowed_without_fingerprint_enforcement() { + // No blocklist configured: reassembly is best-effort only, incomplete is fine. + let state = ProtectionState::new(ProtectionConfig::default()); + let ip = "1.2.3.6".parse().unwrap(); + let decision = state.check(ip, &peek("", false)); + assert!(matches!(decision, Decision::Allow(_))); + } } diff --git a/src/proxy.rs b/src/proxy.rs index 2a3de10..85d8ca3 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -786,16 +786,30 @@ fn parse_protection_config( if let Some(ja3s) = &prot.ja3_blocklist { for hex in ja3s { - if let Some(bytes) = hex_to_bytes16(hex) { - cfg.ja3_blocklist.insert(bytes); - } + // A JA3 is an MD5 digest — exactly 32 hex chars. Reject typos rather than + // silently installing an entry that can never match. + let bytes = hex_to_bytes16(hex).ok_or_else(|| { + napi::Error::from_reason(format!( + "invalid ja3Blocklist entry '{hex}': expected 32 hex characters" + )) + })?; + cfg.ja3_blocklist.insert(bytes); } } if let Some(ja4s) = &prot.ja4_blocklist { for s in ja4s { - // Normalize to lowercase for case-insensitive matching. - cfg.ja4_blocklist.insert(s.to_lowercase().into_boxed_str()); + // Normalize to lowercase *before* validating — matching is case-insensitive, so an + // otherwise-well-formed uppercase fingerprint must not be rejected here. + let lower = s.to_lowercase(); + // Validate against what `compute_ja4` can actually produce, so a malformed or + // unreachable entry fails at config time instead of silently never matching. + if !is_valid_ja4(&lower) { + return Err(napi::Error::from_reason(format!( + "invalid ja4Blocklist entry '{s}': expected JA4 format t_<12 hex>_<12 hex> (36 chars)" + ))); + } + cfg.ja4_blocklist.insert(lower.into_boxed_str()); } } @@ -881,6 +895,34 @@ fn num_cpus() -> u32 { .unwrap_or(4) } +/// Validate a JA4 core-TLS fingerprint string: `t_<12hex>_<12hex>` +/// (36 chars). Expects lowercase input (call sites normalize before validating). Restricted to +/// exactly what `sni::compute_ja4` can produce — not the full JA4 spec — since a blocklist entry +/// symphony itself can never emit (a `q`/`d` transport prefix, or a TLS version it doesn't speak) +/// would pass validation yet can never match, defeating the point of validating it at all. +fn is_valid_ja4(s: &str) -> bool { + let b = s.as_bytes(); + if b.len() != 36 || b[10] != b'_' || b[23] != b'_' { + return false; + } + let a = &b[..10]; + // protocol: symphony only speaks TLS-over-TCP, so only 't' (never 'q'=QUIC, 'd'=DTLS). + // tls version: only the 2-digit strings compute_ja4's ver_str match can emit. + // sni presence (d=domain/i=ip), cipher count (2 digits), extension count (2 digits), + // alpn first/last (2 alphanumeric). + a[0] == b't' + && matches!((a[1], a[2]), (b'0', b'0') | (b'1', b'0') | (b'1', b'1') | (b'1', b'2') | (b'1', b'3')) + && matches!(a[3], b'd' | b'i') + && a[4].is_ascii_digit() + && a[5].is_ascii_digit() + && a[6].is_ascii_digit() + && a[7].is_ascii_digit() + && a[8].is_ascii_alphanumeric() + && a[9].is_ascii_alphanumeric() + && b[11..23].iter().all(u8::is_ascii_hexdigit) + && b[24..36].iter().all(u8::is_ascii_hexdigit) +} + fn hex_to_bytes16(hex: &str) -> Option<[u8; 16]> { if hex.len() != 32 { return None; @@ -1010,4 +1052,43 @@ mod tests { }; assert!(parse_protection_config(&prot).is_ok(), "absent burst must be accepted"); } + + #[test] + fn valid_ja4_accepts_well_formed() { + assert!(is_valid_ja4("t13d1516h2_8daaf6152771_02713d6af862")); + assert!(is_valid_ja4("t00i070500_1234567890ab_abcdef012345")); // version 00, no-SNI variant + for ver in ["00", "10", "11", "12", "13"] { + let s = format!("t{ver}d1516h2_8daaf6152771_02713d6af862"); + assert!(is_valid_ja4(&s), "version {ver} should be accepted: {s}"); + } + } + + #[test] + fn valid_ja4_rejects_malformed() { + assert!(!is_valid_ja4(""), "empty"); + assert!(!is_valid_ja4("t13d1516h2_8daaf6152771"), "missing part C"); + assert!(!is_valid_ja4("t13d1516h2_8daaf6152771_02713d6af86"), "part C too short"); + assert!(!is_valid_ja4("t13d1516h2_8daaf6152771_02713d6af862x"), "too long"); + assert!(!is_valid_ja4("t13d1516h2-8daaf6152771-02713d6af862"), "wrong separators"); + assert!(!is_valid_ja4("t13d1516h2_8daaf615277g_02713d6af862"), "non-hex in part B"); + assert!(!is_valid_ja4("x13d1516h2_8daaf6152771_02713d6af862"), "bad protocol char"); + assert!(!is_valid_ja4("t1xd1516h2_8daaf6152771_02713d6af862"), "non-digit version"); + // Uppercase must be rejected here — call sites are expected to normalize before + // validating; is_valid_ja4 itself only matches the lowercase output compute_ja4 emits. + assert!(!is_valid_ja4("T13D1516H2_8DAAF6152771_02713D6AF862"), "uppercase"); + } + + #[test] + fn valid_ja4_rejects_transports_and_versions_symphony_cannot_emit() { + // symphony only speaks TLS-over-TCP: 'q' (QUIC) and 'd' (DTLS) transport prefixes can + // never match a fingerprint symphony itself computes. + assert!(!is_valid_ja4("q13i070500_1234567890ab_abcdef012345"), "QUIC prefix"); + assert!(!is_valid_ja4("d13i070500_1234567890ab_abcdef012345"), "DTLS prefix"); + // compute_ja4's ver_str only ever emits 00/10/11/12/13 — any other 2-digit value can + // never match either. + for ver in ["01", "02", "14", "20", "99"] { + let s = format!("t{ver}d1516h2_8daaf6152771_02713d6af862"); + assert!(!is_valid_ja4(&s), "version {ver} should be rejected (unreachable): {s}"); + } + } } diff --git a/src/proxy_conn.rs b/src/proxy_conn.rs index 72c0d72..21757c1 100644 --- a/src/proxy_conn.rs +++ b/src/proxy_conn.rs @@ -60,14 +60,28 @@ pub async fn handle(stream: TcpStream, peer_addr: SocketAddr, ctx: Arc that incremented the active counter. - // We hold it in ip_state_held so the ActiveGuard can decrement the SAME entry even - // if eviction removes it from the map between admission and connection close (fix 6). - let ip_state_held: Option> = if let Some(protection) = &ctx.protection { + // We hold it on active_guard so it decrements the SAME entry even if eviction removes + // it from the map between admission and connection close (fix 6). + if let Some(protection) = &ctx.protection { match protection.check(peer_ip, &peek_info) { crate::protection::Decision::Block(reason) => { ctx.listener_metrics.inc_blocked(); @@ -79,28 +93,16 @@ pub async fn handle(stream: TcpStream, peer_addr: SocketAddr, ctx: Arc None, - crate::protection::Decision::Allow(state) => Some(state), + crate::protection::Decision::AllowBypassed => {} + crate::protection::Decision::Allow(state) => active_guard.ip_state = Some(state), } - } else { - None - }; - - // ── Connection is allowed — track it ───────────────────────────────────── - ctx.listener_metrics.inc_active(); - ctx.global_metrics.inc_active(); + } // RAII: decrement counts on scope exit. - // Decrement the active counter through the held Arc so it always hits the - // same entry that was incremented, regardless of concurrent eviction. - let _active_guard = ActiveGuard { - global: ctx.global_metrics.clone(), - listener: ctx.listener_metrics.clone(), - ip_state: ip_state_held, - }; + let _active_guard = active_guard; // ── 3. Route lookup ─────────────────────────────────────────────────────── let table = ctx.route_table.0.load(); diff --git a/src/sni.rs b/src/sni.rs index 5b0dc94..c2cbc05 100644 --- a/src/sni.rs +++ b/src/sni.rs @@ -1,6 +1,25 @@ +use std::time::Duration; use tokio::net::TcpStream; +/// Largest ClientHello we will buffer while reassembling before giving up. A normal +/// ClientHello is well under this; the cap bounds memory against a client that declares a +/// huge handshake length and dribbles bytes. +const MAX_CLIENT_HELLO: usize = 16 * 1024; +/// Initial peek buffer — covers the common single-segment ClientHello without a second syscall. +const INITIAL_PEEK: usize = 4096; +/// How long to wait for the full declared ClientHello before parsing what we have and +/// marking the result incomplete. Short: a client that hasn't sent its ClientHello promptly +/// is either slow or probing. +const REASSEMBLY_TIMEOUT: Duration = Duration::from_secs(5); +/// Poll interval between peeks while waiting for more of a partial ClientHello. MSG_PEEK +/// doesn't consume, so socket readiness stays asserted — we can't await "more than what's +/// buffered"; a small sleep keeps a stalled client from spinning a core until the timeout. +const PEEK_POLL_INTERVAL: Duration = Duration::from_millis(10); + /// Information extracted from a TLS ClientHello via MSG_PEEK. +/// +/// The derived `Default` (`sni: None`, `ja3`/`ja4` empty, `complete: false`) is exactly the +/// "unreadable/absent ClientHello" case — an incomplete hello with no trustworthy fingerprint. #[derive(Debug, Default)] pub struct PeekInfo { /// SNI hostname, if present in the ClientHello. @@ -12,17 +31,140 @@ pub struct PeekInfo { /// FoxIO proprietary license). Format: t__. /// Empty string if the ClientHello could not be parsed. pub ja4: String, + /// True when the full declared ClientHello was buffered before fingerprinting. When + /// false, the fingerprints are computed from a truncated hello and must not be trusted + /// for enforcement — a client can fragment the ClientHello to force a different/empty + /// fingerprint here while rustls later reads the complete handshake (blocklist bypass). + pub complete: bool, +} + +/// Result of scanning as many buffered TLS records as are available for a ClientHello. +enum RecordScan { + /// The buffered prefix can never become a valid ClientHello (wrong record content-type + /// or wrong handshake message-type) — fail fast rather than waiting out the timeout. + Invalid, + /// Not enough raw bytes buffered yet to make further progress; re-peek once at least + /// this many raw wire bytes are available. + NeedMoreRaw(usize), + /// The complete handshake-layer payload (msg_type(1) + length(3) + body, with every TLS + /// record header stripped out) has been reassembled. + Complete(Vec), +} + +/// Walk `raw` (the raw MSG_PEEK'd wire bytes, which may span several TLS records) as a +/// sequence of TLS record headers, stripping each 5-byte record header and concatenating the +/// record payloads into the handshake-layer byte stream. A ClientHello whose handshake +/// message is larger than one TLS record's payload is split across multiple `0x16` Handshake +/// records — treating the raw buffer as a single record (the previous approach) let a +/// continuation record's header bytes leak into the parsed body/extensions and could report +/// `complete=true` before the real payload was fully buffered, silently producing a wrong +/// fingerprint and bypassing blocklist enforcement. +fn scan_handshake_records(raw: &[u8]) -> RecordScan { + let mut offset = 0usize; + let mut hs_buf: Vec = Vec::new(); + let mut declared_hs_len: Option = None; // 4 (handshake header) + handshake body length + + loop { + if let Some(total) = declared_hs_len { + if hs_buf.len() >= total { + hs_buf.truncate(total); + return RecordScan::Complete(hs_buf); + } + } + + // Fail fast on the next expected record's content-type byte as soon as it's visible — + // non-TLS traffic (e.g. plain HTTP) or a non-Handshake record can never become a + // ClientHello no matter how many more bytes arrive. + if raw.len() > offset && raw[offset] != 0x16 { + return RecordScan::Invalid; + } + if raw.len() < offset + 5 { + return RecordScan::NeedMoreRaw(offset + 5); + } + let record_len = u16::from_be_bytes([raw[offset + 3], raw[offset + 4]]) as usize; + let record_end = offset + 5 + record_len; + if raw.len() < record_end { + return RecordScan::NeedMoreRaw(record_end); + } + hs_buf.extend_from_slice(&raw[offset + 5..record_end]); + offset = record_end; + + // Fail fast on the handshake message-type byte as soon as it's visible, and learn the + // declared handshake length once the 4-byte handshake header is complete. + if declared_hs_len.is_none() && !hs_buf.is_empty() { + if hs_buf[0] != 0x01 { + return RecordScan::Invalid; // not a ClientHello handshake message + } + if hs_buf.len() >= 4 { + let hs_len = ((hs_buf[1] as usize) << 16) | ((hs_buf[2] as usize) << 8) | (hs_buf[3] as usize); + declared_hs_len = Some(4 + hs_len); + } + } + } +} + +/// Wrap reassembled handshake-layer bytes (msg_type + length + body) back into the +/// single-record wire format `parse_client_hello` expects. The record's declared length and +/// legacy version are placeholders — `parse_client_hello` only skips over them. +fn wrap_as_record(hs_bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(5 + hs_bytes.len()); + out.push(0x16); + out.extend_from_slice(&[0x03, 0x01]); + out.extend_from_slice(&(hs_bytes.len() as u16).to_be_bytes()); + out.extend_from_slice(hs_bytes); + out } -/// Peek at the first 4096 bytes of the stream and parse the TLS ClientHello. -/// Does NOT consume any bytes from the stream. +/// Peek at the stream and parse the TLS ClientHello, reassembling until the full declared +/// hello is buffered (bounded by `MAX_CLIENT_HELLO` and `REASSEMBLY_TIMEOUT`). Does NOT +/// consume any bytes. `PeekInfo::complete` reports whether the whole hello was captured. pub async fn peek(stream: &TcpStream) -> PeekInfo { - let mut buf = [0u8; 4096]; - let n = match stream.peek(&mut buf).await { - Ok(n) => n, - Err(_) => return PeekInfo::default(), - }; - parse_client_hello(&buf[..n]) + peek_with_timeout(stream, REASSEMBLY_TIMEOUT).await +} + +async fn peek_with_timeout(stream: &TcpStream, timeout: Duration) -> PeekInfo { + match tokio::time::timeout(timeout, peek_reassemble(stream)).await { + Ok(Some((buf, complete))) => { + let mut info = parse_client_hello(&buf); + // A parse from a truncated buffer is never trustworthy for enforcement, even if + // it happened to yield a fingerprint. + info.complete = complete; + info + } + // Timed out mid-reassembly, or the peek errored: parse whatever last arrived, if any. + Ok(None) => PeekInfo::default(), + Err(_) => PeekInfo::default(), + } +} + +/// Peek repeatedly until the full declared ClientHello is buffered or the size cap is hit. +/// Returns `(buffer, complete)`, or `None` if the input is not a TLS ClientHello at all (fail +/// fast) or the stream errored before any bytes. +async fn peek_reassemble(stream: &TcpStream) -> Option<(Vec, bool)> { + let mut buf = vec![0u8; INITIAL_PEEK]; + loop { + let n = match stream.peek(&mut buf).await { + Ok(0) => return None, // peer closed before sending a ClientHello + Ok(n) => n, + Err(_) => return None, + }; + match scan_handshake_records(&buf[..n]) { + RecordScan::Invalid => return None, + RecordScan::Complete(hs_bytes) => return Some((wrap_as_record(&hs_bytes), true)), + RecordScan::NeedMoreRaw(target) => { + if target > MAX_CLIENT_HELLO { + // Declared hello exceeds our cap — parse what we have, marked incomplete. + return Some((buf[..n].to_vec(), false)); + } + if buf.len() < target { + buf.resize(target, 0); + } + } + } + // Wait before re-peeking. The outer timeout bounds the total wait; this just paces + // the poll so a stalled partial hello can't spin a core. + tokio::time::sleep(PEEK_POLL_INTERVAL).await; + } } fn parse_client_hello(buf: &[u8]) -> PeekInfo { @@ -33,7 +175,9 @@ fn parse_client_hello(buf: &[u8]) -> PeekInfo { let sni = extract_sni(hello.extensions); let ja3 = compute_ja3(hello.legacy_version, hello.cipher_suites, hello.extensions); let ja4 = compute_ja4(hello.legacy_version, hello.cipher_suites, hello.extensions); - PeekInfo { sni, ja3, ja4 } + // `complete` is set by the caller (`peek`) from the reassembly result — a direct parse + // here makes no claim about whether the buffer held the whole hello. + PeekInfo { sni, ja3, ja4, complete: false } } // ── TLS record / ClientHello structures ────────────────────────────────────── @@ -209,6 +353,21 @@ fn sha256_hex12(data: &[u8]) -> String { bytes_to_hex(&hash[..6]) // 6 bytes = 12 hex chars } +/// Write `values` as comma-separated 4-hex (`%04x`) tokens into a single pre-allocated +/// String. `compute_ja4` runs on the peek hot path (every connection), so this avoids the +/// per-token `format!` + `Vec` + `join` churn of the naive `.map().collect().join()`. +fn join_hex4(values: &[u16]) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(values.len() * 5); // 4 hex + 1 comma + for (i, &v) in values.iter().enumerate() { + if i > 0 { + out.push(','); + } + let _ = write!(out, "{v:04x}"); + } + out +} + fn compute_ja3(legacy_version: u16, cipher_suites: &[u8], extensions: &[u8]) -> String { // Collect cipher suite values, skipping GREASE let ciphers: Vec = cipher_suites @@ -349,7 +508,13 @@ fn compute_ja4(legacy_version: u16, cipher_suites: &[u8], extensions: &[u8]) -> let list_len = u16::from_be_bytes([ext_data[0], ext_data[1]]) as usize; let algs = &ext_data[2..]; for chunk in algs[..list_len.min(algs.len())].chunks_exact(2) { - sig_algs.push(u16::from_be_bytes([chunk[0], chunk[1]])); + let v = u16::from_be_bytes([chunk[0], chunk[1]]); + // Per JA4 spec, GREASE values are ignored wherever they appear — + // including signature algorithms — else the fingerprint won't match + // standard JA4 tooling. + if !is_grease(v) { + sig_algs.push(v); + } } } } @@ -417,8 +582,7 @@ fn compute_ja4(legacy_version: u16, cipher_suites: &[u8], extensions: &[u8]) -> let part_b = if ciphers.is_empty() { "000000000000".to_string() } else { - let cipher_str = ciphers.iter().map(|v| format!("{v:04x}")).collect::>().join(","); - sha256_hex12(cipher_str.as_bytes()) + sha256_hex12(join_hex4(&ciphers).as_bytes()) }; // Part C: SHA-256/12 of (sorted ext IDs, GREASE/SNI/ALPN excluded) optionally @@ -429,14 +593,13 @@ fn compute_ja4(legacy_version: u16, cipher_suites: &[u8], extensions: &[u8]) -> .copied() .collect(); ext_for_hash.sort_unstable(); - let ext_str = ext_for_hash.iter().map(|v| format!("{v:04x}")).collect::>().join(","); + let ext_str = join_hex4(&ext_for_hash); // Per JA4 spec: with no signature algorithms the string ends without a trailing // underscore. Gate on the parsed list, not extension presence — a present-but-empty // or truncated sig_algs extension must not append a bare '_'. let hash_input = if !sig_algs.is_empty() { - let sig_str = sig_algs.iter().map(|v| format!("{v:04x}")).collect::>().join(","); - format!("{ext_str}_{sig_str}") + format!("{ext_str}_{}", join_hex4(&sig_algs)) } else { ext_str }; @@ -724,6 +887,239 @@ mod tests { assert_ne!(part_c, sha256_hex12(b"000d_"), "must not hash a trailing '_': {}", info.ja4); } + #[test] + fn test_ja4_grease_signature_algorithm_ignored() { + // A GREASE signature algorithm must be excluded from JA4_c, else the fingerprint + // diverges from standard JA4 tooling (RFC 8701: ignore GREASE wherever it appears). + let real = make_client_hello(0x0303, &[0x1301], &[make_sig_algs_ext(&[0x0403, 0x0804])]); + let with_grease = + make_client_hello(0x0303, &[0x1301], &[make_sig_algs_ext(&[0x0403, 0x0A0A, 0x0804])]); + assert_eq!( + parse_client_hello(&real).ja4, + parse_client_hello(&with_grease).ja4, + "GREASE sig alg must not change JA4" + ); + } + + #[test] + fn test_join_hex4() { + assert_eq!(join_hex4(&[]), ""); + assert_eq!(join_hex4(&[0x1301]), "1301"); + assert_eq!(join_hex4(&[0x0403, 0x0804, 0x0001]), "0403,0804,0001"); + } + + fn scan_complete(raw: &[u8]) -> Option> { + match scan_handshake_records(raw) { + RecordScan::Complete(hs) => Some(hs), + _ => None, + } + } + + #[test] + fn test_scan_handshake_records_single_record() { + let ch = make_client_hello(0x0303, &[0x1301], &[]); + let hs_len = ((ch[6] as usize) << 16) | ((ch[7] as usize) << 8) | (ch[8] as usize); + let hs = scan_complete(&ch).expect("single record should be complete"); + assert_eq!(hs.len(), 4 + hs_len); + assert_eq!(hs, &ch[5..]); + } + + #[test] + fn test_scan_handshake_records_needs_more() { + let ch = make_client_hello(0x0303, &[0x1301], &[]); + // Fewer than 5 bytes: still waiting on the record header itself. + assert!(matches!(scan_handshake_records(&ch[..4]), RecordScan::NeedMoreRaw(5))); + // Record header known, but the record's declared payload isn't fully buffered yet. + assert!(matches!(scan_handshake_records(&ch[..8]), RecordScan::NeedMoreRaw(n) if n == ch.len())); + assert!(matches!(scan_handshake_records(&ch[..ch.len() - 1]), RecordScan::NeedMoreRaw(n) if n == ch.len())); + } + + #[test] + fn test_scan_handshake_records_rejects_non_handshake_record() { + // content_type 0x17 = application data, not a Handshake record. + assert!(matches!( + scan_handshake_records(&[0x17, 0x03, 0x03, 0, 5]), + RecordScan::Invalid + )); + // A single byte is already enough to know plain HTTP ('G') isn't TLS. + assert!(matches!(scan_handshake_records(b"G"), RecordScan::Invalid)); + } + + #[test] + fn test_scan_handshake_records_rejects_non_client_hello_message_type() { + // content_type Handshake, but msg_type 0x02 (ServerHello) instead of 0x01 (ClientHello). + let mut record = vec![0x16, 0x03, 0x03, 0x00, 0x04]; + record.extend_from_slice(&[0x02, 0x00, 0x00, 0x00]); + assert!(matches!(scan_handshake_records(&record), RecordScan::Invalid)); + } + + #[test] + fn test_scan_handshake_records_reassembles_across_multiple_records() { + // One ClientHello handshake message split across two TLS Handshake records — each + // with its own 5-byte record header — must reassemble to the same handshake bytes as + // the equivalent single-record encoding. + let ch = make_client_hello(0x0303, &[0x1301, 0x1302], &[make_sni_ext(b"example.com")]); + let hs_layer = &ch[5..]; // handshake header + body, no record header + let split = hs_layer.len() / 2; + let mut multi = Vec::new(); + for chunk in [&hs_layer[..split], &hs_layer[split..]] { + multi.push(0x16); + multi.extend_from_slice(&[0x03, 0x01]); + multi.extend_from_slice(&(chunk.len() as u16).to_be_bytes()); + multi.extend_from_slice(chunk); + } + let hs = scan_complete(&multi).expect("multi-record hello should reassemble to complete"); + assert_eq!(hs, hs_layer, "reassembled handshake bytes must match the single-record encoding"); + } + + // ── peek reassembly (fragmented ClientHello) ────────────────────────────── + + // Drive `peek()` against one end of a real connected TCP pair, with `client_writes` + // driving the other end (a closure that owns the client socket). + async fn peek_with_client(timeout: Duration, hello_writer: F) -> PeekInfo + where + F: FnOnce(tokio::net::TcpStream) -> Fut + Send + 'static, + Fut: std::future::Future + Send, + { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = tokio::spawn(async move { + let sock = tokio::net::TcpStream::connect(addr).await.unwrap(); + hello_writer(sock).await; + }); + let (server, _) = listener.accept().await.unwrap(); + let info = peek_with_timeout(&server, timeout).await; + client.abort(); // the writer may still be stalling; we have our result + let _ = client.await; + info + } + + #[tokio::test] + async fn peek_reassembles_fragmented_client_hello() { + // A large ClientHello sent in two segments with a gap must still be fully parsed and + // marked complete — this is the blocklist-bypass case. + let mut exts = vec![make_sni_ext(b"example.com")]; + exts.push(make_sig_algs_ext(&[0x0403, 0x0804, 0x0805, 0x0806, 0x0401])); + // Pad past the initial peek buffer so reassembly must fetch a second time. + exts.push(make_extension(0xABCD, &vec![0u8; 5000])); + let hello = make_client_hello(0x0303, &[0x1301, 0x1302, 0x1303], &exts); + assert!(hello.len() > INITIAL_PEEK, "test hello must exceed one peek buffer"); + let split = 200; + + let info = peek_with_client(Duration::from_secs(5), move |mut sock| async move { + use tokio::io::AsyncWriteExt; + sock.write_all(&hello[..split]).await.unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + sock.write_all(&hello[split..]).await.unwrap(); + // Keep the socket open so peek's MSG_PEEK still sees the buffered bytes. + tokio::time::sleep(Duration::from_secs(2)).await; + }) + .await; + + assert!(info.complete, "fragmented hello should reassemble to complete"); + assert_eq!(info.sni.as_deref(), Some("example.com")); + assert_eq!(info.ja4.len(), 36); + } + + #[tokio::test] + async fn peek_marks_incomplete_when_client_stalls_mid_hello() { + // A client that sends only part of a declared ClientHello and stalls must yield + // complete=false (so the caller can fail closed under fingerprint enforcement), + // without blocking past the reassembly timeout. + let hello = make_client_hello(0x0303, &[0x1301], &[make_extension(0xABCD, &vec![0u8; 3000])]); + let partial = hello[..100].to_vec(); + + // Short injected timeout so the test doesn't wait out the production 5s. + let info = peek_with_client(Duration::from_millis(200), move |mut sock| async move { + use tokio::io::AsyncWriteExt; + sock.write_all(&partial).await.unwrap(); + // Stall past the (injected) reassembly timeout without sending the rest. + tokio::time::sleep(Duration::from_secs(30)).await; + }) + .await; + + assert!(!info.complete, "a stalled partial hello must be marked incomplete"); + } + + #[tokio::test] + async fn peek_reassembles_client_hello_split_across_multiple_tls_records() { + // The blocklist-bypass case hdbjeff's review flagged: a ClientHello divided across + // TWO separate TLS Handshake records (not just two TCP writes of one record). Each + // continuation record's own 5-byte header must be stripped, not parsed as hello body. + let hello = make_client_hello( + 0x0303, + &[0x1301, 0x1302], + &[make_sni_ext(b"example.com"), make_sig_algs_ext(&[0x0403, 0x0804])], + ); + let hs_layer = hello[5..].to_vec(); // handshake header + body, no record header + let split = hs_layer.len() / 2; + let mut wire = Vec::new(); + for chunk in [&hs_layer[..split], &hs_layer[split..]] { + wire.push(0x16); + wire.extend_from_slice(&[0x03, 0x01]); + wire.extend_from_slice(&(chunk.len() as u16).to_be_bytes()); + wire.extend_from_slice(chunk); + } + + let expected = parse_client_hello(&hello); + + let info = peek_with_client(Duration::from_secs(5), move |mut sock| async move { + use tokio::io::AsyncWriteExt; + sock.write_all(&wire).await.unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; + }) + .await; + + assert!(info.complete, "hello split across TLS records should reassemble to complete"); + assert_eq!(info.sni.as_deref(), Some("example.com")); + assert_eq!( + info.ja4, expected.ja4, + "fingerprint from a multi-record hello must match the equivalent single-record hello \ + — otherwise a JA4 blocklist entry for this client can be bypassed by splitting records" + ); + } + + #[tokio::test] + async fn peek_fails_fast_on_non_tls_traffic() { + // Plain HTTP (or any non-TLS prefix) must be rejected as soon as the first byte proves + // it, not after burning the full reassembly timeout polling for more bytes that will + // never turn it into a ClientHello (pre-protection DoS). + let start = std::time::Instant::now(); + let info = peek_with_client(Duration::from_secs(5), |mut sock| async move { + use tokio::io::AsyncWriteExt; + sock.write_all(b"GET / HTTP/1.1\r\n\r\n").await.unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; + }) + .await; + assert!(!info.complete); + assert!( + start.elapsed() < Duration::from_secs(1), + "non-TLS input should be rejected promptly, not after the full timeout: {:?}", + start.elapsed() + ); + } + + #[tokio::test] + async fn peek_fails_fast_on_non_client_hello_handshake() { + // A Handshake record whose message type isn't ClientHello (e.g. a stray ServerHello) + // must also be rejected promptly rather than polling out the timeout. + let mut record = vec![0x16, 0x03, 0x03, 0x00, 0x04]; + record.extend_from_slice(&[0x02, 0x00, 0x00, 0x00]); // msg_type = ServerHello + let start = std::time::Instant::now(); + let info = peek_with_client(Duration::from_secs(5), move |mut sock| async move { + use tokio::io::AsyncWriteExt; + sock.write_all(&record).await.unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; + }) + .await; + assert!(!info.complete); + assert!( + start.elapsed() < Duration::from_secs(1), + "non-ClientHello handshake should be rejected promptly, not after the full timeout: {:?}", + start.elapsed() + ); + } + #[test] fn test_ja4_known_vector() { // Synthetic vector: verify Part A exactly and that Parts B/C are consistent