//! TLS for the FIFA 17 redirector — a deliberately narrow legacy compatibility //! island. //! //! # Driven by an observed ClientHello, not by generic legacy assumptions //! //! A retail FIFA 17 client was captured through a passive proxy while reaching //! the FUT hub. It offers **exactly eight suites, every one static-RSA**: //! //! ```text //! 0x009D TLS_RSA_WITH_AES_256_GCM_SHA384 0x0035 TLS_RSA_WITH_AES_256_CBC_SHA //! 0x009C TLS_RSA_WITH_AES_128_GCM_SHA256 0x002F TLS_RSA_WITH_AES_128_CBC_SHA //! 0x003D TLS_RSA_WITH_AES_256_CBC_SHA256 0x0005 TLS_RSA_WITH_RC4_128_SHA //! 0x003C TLS_RSA_WITH_AES_128_CBC_SHA256 0x0004 TLS_RSA_WITH_RC4_128_MD5 //! //! client_version TLS 1.2 extensions: server_name, signature_algorithms only //! ``` //! //! Zero forward-secret suites, which is why `rustls` cannot serve this client //! and why this crate uses `openssl` directly. //! //! # What is deliberately NOT enabled //! //! * **RC4 and MD5.** The client offers them; we do not need them. It already //! negotiates `AES256-GCM-SHA384` against the Python oracle, so resurrecting //! RC4 for historical completeness would weaken the service for nothing. //! * **SSLv3.** Never. //! * **A lowered security level.** Not applied pre-emptively. The default //! policy is tried first; if the retail handshake fails because OpenSSL //! rejects something actually required, the narrowest possible change is made //! and documented — not a blanket `SECLEVEL=0`. //! //! # Scope //! //! Every setting here applies to THIS listener's `SslContext` only. Nothing //! global is weakened, and no other OpenFUT component links OpenSSL. use std::fmt; use std::path::Path; use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslOptions, SslVersion}; /// The suites we enable: the six RSA+AES options the client offers, strongest /// first, in OpenSSL's pre-TLS-1.3 naming. /// /// RC4/MD5 are excluded on purpose (see module docs). Order expresses our /// preference; the client's own order put AES-256-GCM first anyway. pub const CIPHER_LIST: &str = "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA"; /// Suites the observed client offers that we deliberately refuse. pub const REFUSED_SUITES: [&str; 2] = ["RC4-SHA", "RC4-MD5"]; #[derive(Debug)] pub struct TlsError(pub String); impl fmt::Display for TlsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl std::error::Error for TlsError {} /// TLS knobs, all overridable so a failed retail handshake is a configuration /// change rather than a code change. #[derive(Debug, Clone)] pub struct TlsConfig { pub cert_path: String, pub key_path: String, pub cipher_list: String, pub min_version: SslVersion, pub max_version: SslVersion, /// `None` = leave OpenSSL's default policy alone. Only set this if a real /// handshake proves it necessary, and record why. pub security_level: Option, } impl TlsConfig { /// Defaults chosen from the observed client: it speaks TLS 1.2 and offers /// no TLS 1.3, so both floor and ceiling are 1.2. /// /// The floor is NOT dropped to TLS 1.0 pre-emptively. The oracle allows it, /// but no evidence shows this client needs it, and "the oracle permits it" /// is not the same as "the client requires it". pub fn new(cert_path: impl Into, key_path: impl Into) -> TlsConfig { TlsConfig { cert_path: cert_path.into(), key_path: key_path.into(), cipher_list: CIPHER_LIST.to_string(), min_version: SslVersion::TLS1_2, max_version: SslVersion::TLS1_2, security_level: None, } } } /// The OpenSSL version this binary is linked against. /// /// Vendored, so it is fixed at build time rather than inherited from the host. /// Printed at startup and recorded in gate evidence: a TLS implementation /// change is not a routine dependency bump, it invalidates the compatibility /// testing. pub fn openssl_version() -> String { openssl::version::version().to_string() } /// Build the acceptor for the redirector listener. pub fn build_acceptor(cfg: &TlsConfig) -> Result { // `mozilla_intermediate` would preload a modern, forward-secret-only // policy — precisely wrong here. Start from the bare method and state every // choice explicitly, so nothing is inherited silently. let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()) .map_err(|e| TlsError(format!("acceptor: {e}")))?; b.set_min_proto_version(Some(cfg.min_version)) .map_err(|e| TlsError(format!("min proto: {e}")))?; b.set_max_proto_version(Some(cfg.max_version)) .map_err(|e| TlsError(format!("max proto: {e}")))?; // Explicit list, replacing whatever policy the profile brought with it. b.set_cipher_list(&cfg.cipher_list) .map_err(|e| TlsError(format!("cipher list {:?}: {e}", cfg.cipher_list)))?; if let Some(level) = cfg.security_level { b.set_security_level(level); } // SSLv3 is never acceptable, whatever the version floor says. b.set_options(SslOptions::NO_SSLV3); let cert = Path::new(&cfg.cert_path); let key = Path::new(&cfg.key_path); if !cert.exists() { return Err(TlsError(format!( "certificate not found: {}", cfg.cert_path ))); } if !key.exists() { return Err(TlsError(format!("private key not found: {}", cfg.key_path))); } b.set_certificate_chain_file(cert) .map_err(|e| TlsError(format!("certificate {}: {e}", cfg.cert_path)))?; b.set_private_key_file(key, SslFiletype::PEM) .map_err(|e| TlsError(format!("private key {}: {e}", cfg.key_path)))?; // Catches a mismatched pair at startup rather than mid-handshake, where it // would look like a client problem. b.check_private_key() .map_err(|e| TlsError(format!("certificate and key do not match: {e}")))?; Ok(b.build()) } /// The eight suites the captured retail ClientHello offers, in OpenSSL naming. /// /// Used to build a client that behaves like FIFA 17 for the self-test below. pub const OBSERVED_CLIENT_SUITES: &str = "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:\ AES256-SHA:AES128-SHA:RC4-SHA:RC4-MD5"; /// What a handshake actually negotiated. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Negotiated { pub version: String, pub cipher: String, } /// Handshake against ourselves with a client restricted to `client_ciphers`. /// /// Enumerating the server's configured list would be the obvious check, but the /// crate exposes no such accessor at this version — and a real handshake is /// better evidence anyway: it proves MUTUAL support and reports what was /// actually chosen, which is the thing a gate is judged on. Defaulting /// `client_ciphers` to the observed FIFA suites makes this a direct rehearsal /// of the retail handshake. pub fn self_test(cfg: &TlsConfig, client_ciphers: &str) -> Result { use openssl::ssl::{SslConnector, SslVerifyMode}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; let acceptor = build_acceptor(cfg)?; let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| TlsError(format!("bind: {e}")))?; let addr = listener .local_addr() .map_err(|e| TlsError(format!("addr: {e}")))?; let server = std::thread::spawn(move || -> Result { let (sock, _) = listener.accept().map_err(|e| e.to_string())?; let mut tls = acceptor.accept(sock).map_err(|e| e.to_string())?; let neg = { let s = tls.ssl(); Negotiated { version: s.version_str().to_string(), cipher: s .current_cipher() .map(|c| c.name().to_string()) .unwrap_or_default(), } }; let mut buf = [0u8; 64]; let _ = tls.read(&mut buf); let _ = tls.write_all(b"ok"); let _ = tls.flush(); Ok(neg) }); let mut cb = SslConnector::builder(SslMethod::tls()).map_err(|e| TlsError(format!("connector: {e}")))?; cb.set_cipher_list(client_ciphers) .map_err(|e| TlsError(format!("client cipher list: {e}")))?; // The point is the cipher/version negotiation, not PKI: the oracle's // certificate is self-signed and the real client's checks are patched out. cb.set_verify(SslVerifyMode::NONE); let connector = cb.build(); let sock = TcpStream::connect(addr).map_err(|e| TlsError(format!("connect: {e}")))?; let mut client = connector .configure() .and_then(|c| { c.verify_hostname(false) .into_ssl("winter15.gosredirector.ea.com") }) .and_then(|ssl| openssl::ssl::SslStream::new(ssl, sock)) .map_err(|e| TlsError(format!("client setup: {e}")))?; client .connect() .map_err(|e| TlsError(format!("client handshake failed: {e}")))?; let _ = client.write_all(b"ping"); let _ = client.flush(); let mut buf = [0u8; 8]; let _ = client.read(&mut buf); server .join() .map_err(|_| TlsError("server thread panicked".into()))? .map_err(|e| TlsError(format!("server handshake failed: {e}"))) } #[cfg(test)] mod tests { use super::*; /// Reuse the proven Python redirector's material, so TLS implementation is /// the only variable in the A/B. fn oracle_cert() -> (String, String) { let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools"); ( format!("{base}/redir_cert.pem"), format!("{base}/redir_key.pem"), ) } fn cfg() -> TlsConfig { let (c, k) = oracle_cert(); TlsConfig::new(c, k) } #[test] fn acceptor_builds_with_the_oracle_certificate() { assert!(build_acceptor(&cfg()).is_ok(), "acceptor must build"); } /// The decisive test: the configured list must actually contain a suite the /// observed client offers, or the retail handshake cannot succeed. /// The decisive test: a client restricted to exactly the suites the retail /// ClientHello offered must complete a handshake, and must land on the /// suite the real client already negotiated against Python. #[test] fn a_client_offering_only_fifas_suites_completes_a_handshake() { let neg = self_test(&cfg(), OBSERVED_CLIENT_SUITES).expect("handshake"); assert_eq!(neg.cipher, "AES256-GCM-SHA384", "negotiated {neg:?}"); assert_eq!(neg.version, "TLSv1.2", "negotiated {neg:?}"); } /// Each RSA+AES suite individually, so a client that offered only one of /// them would still connect. #[test] fn every_enabled_rsa_aes_suite_can_be_negotiated_alone() { for suite in [ "AES256-GCM-SHA384", "AES128-GCM-SHA256", "AES256-SHA256", "AES128-SHA256", "AES256-SHA", "AES128-SHA", ] { let neg = self_test(&cfg(), suite) .unwrap_or_else(|e| panic!("{suite} could not be negotiated: {e}")); assert_eq!(neg.cipher, suite); } } /// RC4/MD5 are offered by the client and deliberately refused by us, so a /// client offering ONLY those must fail to connect. This is the test that /// proves the refusal is real rather than aspirational. #[test] fn a_client_offering_only_rc4_is_refused() { let err = self_test(&cfg(), "RC4-SHA:RC4-MD5"); assert!(err.is_err(), "RC4-only client should not connect: {err:?}"); } /// A forward-secret-only client must also fail — proving we did not /// silently inherit a modern profile that would exclude FIFA. #[test] fn a_forward_secret_only_client_is_not_what_we_serve() { let r = self_test(&cfg(), "ECDHE-RSA-AES256-GCM-SHA384"); assert!(r.is_err(), "we should not be offering ECDHE; got {r:?}"); } #[test] fn security_level_is_not_lowered_by_default() { assert_eq!( cfg().security_level, None, "the default policy is tried first; lowering requires evidence" ); } #[test] fn protocol_window_matches_the_observed_client() { let c = cfg(); assert_eq!(c.min_version, SslVersion::TLS1_2); assert_eq!(c.max_version, SslVersion::TLS1_2); } #[test] fn a_missing_certificate_fails_clearly() { let mut c = cfg(); c.cert_path = "/nonexistent/cert.pem".into(); let e = match build_acceptor(&c) { Err(e) => e.to_string(), Ok(_) => panic!("a missing certificate must not build"), }; assert!(e.contains("certificate not found"), "{e}"); } #[test] fn a_mismatched_key_is_caught_at_startup() { let (cert, _) = oracle_cert(); let mut c = cfg(); // Point the key at the certificate: a valid PEM that is not the key. c.key_path = cert; assert!(build_acceptor(&c).is_err(), "mismatch must not build"); } #[test] fn an_unusable_cipher_list_is_an_error_not_a_silent_empty_set() { let mut c = cfg(); c.cipher_list = "THIS-IS-NOT-A-CIPHER".into(); assert!(build_acceptor(&c).is_err()); } #[test] fn openssl_version_is_reportable() { let v = openssl_version(); assert!(v.contains("OpenSSL"), "{v}"); } }