//! 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, extensions: Vec, server_name: Option, supported_versions: Vec, supported_groups: Vec, } fn be16(b: &[u8], i: usize) -> Option { 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 { // 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 = 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() { "" } else { name }, if is_forward_secret(*c) { "[forward-secret]" } else { "" } )); } let fs: Vec = 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::>() .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 = std::env::args().skip(1).collect(); let get = |flag: &str| -> Option { 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 --upstream [--report ]" ); 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(|_| "".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 { 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 = 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]); } } }