diff --git a/openfut-blaze-host/Cargo.toml b/openfut-blaze-host/Cargo.toml index ebfe4f4..ad7fe99 100644 --- a/openfut-blaze-host/Cargo.toml +++ b/openfut-blaze-host/Cargo.toml @@ -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" diff --git a/openfut-blaze-host/src/bin/tls-observe.rs b/openfut-blaze-host/src/bin/tls-observe.rs new file mode 100644 index 0000000..e8b39a6 --- /dev/null +++ b/openfut-blaze-host/src/bin/tls-observe.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, + 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]); + } + } +}