blaze-host: passive ClientHello observer for the redirector TLS decision
The redirector TLS question cannot be answered from the cipher OpenSSL selected: its server follows client preference by default, so FIFA preferring static RSA does not prove ECDHE was unavailable. Choosing a TLS stack on that inference would be a guess. This reads the actual ClientHello. PASSIVE BY CONSTRUCTION. Bytes relay verbatim both ways, nothing is injected or rewritten, and the handshake is still terminated by the untouched Python redirector. A parse failure logs and relays anyway -- observation must never be able to break the path it observes. Reports record/client version, supported_versions, SNI, every offered suite by name, extensions, and a verdict on whether ANY forward-secret suite is offered, which is exactly the rustls question. Unknown suites print as hex rather than being dropped. Verified end to end against the live Python redirector with openssl s_client: 31 offered suites parsed, 18 classified forward-secret, and Python logged the relayed request and served its 406B serverinstanceinfo -- proving observation AND pass-through in one run. Unit-tested on truncated and non-TLS input; the verdict is asserted in both directions so a static-RSA-only hello reports RULED OUT rather than defaulting to the permissive answer. NOTE: that 18-suite result is from openssl s_client, NOT from FIFA. It proves the instrument works. The actual question is still open until a retail FIFA ClientHello is captured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,10 @@ path = "src/main.rs"
|
||||
name = "blaze-probe"
|
||||
path = "src/bin/blaze-probe.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tls-observe"
|
||||
path = "src/bin/tls-observe.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "blaze-sanitize"
|
||||
path = "src/bin/blaze-sanitize.rs"
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Passive TLS ClientHello observer for the Blaze redirector.
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA ──> tls-observe ──(raw bytes, unmodified)──> Python redirector
|
||||
//! │
|
||||
//! └── parses and reports the ClientHello
|
||||
//! ```
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! Every observed redirector handshake selected `AES256-GCM-SHA384` — TLS 1.2
|
||||
//! with **static RSA key exchange**. `rustls` supports only forward-secret
|
||||
//! (EC)DHE suites, so if that is all the client offers, rustls is ruled out for
|
||||
//! a Rust redirector and an OpenSSL-backed stack is required.
|
||||
//!
|
||||
//! But **the selected cipher does not reveal what was offered.** OpenSSL's
|
||||
//! server follows the client's preference order by default, so preferring
|
||||
//! static RSA does not prove ECDHE was unavailable. Choosing a TLS stack on
|
||||
//! that inference would be exactly the kind of guess this project keeps
|
||||
//! refusing to make. So: read the actual ClientHello.
|
||||
//!
|
||||
//! # Passive by construction
|
||||
//!
|
||||
//! Bytes are forwarded verbatim in both directions and nothing is injected,
|
||||
//! rewritten or delayed beyond a parse of the first record. The handshake is
|
||||
//! still terminated by the untouched Python redirector, so a FIFA session runs
|
||||
//! exactly as it otherwise would. If parsing fails, the proxy still relays —
|
||||
//! observation must never be able to break the path it is observing.
|
||||
//!
|
||||
//! ```text
|
||||
//! tls-observe --listen 0.0.0.0:42227 --upstream 127.0.0.1:42127
|
||||
//! ```
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
// ---------------------------------------------------------------- TLS names
|
||||
|
||||
/// Cipher suites we care about naming. Not exhaustive: unknown values are
|
||||
/// reported as hex so nothing is silently dropped.
|
||||
fn cipher_name(id: u16) -> &'static str {
|
||||
match id {
|
||||
0x0004 => "TLS_RSA_WITH_RC4_128_MD5",
|
||||
0x0005 => "TLS_RSA_WITH_RC4_128_SHA",
|
||||
0x000A => "TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
0x002F => "TLS_RSA_WITH_AES_128_CBC_SHA",
|
||||
0x0035 => "TLS_RSA_WITH_AES_256_CBC_SHA",
|
||||
0x003C => "TLS_RSA_WITH_AES_128_CBC_SHA256",
|
||||
0x003D => "TLS_RSA_WITH_AES_256_CBC_SHA256",
|
||||
0x009C => "TLS_RSA_WITH_AES_128_GCM_SHA256",
|
||||
0x009D => "TLS_RSA_WITH_AES_256_GCM_SHA384",
|
||||
0x0033 => "TLS_DHE_RSA_WITH_AES_128_CBC_SHA",
|
||||
0x0039 => "TLS_DHE_RSA_WITH_AES_256_CBC_SHA",
|
||||
0x009E => "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256",
|
||||
0x009F => "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384",
|
||||
0xC013 => "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
|
||||
0xC014 => "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
|
||||
0xC027 => "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||
0xC028 => "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
|
||||
0xC02F => "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
|
||||
0xC030 => "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
|
||||
0xC009 => "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
||||
0xC00A => "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
|
||||
0xC02B => "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
|
||||
0xC02C => "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
|
||||
0xCCA8 => "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
|
||||
0x1301 => "TLS_AES_128_GCM_SHA256 (1.3)",
|
||||
0x1302 => "TLS_AES_256_GCM_SHA384 (1.3)",
|
||||
0x1303 => "TLS_CHACHA20_POLY1305_SHA256 (1.3)",
|
||||
0x00FF => "TLS_EMPTY_RENEGOTIATION_INFO_SCSV",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this suite use an ephemeral (forward-secret) key exchange?
|
||||
///
|
||||
/// This is the whole decision: rustls offers ECDHE/DHE suites only.
|
||||
fn is_forward_secret(id: u16) -> bool {
|
||||
let n = cipher_name(id);
|
||||
n.contains("ECDHE") || n.contains("DHE_") || n.ends_with("(1.3)")
|
||||
}
|
||||
|
||||
fn tls_version_name(v: u16) -> &'static str {
|
||||
match v {
|
||||
0x0300 => "SSL 3.0",
|
||||
0x0301 => "TLS 1.0",
|
||||
0x0302 => "TLS 1.1",
|
||||
0x0303 => "TLS 1.2",
|
||||
0x0304 => "TLS 1.3",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_name(id: u16) -> &'static str {
|
||||
match id {
|
||||
0 => "server_name",
|
||||
5 => "status_request",
|
||||
10 => "supported_groups",
|
||||
11 => "ec_point_formats",
|
||||
13 => "signature_algorithms",
|
||||
16 => "ALPN",
|
||||
23 => "extended_master_secret",
|
||||
35 => "session_ticket",
|
||||
43 => "supported_versions",
|
||||
45 => "psk_key_exchange_modes",
|
||||
51 => "key_share",
|
||||
65281 => "renegotiation_info",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- parsing
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClientHello {
|
||||
record_version: u16,
|
||||
client_version: u16,
|
||||
cipher_suites: Vec<u16>,
|
||||
extensions: Vec<u16>,
|
||||
server_name: Option<String>,
|
||||
supported_versions: Vec<u16>,
|
||||
supported_groups: Vec<u16>,
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], i: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
|
||||
}
|
||||
|
||||
/// Parse a ClientHello from the first TLS record.
|
||||
///
|
||||
/// Returns `None` rather than erroring: an unparseable hello must still be
|
||||
/// relayed, so the caller treats this as "nothing learned", never as a failure.
|
||||
fn parse_client_hello(buf: &[u8]) -> Option<ClientHello> {
|
||||
// TLS record: type(1) version(2) length(2)
|
||||
if *buf.first()? != 0x16 {
|
||||
return None; // not a handshake record
|
||||
}
|
||||
let mut h = ClientHello {
|
||||
record_version: be16(buf, 1)?,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Handshake: type(1) length(3) then the body.
|
||||
let hs = buf.get(5..)?;
|
||||
if *hs.first()? != 0x01 {
|
||||
return None; // not a ClientHello
|
||||
}
|
||||
let mut i = 4; // skip handshake header
|
||||
h.client_version = be16(hs, i)?;
|
||||
i += 2;
|
||||
i += 32; // random
|
||||
let sid_len = *hs.get(i)? as usize;
|
||||
i += 1 + sid_len;
|
||||
|
||||
let cs_len = be16(hs, i)? as usize;
|
||||
i += 2;
|
||||
for k in (0..cs_len).step_by(2) {
|
||||
if let Some(c) = be16(hs, i + k) {
|
||||
h.cipher_suites.push(c);
|
||||
}
|
||||
}
|
||||
i += cs_len;
|
||||
|
||||
let comp_len = *hs.get(i)? as usize;
|
||||
i += 1 + comp_len;
|
||||
|
||||
// Extensions are optional (SSL3/TLS1.0 clients may omit them).
|
||||
let Some(ext_total) = be16(hs, i) else {
|
||||
return Some(h);
|
||||
};
|
||||
i += 2;
|
||||
let end = i + ext_total as usize;
|
||||
while i + 4 <= end.min(hs.len()) {
|
||||
let etype = be16(hs, i)?;
|
||||
let elen = be16(hs, i + 2)? as usize;
|
||||
let body = hs.get(i + 4..i + 4 + elen).unwrap_or(&[]);
|
||||
h.extensions.push(etype);
|
||||
match etype {
|
||||
0 => {
|
||||
// server_name: list(2) type(1) len(2) host
|
||||
if body.len() > 5 {
|
||||
let n = be16(body, 3).unwrap_or(0) as usize;
|
||||
if let Some(s) = body.get(5..5 + n) {
|
||||
h.server_name = Some(String::from_utf8_lossy(s).into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
43 => {
|
||||
if let Some(&n) = body.first() {
|
||||
for k in (0..n as usize).step_by(2) {
|
||||
if let Some(v) = be16(body, 1 + k) {
|
||||
h.supported_versions.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10 => {
|
||||
let n = be16(body, 0).unwrap_or(0) as usize;
|
||||
for k in (0..n).step_by(2) {
|
||||
if let Some(g) = be16(body, 2 + k) {
|
||||
h.supported_groups.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 4 + elen;
|
||||
}
|
||||
Some(h)
|
||||
}
|
||||
|
||||
fn report(conn: u64, peer: &str, h: &ClientHello) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(&format!("=== ClientHello #{conn} from {peer} ===\n"));
|
||||
s.push_str(&format!(
|
||||
" record version : 0x{:04x} {}\n",
|
||||
h.record_version,
|
||||
tls_version_name(h.record_version)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" client version : 0x{:04x} {}\n",
|
||||
h.client_version,
|
||||
tls_version_name(h.client_version)
|
||||
));
|
||||
if !h.supported_versions.is_empty() {
|
||||
let v: Vec<String> = h
|
||||
.supported_versions
|
||||
.iter()
|
||||
.map(|v| tls_version_name(*v).to_string())
|
||||
.collect();
|
||||
s.push_str(&format!(" supported_versions: {}\n", v.join(", ")));
|
||||
}
|
||||
if let Some(sni) = &h.server_name {
|
||||
s.push_str(&format!(" SNI : {sni}\n"));
|
||||
}
|
||||
s.push_str(&format!(" cipher suites : {}\n", h.cipher_suites.len()));
|
||||
for c in &h.cipher_suites {
|
||||
let name = cipher_name(*c);
|
||||
s.push_str(&format!(
|
||||
" 0x{:04x} {:<45} {}\n",
|
||||
c,
|
||||
if name.is_empty() { "<unknown>" } else { name },
|
||||
if is_forward_secret(*c) {
|
||||
"[forward-secret]"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
}
|
||||
let fs: Vec<u16> = h
|
||||
.cipher_suites
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| is_forward_secret(*c))
|
||||
.collect();
|
||||
s.push_str(&format!(
|
||||
" extensions : {}\n",
|
||||
h.extensions
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let n = extension_name(*e);
|
||||
if n.is_empty() {
|
||||
format!("{e}")
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
s.push('\n');
|
||||
s.push_str(" VERDICT:\n");
|
||||
if fs.is_empty() {
|
||||
s.push_str(" NO forward-secret suite offered.\n");
|
||||
s.push_str(" => rustls is RULED OUT for the redirector; an OpenSSL-backed\n");
|
||||
s.push_str(" stack (native-tls / openssl) is required.\n");
|
||||
} else {
|
||||
s.push_str(&format!(
|
||||
" {} forward-secret suite(s) OFFERED:\n",
|
||||
fs.len()
|
||||
));
|
||||
for c in &fs {
|
||||
s.push_str(&format!(" 0x{:04x} {}\n", c, cipher_name(*c)));
|
||||
}
|
||||
s.push_str(" => rustls is VIABLE in principle. Confirm with a real handshake\n");
|
||||
s.push_str(" against a rustls listener before deciding.\n");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- proxy
|
||||
|
||||
fn pump(mut from: TcpStream, mut to: TcpStream) {
|
||||
let mut buf = [0u8; 32768];
|
||||
loop {
|
||||
match from.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if to.write_all(&buf[..n]).is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = to.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = to.shutdown(std::net::Shutdown::Write);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let get = |flag: &str| -> Option<String> {
|
||||
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
|
||||
};
|
||||
let listen = get("--listen").unwrap_or_else(|| {
|
||||
eprintln!(
|
||||
"usage: tls-observe --listen <addr:port> --upstream <addr:port> [--report <path>]"
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("Passively observes the TLS ClientHello and relays every byte to the");
|
||||
eprintln!("upstream redirector unmodified. Never terminates TLS itself.");
|
||||
std::process::exit(2);
|
||||
});
|
||||
let upstream = get("--upstream").unwrap_or_else(|| {
|
||||
eprintln!("--upstream is required (the real redirector, e.g. 127.0.0.1:42127)");
|
||||
std::process::exit(2);
|
||||
});
|
||||
let report_path = get("--report");
|
||||
|
||||
let listener = TcpListener::bind(&listen).unwrap_or_else(|e| {
|
||||
eprintln!("cannot bind {listen}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
eprintln!("tls-observe: {listen} -> {upstream} (passive; TLS terminated upstream)");
|
||||
|
||||
let counter = Arc::new(AtomicU64::new(0));
|
||||
for incoming in listener.incoming() {
|
||||
let Ok(mut client) = incoming else { continue };
|
||||
let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let upstream = upstream.clone();
|
||||
let report_path = report_path.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let peer = client
|
||||
.peer_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|_| "<unknown>".into());
|
||||
let _ = client.set_nodelay(true);
|
||||
|
||||
// Read the first chunk: the ClientHello. Observation only — these
|
||||
// bytes are forwarded verbatim regardless of what we make of them.
|
||||
let mut head = vec![0u8; 8192];
|
||||
let _ = client.set_read_timeout(Some(Duration::from_secs(15)));
|
||||
let n = match client.read(&mut head) {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => n,
|
||||
};
|
||||
head.truncate(n);
|
||||
let _ = client.set_read_timeout(None);
|
||||
|
||||
match parse_client_hello(&head) {
|
||||
Some(h) => {
|
||||
let text = report(id, &peer, &h);
|
||||
print!("{text}");
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().flush();
|
||||
if let Some(p) = &report_path {
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(p)
|
||||
{
|
||||
let _ = f.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
eprintln!("conn {id} from {peer}: first record is not a ClientHello ({n}B)")
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(mut server) = TcpStream::connect(&upstream) else {
|
||||
eprintln!("conn {id}: cannot reach upstream {upstream}");
|
||||
return;
|
||||
};
|
||||
let _ = server.set_nodelay(true);
|
||||
if server.write_all(&head).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = server.flush();
|
||||
|
||||
let (c2, s2) = match (client.try_clone(), server.try_clone()) {
|
||||
(Ok(a), Ok(b)) => (a, b),
|
||||
_ => return,
|
||||
};
|
||||
let up = std::thread::spawn(move || pump(client, server));
|
||||
pump(s2, c2);
|
||||
let _ = up.join();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal TLS 1.2 ClientHello offering one ECDHE and one static-RSA suite.
|
||||
fn synthetic_hello() -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&[0x03, 0x03]); // client_version TLS 1.2
|
||||
body.extend_from_slice(&[0u8; 32]); // random
|
||||
body.push(0); // session id len
|
||||
body.extend_from_slice(&2u16.to_be_bytes().map(|b| b)); // placeholder
|
||||
let cs: [u16; 2] = [0xC030, 0x009D];
|
||||
let cs_bytes: Vec<u8> = cs.iter().flat_map(|c| c.to_be_bytes()).collect();
|
||||
let l = body.len();
|
||||
body.truncate(l - 2);
|
||||
body.extend_from_slice(&(cs_bytes.len() as u16).to_be_bytes());
|
||||
body.extend_from_slice(&cs_bytes);
|
||||
body.push(1); // compression len
|
||||
body.push(0); // null
|
||||
body.extend_from_slice(&0u16.to_be_bytes()); // no extensions
|
||||
|
||||
let mut hs = vec![0x01];
|
||||
hs.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]);
|
||||
hs.extend_from_slice(&body);
|
||||
|
||||
let mut rec = vec![0x16, 0x03, 0x01];
|
||||
rec.extend_from_slice(&(hs.len() as u16).to_be_bytes());
|
||||
rec.extend_from_slice(&hs);
|
||||
rec
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ciphers_and_classifies_forward_secrecy() {
|
||||
let h = parse_client_hello(&synthetic_hello()).expect("parses");
|
||||
assert_eq!(h.client_version, 0x0303);
|
||||
assert_eq!(h.cipher_suites, vec![0xC030, 0x009D]);
|
||||
assert!(
|
||||
is_forward_secret(0xC030),
|
||||
"ECDHE must count as forward-secret"
|
||||
);
|
||||
assert!(!is_forward_secret(0x009D), "static RSA must not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_reflects_what_was_offered() {
|
||||
let h = parse_client_hello(&synthetic_hello()).unwrap();
|
||||
let text = report(1, "test", &h);
|
||||
assert!(text.contains("rustls is VIABLE"), "{text}");
|
||||
|
||||
let only_static = ClientHello {
|
||||
cipher_suites: vec![0x009D, 0x002F],
|
||||
..Default::default()
|
||||
};
|
||||
let text = report(1, "test", &only_static);
|
||||
assert!(text.contains("RULED OUT"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_tls_input_is_declined_rather_than_misparsed() {
|
||||
assert!(parse_client_hello(b"GET / HTTP/1.1\r\n\r\n").is_none());
|
||||
assert!(parse_client_hello(&[]).is_none());
|
||||
// A handshake record that is not a ClientHello.
|
||||
assert!(parse_client_hello(&[0x16, 0x03, 0x01, 0x00, 0x04, 0x02, 0, 0, 0]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_hello_does_not_panic() {
|
||||
let full = synthetic_hello();
|
||||
for cut in 1..full.len() {
|
||||
let _ = parse_client_hello(&full[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user