diff --git a/Cargo.lock b/Cargo.lock index 3e4c3ca..10fbb5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3226,6 +3226,14 @@ version = "0.1.0" dependencies = [ "openfut-adapter-fifa17", "openfut-host-config", + "openfut-tls", + "openssl", +] + +[[package]] +name = "openfut-tls" +version = "0.1.0" +dependencies = [ "openssl", ] diff --git a/Cargo.toml b/Cargo.toml index 4a7694c..68b44a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "openfut-adapter-fifa17", "openfut-blaze-host", "openfut-host-config", + "openfut-tls", "openfut-redirector-host", "openfut-bridge", "openfut-launcher", diff --git a/openfut-adapter-fifa17/src/lib.rs b/openfut-adapter-fifa17/src/lib.rs index 4f80a2e..1451826 100644 --- a/openfut-adapter-fifa17/src/lib.rs +++ b/openfut-adapter-fifa17/src/lib.rs @@ -70,5 +70,6 @@ pub mod blaze; pub mod redirector; pub mod roster; +pub mod tls; pub use blaze::{Adapter, AdapterConfig, Session}; diff --git a/openfut-adapter-fifa17/src/roster/mod.rs b/openfut-adapter-fifa17/src/roster/mod.rs index 188adf7..8510746 100644 --- a/openfut-adapter-fifa17/src/roster/mod.rs +++ b/openfut-adapter-fifa17/src/roster/mod.rs @@ -38,7 +38,8 @@ pub const REQUEST_PATH: &str = "/fifa17/fut/rosterupdate.xml"; /// The "no update available" body, byte-for-byte from the oracle. -pub const ROSTER_XML: &[u8] = b"\n\n"; +pub const ROSTER_XML: &[u8] = + b"\n\n"; /// The `Server:` string the oracle emits. /// @@ -173,18 +174,28 @@ mod tests { fn header_order_matches_basehttprequesthandler() { let r = roster_response(Method::Get, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT"); let text = String::from_utf8_lossy(&r); - let order: Vec<&str> = ["Server:", "Date:", "Content-Type:", "Content-Length:", "Connection:"] - .iter() - .map(|h| { - text.find(h).map(|_| *h).unwrap_or("MISSING") - }) - .collect(); + let order: Vec<&str> = [ + "Server:", + "Date:", + "Content-Type:", + "Content-Length:", + "Connection:", + ] + .iter() + .map(|h| text.find(h).map(|_| *h).unwrap_or("MISSING")) + .collect(); assert!(!order.contains(&"MISSING"), "{text}"); let positions: Vec = order.iter().map(|h| text.find(h).unwrap()).collect(); let mut sorted = positions.clone(); sorted.sort_unstable(); - assert_eq!(positions, sorted, "headers out of the oracle's order:\n{text}"); - assert!(text.starts_with("HTTP/1.0 200 OK\r\n"), "must be HTTP/1.0: {text}"); + assert_eq!( + positions, sorted, + "headers out of the oracle's order:\n{text}" + ); + assert!( + text.starts_with("HTTP/1.0 200 OK\r\n"), + "must be HTTP/1.0: {text}" + ); } #[test] diff --git a/openfut-adapter-fifa17/src/tls.rs b/openfut-adapter-fifa17/src/tls.rs new file mode 100644 index 0000000..477f441 --- /dev/null +++ b/openfut-adapter-fifa17/src/tls.rs @@ -0,0 +1,141 @@ +//! FIFA 17's TLS profile — what the retail client was *observed* to offer. +//! +//! # Evidence, not assumption +//! +//! 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 the transport hosts link OpenSSL directly. +//! +//! # Deliberately not enabled +//! +//! * **RC4 and MD5.** The client offers them; it does not need them. It already +//! negotiates `AES256-GCM-SHA384` against the Python oracle, so resurrecting +//! RC4 for completeness would weaken the service for nothing. +//! * **SSLv3.** Never. +//! * **A lowered security level.** Not applied pre-emptively. The default +//! policy is tried first; if a retail handshake fails because OpenSSL rejects +//! something genuinely required, the narrowest possible change is made and +//! documented — not a blanket `SECLEVEL=0`. +//! +//! # Why this is data and not code +//! +//! These are facts about FIFA 17, so they live in the FIFA 17 adapter rather +//! than in `openfut-tls`, which must stay game-independent. They are plain +//! strings so this crate keeps its lean dependency list: an adapter should not +//! drag OpenSSL into the build of everything that reads a card table. +//! +//! Confirmed live on 2026-08-11: the retail client reached the FUT hub through +//! a Rust host configured from exactly these values, negotiating +//! `TLSv1.2 / AES256-GCM-SHA384` with `sni=winter15.gosredirector.ea.com`. + +/// The suites enabled for FIFA 17: the six RSA+AES options the client offers, +/// strongest first, in OpenSSL's pre-TLS-1.3 naming. +/// +/// Order expresses preference; the client's own order put AES-256-GCM first +/// anyway, which is what the live handshake selected. +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 are deliberately refused. +pub const REFUSED_SUITES: [&str; 2] = ["RC4-SHA", "RC4-MD5"]; + +/// All eight suites the captured ClientHello offered, for handshake rehearsals. +pub const OBSERVED_CLIENT_SUITES: &str = + "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:\ + AES256-SHA:AES128-SHA:RC4-SHA:RC4-MD5"; + +/// The SNI the retail client sends to the redirector. +/// +/// Nothing routes on it — recorded so a rehearsal handshake matches the real +/// one, and so a log line can show whether a connection came from the game or +/// from a probe. +pub const CLIENT_SNI: &str = "winter15.gosredirector.ea.com"; + +/// Protocol floor, as OpenSSL spells it. +/// +/// The floor is NOT dropped to TLS 1.0 pre-emptively. The Python oracle permits +/// it, but no evidence shows this client needs it, and "the oracle permits it" +/// is not "the client requires it". +pub const MIN_VERSION: &str = "TLSv1.2"; + +/// Protocol ceiling. The client offers no TLS 1.3, so the window is exact. +pub const MAX_VERSION: &str = "TLSv1.2"; + +/// The suite the live retail handshake selected, and which a rehearsal is +/// expected to reproduce. +pub const EXPECTED_SUITE: &str = "AES256-GCM-SHA384"; + +#[cfg(test)] +mod tests { + use super::*; + + /// The enabled list must be exactly the client's offer minus the refusals. + /// Stated as a derivation so adding a suite to one constant without the + /// other is a test failure rather than a silent divergence. + #[test] + fn the_enabled_list_is_the_offer_minus_the_refusals() { + let offered: Vec<&str> = OBSERVED_CLIENT_SUITES.split(':').collect(); + let enabled: Vec<&str> = CIPHER_LIST.split(':').collect(); + let expected: Vec<&str> = offered + .iter() + .copied() + .filter(|s| !REFUSED_SUITES.contains(s)) + .collect(); + assert_eq!(enabled, expected); + assert_eq!(offered.len(), 8, "the capture showed eight suites"); + assert_eq!(enabled.len(), 6); + } + + /// Every enabled suite must be static RSA. An ECDHE suite slipping in would + /// be silently useless to this client, which offers none. + #[test] + fn nothing_forward_secret_is_enabled() { + for s in CIPHER_LIST.split(':') { + assert!( + !s.contains("ECDHE") && !s.contains("DHE"), + "{s} is forward-secret; FIFA 17 offers no such suite" + ); + } + } + + #[test] + fn rc4_and_md5_are_refused_not_merely_absent() { + for s in REFUSED_SUITES { + assert!( + OBSERVED_CLIENT_SUITES.contains(s), + "{s} must be one the client actually offers" + ); + assert!(!CIPHER_LIST.contains(s), "{s} must not be enabled"); + } + } + + #[test] + fn the_expected_suite_is_one_we_enable_and_the_client_offers() { + assert!(CIPHER_LIST.split(':').any(|s| s == EXPECTED_SUITE)); + assert!(OBSERVED_CLIENT_SUITES + .split(':') + .any(|s| s == EXPECTED_SUITE)); + assert_eq!( + CIPHER_LIST.split(':').next(), + Some(EXPECTED_SUITE), + "preference order should put the observed selection first" + ); + } + + #[test] + fn the_protocol_window_is_exactly_tls12() { + assert_eq!(MIN_VERSION, "TLSv1.2"); + assert_eq!(MAX_VERSION, "TLSv1.2"); + } +} diff --git a/openfut-adapter-fifa17/tests/roster_parity.rs b/openfut-adapter-fifa17/tests/roster_parity.rs index 81cb81a..75d6b2b 100644 --- a/openfut-adapter-fifa17/tests/roster_parity.rs +++ b/openfut-adapter-fifa17/tests/roster_parity.rs @@ -96,7 +96,8 @@ fn post_matches_the_oracle() { fn the_server_constant_still_matches_the_observed_oracle() { let observed = field(&fixture(), "observed_server"); assert_eq!( - roster::ORACLE_SERVER, observed, + roster::ORACLE_SERVER, + observed, "roster::ORACLE_SERVER is stale — the oracle now sends {observed:?}. \ Regenerate fixtures and update the constant." ); diff --git a/openfut-redirector-host/Cargo.toml b/openfut-redirector-host/Cargo.toml index e28ac94..ed5308a 100644 --- a/openfut-redirector-host/Cargo.toml +++ b/openfut-redirector-host/Cargo.toml @@ -9,6 +9,8 @@ publish = false [dependencies] openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" } openfut-host-config = { path = "../openfut-host-config" } +# Shared legacy-TLS listener. See its manifest for why openssl and not rustls. +openfut-tls = { path = "../openfut-tls" } # Direct openssl, NOT native-tls. # diff --git a/openfut-redirector-host/src/config.rs b/openfut-redirector-host/src/config.rs index 949fe4a..af139ac 100644 --- a/openfut-redirector-host/src/config.rs +++ b/openfut-redirector-host/src/config.rs @@ -10,7 +10,7 @@ use std::time::Duration; use openfut_adapter_fifa17::blaze::AdapterConfig; use openfut_host_config::{self as hostcfg, ConfigError}; -use crate::tls::TlsConfig; +use openfut_tls::{ProtocolVersion, TlsConfig}; /// How long the oracle holds a redirector connection open after responding /// (`time.sleep(0.3)` in `blaze_responder_v3b.redir_handle`). @@ -22,6 +22,28 @@ use crate::tls::TlsConfig; /// reimplementation OF. pub const ORACLE_CLOSE_DWELL: Duration = Duration::from_millis(300); +/// Compose the shared TLS listener from the FIFA 17 adapter's observed profile. +/// +/// The split is deliberate: `openfut-tls` knows how to build an acceptor, +/// `openfut-adapter-fifa17` knows what FIFA 17 offers, and this host knows only +/// how to join them. A second host serving the same client uses this same pair +/// rather than repeating either half — one configuration path is the structural +/// guard against the 2026-08-11 certificate mismatch, where two services +/// configured TLS separately and disagreed. +fn fifa17_tls(cert: impl Into, key: impl Into) -> Result { + use openfut_adapter_fifa17::tls as profile; + let ver = |s: &str| { + ProtocolVersion::parse(s).map_err(|e| ConfigError(format!("FIFA 17 TLS profile: {e}"))) + }; + Ok(TlsConfig::new( + cert, + key, + profile::CIPHER_LIST, + ver(profile::MIN_VERSION)?, + ver(profile::MAX_VERSION)?, + )) +} + #[derive(Debug, Clone)] pub struct RedirectorConfig { /// BIND: where this listener binds. Never client-visible. @@ -57,7 +79,10 @@ impl RedirectorConfig { )?; let key = hostcfg::required("OPENFUT_REDIRECTOR_KEY", "path to the matching private key")?; - let mut tls = TlsConfig::new(cert, key); + // FIFA 17's observed profile, supplied by the adapter. The transport + // host chooses no cipher and no protocol window of its own: those are + // facts about the client, and belong with the game they describe. + let mut tls = fifa17_tls(cert, key)?; if let Some(list) = hostcfg::optional_opt("OPENFUT_REDIRECTOR_CIPHERS") { tls.cipher_list = list; } @@ -97,10 +122,11 @@ impl RedirectorConfig { RedirectorConfig { listen_addr: "127.0.0.1".into(), listen_port: 0, - tls: TlsConfig::new( + tls: fifa17_tls( format!("{base}/redir_cert.pem"), format!("{base}/redir_key.pem"), - ), + ) + .expect("the adapter's TLS profile must be valid"), adapter: AdapterConfig::advertising(advertise), close_dwell: ORACLE_CLOSE_DWELL, } diff --git a/openfut-redirector-host/src/lib.rs b/openfut-redirector-host/src/lib.rs index 762a77e..6ccc467 100644 --- a/openfut-redirector-host/src/lib.rs +++ b/openfut-redirector-host/src/lib.rs @@ -30,7 +30,6 @@ //! the Blaze sidecar's. pub mod config; -pub mod tls; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -72,7 +71,7 @@ pub fn identity() -> String { } else { "release" }, - tls::openssl_version(), + openfut_tls::openssl_version(), ) } @@ -83,7 +82,7 @@ pub fn identity() -> String { pub fn banner(cfg: &RedirectorConfig) -> String { format!( "openfut-redirector-host v{} commit={} profile={} openssl={} listen={} \ - advertise={}:{} tls_min={:?} tls_max={:?} security_level={} ciphers={}", + advertise={}:{} tls_min={} tls_max={} security_level={} cert_sha256={} ciphers={}", env!("CARGO_PKG_VERSION"), BUILD_COMMIT, if cfg!(debug_assertions) { @@ -91,16 +90,24 @@ pub fn banner(cfg: &RedirectorConfig) -> String { } else { "release" }, - tls::openssl_version(), + openfut_tls::openssl_version(), cfg.listen_on(), cfg.adapter.endpoints.advertise, cfg.adapter.endpoints.blaze_port, - cfg.tls.min_version, - cfg.tls.max_version, + // Readable names, not `SslVersion(771)`: this line is gate evidence and + // is read by humans comparing one run against another. + cfg.tls.min_version.as_str(), + cfg.tls.max_version.as_str(), cfg.tls .security_level .map(|l| l.to_string()) .unwrap_or_else(|| "default".into()), + // WHICH certificate, not just that one loaded. Serving a different + // certificate from the rest of the stack raises no error at startup and + // breaks the client much later, with nothing logged anywhere. Printing + // it unprompted is what turns that into a one-glance comparison. + openfut_tls::certificate_fingerprint(&cfg.tls.cert_path) + .unwrap_or_else(|e| format!("unreadable ({e})")), cfg.tls.cipher_list, ) } @@ -166,13 +173,18 @@ fn record(outcomes: &Outcomes, o: ConnOutcome) { /// Build TLS, rehearse the retail handshake, and bind. pub fn bind(cfg: RedirectorConfig) -> std::io::Result { - let acceptor = tls::build_acceptor(&cfg.tls) + let acceptor = openfut_tls::build_acceptor(&cfg.tls) .map_err(|e| std::io::Error::other(format!("TLS setup failed: {e}")))?; // Rehearse the retail handshake BEFORE accepting anything: a client // restricted to exactly the suites FIFA offers must connect. Catching a // cipher/version misconfiguration here means it never shows up as an // unexplained failure during a live gate. - let neg = tls::self_test(&cfg.tls, tls::OBSERVED_CLIENT_SUITES).map_err(|e| { + let neg = openfut_tls::self_test( + &cfg.tls, + openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES, + openfut_adapter_fifa17::tls::CLIENT_SNI, + ) + .map_err(|e| { std::io::Error::other(format!( "self-test failed — a FIFA-like client cannot connect: {e}" )) @@ -322,7 +334,10 @@ pub enum BodyRead { /// No `Content-Length` header — nothing to drain. None, Complete(usize), - Short { got: usize, want: usize }, + Short { + got: usize, + want: usize, + }, } /// Parse `Content-Length` and read the remainder of the body into `buf`. diff --git a/openfut-redirector-host/src/tls.rs b/openfut-redirector-host/src/tls.rs deleted file mode 100644 index 79e9778..0000000 --- a/openfut-redirector-host/src/tls.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! 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}"); - } -} diff --git a/openfut-redirector-host/tests/fifa17_tls_profile.rs b/openfut-redirector-host/tests/fifa17_tls_profile.rs new file mode 100644 index 0000000..85eabd6 --- /dev/null +++ b/openfut-redirector-host/tests/fifa17_tls_profile.rs @@ -0,0 +1,140 @@ +//! The FIFA 17 TLS profile, exercised through the shared listener. +//! +//! These assertions used to live inside this host's own `tls` module. That +//! module was split in two — mechanism into `openfut-tls`, FIFA 17's observed +//! suites into `openfut-adapter-fifa17::tls` — and neither half can prove the +//! interesting property alone: the generic crate does not know what FIFA +//! offers, and the adapter is dependency-free and cannot open a socket. +//! +//! This host is where the two are composed, so this is where the composition is +//! tested. Every case here was passing before the split and must still pass +//! after it; the refactor is only sound if the retail handshake is unchanged. +//! +//! Live confirmation, 2026-08-11 17:09: a retail client negotiated +//! `TLSv1.2 / AES256-GCM-SHA384` against this host, presenting +//! `sni=winter15.gosredirector.ea.com`, and reached the FUT hub. + +use openfut_adapter_fifa17::tls as profile; +use openfut_tls::{self, ProtocolVersion, TlsConfig}; + +/// Reuse the proven Python redirector's material, so the TLS implementation is +/// the only variable in an A/B. +/// +/// NOTE: this is the repo's copy, which is **not** the certificate the deployed +/// container serves. That difference is irrelevant here — these tests exercise +/// cipher and version negotiation, for which any valid RSA pair does — but it +/// is emphatically not irrelevant at runtime, where serving the wrong one costs +/// gates. A host takes its certificate from configuration, never from here. +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, + profile::CIPHER_LIST, + ProtocolVersion::parse(profile::MIN_VERSION).unwrap(), + ProtocolVersion::parse(profile::MAX_VERSION).unwrap(), + ) +} + +fn rehearse(client_ciphers: &str) -> Result { + openfut_tls::self_test(&cfg(), client_ciphers, profile::CLIENT_SNI) +} + +#[test] +fn acceptor_builds_with_the_oracle_certificate() { + assert!(openfut_tls::build_acceptor(&cfg()).is_ok()); +} + +/// The decisive case: 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 = rehearse(profile::OBSERVED_CLIENT_SUITES).expect("handshake"); + assert_eq!(neg.cipher, profile::EXPECTED_SUITE, "negotiated {neg:?}"); + assert_eq!(neg.version, "TLSv1.2", "negotiated {neg:?}"); +} + +/// Each enabled suite individually, so a client offering only one still connects. +#[test] +fn every_enabled_suite_can_be_negotiated_alone() { + for suite in profile::CIPHER_LIST.split(':') { + let neg = + rehearse(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, so a client +/// offering ONLY those must fail. This is what makes the refusal real rather +/// than aspirational. +#[test] +fn a_client_offering_only_rc4_is_refused() { + let only_refused = profile::REFUSED_SUITES.join(":"); + let r = rehearse(&only_refused); + assert!(r.is_err(), "RC4-only client should not connect: {r:?}"); +} + +/// 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 = rehearse("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, ProtocolVersion::Tls12); + assert_eq!(c.max_version, ProtocolVersion::Tls12); +} + +/// The refactor's own guard. +/// +/// The values below are copied from the host as it was when gates 1-14 passed, +/// written out literally rather than referenced. If a later edit to the adapter +/// changes what this host serves, this fails and names the drift — which is the +/// only way a "pure refactor" can be shown to have been pure. +#[test] +fn the_composed_profile_still_matches_the_gate_proven_host() { + let c = cfg(); + assert_eq!( + c.cipher_list, + "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA" + ); + assert_eq!(c.min_version.as_str(), "TLSv1.2"); + assert_eq!(c.max_version.as_str(), "TLSv1.2"); + assert_eq!(c.security_level, None); + assert_eq!(profile::CLIENT_SNI, "winter15.gosredirector.ea.com"); +} + +/// A host must be able to report which certificate it serves. +/// +/// Not decoration: the failure this guards against is invisible at startup. +/// Serving a certificate that differs from the rest of the stack produced no +/// error anywhere — the client cached the first one it saw and every later +/// service failed its handshake in silence. +#[test] +fn the_certificate_fingerprint_is_reportable() { + let (cert, _) = oracle_cert(); + let fp = openfut_tls::certificate_fingerprint(&cert).expect("fingerprint"); + assert_eq!(fp.matches(':').count(), 31, "32 octets: {fp}"); +} diff --git a/openfut-redirector-host/tests/oracle_parity.rs b/openfut-redirector-host/tests/oracle_parity.rs index b1da5b2..46bee71 100644 --- a/openfut-redirector-host/tests/oracle_parity.rs +++ b/openfut-redirector-host/tests/oracle_parity.rs @@ -9,7 +9,8 @@ use std::io::{Read, Write}; use std::net::TcpStream; -use openfut_redirector_host::{bind, tls, BodyRead, RedirectorConfig}; +use openfut_adapter_fifa17::tls; +use openfut_redirector_host::{bind, BodyRead, RedirectorConfig}; use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; /// Start the real host on an ephemeral port. @@ -164,7 +165,8 @@ fn the_request_body_is_drained_before_closing() { }); let mut b = SslConnector::builder(SslMethod::tls()).expect("connector"); - b.set_cipher_list(tls::OBSERVED_CLIENT_SUITES).expect("list"); + b.set_cipher_list(tls::OBSERVED_CLIENT_SUITES) + .expect("list"); b.set_verify(SslVerifyMode::NONE); let sock = TcpStream::connect(&addr).expect("connect"); let ssl = b @@ -192,7 +194,9 @@ fn the_request_body_is_drained_before_closing() { // If the host had already answered and closed, this write — or the read // that follows it — is where the reset surfaces. - stream.write_all(REQUEST_BODY).expect("body must be accepted"); + stream + .write_all(REQUEST_BODY) + .expect("body must be accepted"); stream.flush().ok(); let mut out = Vec::new(); diff --git a/openfut-tls/Cargo.toml b/openfut-tls/Cargo.toml new file mode 100644 index 0000000..21a7e3b --- /dev/null +++ b/openfut-tls/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "openfut-tls" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Shared legacy-TLS listener for OpenFUT transport hosts" +publish = false + +[dependencies] +# Direct openssl, NOT native-tls and NOT rustls. +# +# The clients OpenFUT serves are legacy game clients. FIFA 17's ProtoSSL offers +# only static-RSA suites, so rustls cannot serve it at all, and native-tls +# deliberately hides the knobs that matter here: cipher list, protocol floor and +# ceiling, security level. +# +# VENDORED, so a distro libssl update cannot silently change whether a client +# can connect. The linked version is reported by `openssl_version()`, printed at +# host startup, and recorded in gate evidence — a TLS implementation change is +# not a routine dependency bump. +# +# This crate exists so that every host presents TLS built by the SAME code. Two +# hosts configuring OpenSSL separately is how the certificate mismatch of +# 2026-08-11 happened: the redirector served one certificate and the rest of the +# stack another, FIFA's ProtoSSL cached the first, and every later service +# failed its handshake with no error logged anywhere. +openssl = { version = "0.10", features = ["vendored"] } diff --git a/openfut-tls/src/lib.rs b/openfut-tls/src/lib.rs new file mode 100644 index 0000000..a0977c2 --- /dev/null +++ b/openfut-tls/src/lib.rs @@ -0,0 +1,484 @@ +//! Legacy-TLS listeners for OpenFUT transport hosts. +//! +//! # Why this crate exists +//! +//! Every OpenFUT host that faces a game client needs the same thing: an +//! OpenSSL acceptor configured for a client far older than the defaults any +//! modern TLS stack ships. Before this crate there was exactly one such host, so +//! its TLS lived inside it. The moment a second host needed the same setup, the +//! choice was to share this code or copy it. +//! +//! Copying it is not a style question. On 2026-08-11 the Rust redirector served +//! one certificate while the rest of the stack served another. FIFA's ProtoSSL +//! caches the server certificate per backend, so the first connection of a +//! session decided what the client expected and every later service failed its +//! handshake — silently, because Python's `socketserver` swallows `ssl.SSLError` +//! as `OSError` and logs nothing. Three gates were lost to it. One place to +//! configure TLS is the structural fix. +//! +//! # What belongs here, and what does not +//! +//! This crate is **game-independent**. It knows how to build an acceptor, not +//! which suites any particular client offers. Cipher lists, protocol windows, +//! the SNI a client sends, and which suites are deliberately refused are facts +//! about a *game*, and live in that game's adapter — see +//! `openfut-adapter-fifa17`'s `tls` module. Values arrive here as plain data so +//! adapters never take an OpenSSL dependency. +//! +//! # Scope of every setting +//! +//! Everything configured here applies to the `SslContext` being built. Nothing +//! global is weakened, and no other OpenFUT crate links OpenSSL. + +use std::fmt; +use std::path::Path; + +use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslOptions, SslVersion}; + +#[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 {} + +/// A TLS protocol version, expressed without an OpenSSL type. +/// +/// Adapters name the version window their client was observed to use; keeping +/// that expressible as plain data is what lets them stay dependency-free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolVersion { + Tls10, + Tls11, + Tls12, + Tls13, +} + +impl ProtocolVersion { + /// Parse OpenSSL's own spelling, as reported by a handshake. + /// + /// Deliberately strict: an unrecognised version is an error, never a + /// silent fallback to a default. Guessing here would mean serving a + /// protocol window nobody chose. + pub fn parse(s: &str) -> Result { + match s.trim() { + "TLSv1" | "TLSv1.0" => Ok(ProtocolVersion::Tls10), + "TLSv1.1" => Ok(ProtocolVersion::Tls11), + "TLSv1.2" => Ok(ProtocolVersion::Tls12), + "TLSv1.3" => Ok(ProtocolVersion::Tls13), + other => Err(TlsError(format!("unknown TLS version {other:?}"))), + } + } + + pub fn as_str(self) -> &'static str { + match self { + ProtocolVersion::Tls10 => "TLSv1.0", + ProtocolVersion::Tls11 => "TLSv1.1", + ProtocolVersion::Tls12 => "TLSv1.2", + ProtocolVersion::Tls13 => "TLSv1.3", + } + } + + fn to_ssl(self) -> SslVersion { + match self { + ProtocolVersion::Tls10 => SslVersion::TLS1, + ProtocolVersion::Tls11 => SslVersion::TLS1_1, + ProtocolVersion::Tls12 => SslVersion::TLS1_2, + ProtocolVersion::Tls13 => SslVersion::TLS1_3, + } + } +} + +/// 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: ProtocolVersion, + pub max_version: ProtocolVersion, + /// `None` = leave OpenSSL's default policy alone. Only set this if a real + /// handshake proves it necessary, and record why. Lowering the security + /// level pre-emptively weakens the service to fix a problem nobody has + /// demonstrated. + pub security_level: Option, +} + +impl TlsConfig { + /// Build a config from an adapter's observed profile. + /// + /// There is intentionally no `Default`: the cipher list and version window + /// are evidence about a specific client, and a generic crate has no + /// business inventing them. + pub fn new( + cert_path: impl Into, + key_path: impl Into, + cipher_list: impl Into, + min_version: ProtocolVersion, + max_version: ProtocolVersion, + ) -> TlsConfig { + TlsConfig { + cert_path: cert_path.into(), + key_path: key_path.into(), + cipher_list: cipher_list.into(), + min_version, + max_version, + 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. +pub fn openssl_version() -> String { + openssl::version::version().to_string() +} + +/// Build a listener acceptor from `cfg`. +pub fn build_acceptor(cfg: &TlsConfig) -> Result { + // `mozilla_intermediate` preloads a modern, forward-secret-only policy — + // precisely wrong for a legacy client. It is used only as a starting + // builder; every setting that matters is then stated explicitly below, 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.to_ssl())) + .map_err(|e| TlsError(format!("min proto: {e}")))?; + b.set_max_proto_version(Some(cfg.max_version.to_ssl())) + .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 SHA-256 fingerprint of the configured certificate, colon-separated. +/// +/// Exists so a host can log which certificate it is serving, and so a caller +/// can compare hosts. Serving a *different* certificate from the rest of the +/// stack is not a visible error at startup — it fails much later, inside a +/// client that logs nothing — so the value is worth printing unprompted. +pub fn certificate_fingerprint(cert_path: &str) -> Result { + let pem = std::fs::read(cert_path) + .map_err(|e| TlsError(format!("reading certificate {cert_path}: {e}")))?; + let cert = openssl::x509::X509::from_pem(&pem) + .map_err(|e| TlsError(format!("parsing certificate {cert_path}: {e}")))?; + let digest = cert + .digest(openssl::hash::MessageDigest::sha256()) + .map_err(|e| TlsError(format!("fingerprint: {e}")))?; + Ok(digest + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":")) +} + +/// 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 +/// openssl crate exposes no such accessor — 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. Passing an adapter's +/// observed client suites makes this a direct rehearsal of the retail +/// handshake. +/// +/// `sni` is the name the real client sends, so the rehearsal matches it. +pub fn self_test(cfg: &TlsConfig, client_ciphers: &str, sni: &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 cipher/version negotiation, not PKI: the 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(sni)) + .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::*; + + /// A self-signed pair, generated here rather than borrowed from the game + /// stack: this crate is game-independent and its tests must not depend on + /// FIFA material. Adapter crates test their own profiles. + fn keypair() -> (String, String) { + use openssl::asn1::Asn1Time; + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + use openssl::x509::{X509Name, X509}; + + let dir = std::env::temp_dir().join(format!("openfut-tls-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cert_path = dir.join("cert.pem"); + let key_path = dir.join("key.pem"); + if cert_path.exists() && key_path.exists() { + return ( + cert_path.to_string_lossy().into(), + key_path.to_string_lossy().into(), + ); + } + + let rsa = Rsa::generate(2048).unwrap(); + let pkey = PKey::from_rsa(rsa).unwrap(); + let mut name = X509Name::builder().unwrap(); + name.append_entry_by_text("CN", "openfut-tls-test").unwrap(); + let name = name.build(); + + let mut b = X509::builder().unwrap(); + b.set_version(2).unwrap(); + b.set_subject_name(&name).unwrap(); + b.set_issuer_name(&name).unwrap(); + b.set_pubkey(&pkey).unwrap(); + b.set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + b.set_not_after(&Asn1Time::days_from_now(3650).unwrap()) + .unwrap(); + b.sign(&pkey, MessageDigest::sha256()).unwrap(); + let cert = b.build(); + + std::fs::write(&cert_path, cert.to_pem().unwrap()).unwrap(); + std::fs::write(&key_path, pkey.private_key_to_pem_pkcs8().unwrap()).unwrap(); + ( + cert_path.to_string_lossy().into(), + key_path.to_string_lossy().into(), + ) + } + + /// A legacy static-RSA profile, standing in for what an adapter supplies. + fn cfg() -> TlsConfig { + let (c, k) = keypair(); + TlsConfig::new( + c, + k, + "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA", + ProtocolVersion::Tls12, + ProtocolVersion::Tls12, + ) + } + + #[test] + fn an_acceptor_builds_from_a_valid_pair() { + assert!(build_acceptor(&cfg()).is_ok()); + } + + #[test] + fn a_static_rsa_client_completes_a_handshake() { + let neg = self_test(&cfg(), "AES256-GCM-SHA384", "example.invalid").expect("handshake"); + assert_eq!(neg.cipher, "AES256-GCM-SHA384"); + assert_eq!(neg.version, "TLSv1.2"); + } + + /// The configured list is authoritative: a client offering only something + /// outside it must fail. Without this, an acceptor that silently kept the + /// modern default profile would look identical to a correct one. + #[test] + fn a_suite_outside_the_configured_list_is_refused() { + let r = self_test(&cfg(), "ECDHE-RSA-AES256-GCM-SHA384", "example.invalid"); + assert!(r.is_err(), "expected refusal, got {r:?}"); + } + + #[test] + fn the_protocol_window_is_enforced() { + let (c, k) = keypair(); + let mut cfg = TlsConfig::new( + c, + k, + "AES256-GCM-SHA384", + ProtocolVersion::Tls12, + ProtocolVersion::Tls12, + ); + assert_eq!( + self_test(&cfg, "AES256-GCM-SHA384", "x").unwrap().version, + "TLSv1.2" + ); + // Ceiling raised: TLS 1.3 has its own suites, so a 1.2-only client list + // still lands on 1.2 — the window is a window, not a forced version. + cfg.max_version = ProtocolVersion::Tls13; + assert_eq!( + self_test(&cfg, "AES256-GCM-SHA384", "x").unwrap().version, + "TLSv1.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 mut c = cfg(); + // A valid PEM that is not the key. + c.key_path = c.cert_path.clone(); + assert!(build_acceptor(&c).is_err(), "mismatch must not build"); + } + + #[test] + fn security_level_is_not_lowered_by_default() { + assert_eq!(cfg().security_level, None); + } + + #[test] + fn versions_round_trip_and_unknown_ones_are_refused() { + for v in [ + ProtocolVersion::Tls10, + ProtocolVersion::Tls11, + ProtocolVersion::Tls12, + ProtocolVersion::Tls13, + ] { + assert_eq!(ProtocolVersion::parse(v.as_str()).unwrap(), v); + } + // OpenSSL reports 1.0 as bare "TLSv1"; accept what a handshake reports. + assert_eq!( + ProtocolVersion::parse("TLSv1").unwrap(), + ProtocolVersion::Tls10 + ); + assert!(ProtocolVersion::parse("SSLv3").is_err()); + assert!(ProtocolVersion::parse("").is_err()); + } + + /// The fingerprint must be the certificate's, so two hosts can be compared. + #[test] + fn the_fingerprint_matches_openssl_and_differs_between_certificates() { + let (c, _) = keypair(); + let fp = certificate_fingerprint(&c).expect("fingerprint"); + assert_eq!(fp.len(), 32 * 3 - 1, "32 hex pairs, colon separated: {fp}"); + assert!(fp.chars().all(|ch| ch.is_ascii_hexdigit() || ch == ':')); + + // A different certificate must produce a different fingerprint — this + // is the whole point of the check that guards cross-host parity. + let dir = std::env::temp_dir().join(format!("openfut-tls-alt-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let other = dir.join("other.pem"); + if !other.exists() { + // Reuse the generator by pointing at a fresh temp dir. + let rsa = openssl::rsa::Rsa::generate(2048).unwrap(); + let pkey = openssl::pkey::PKey::from_rsa(rsa).unwrap(); + let mut nb = openssl::x509::X509Name::builder().unwrap(); + nb.append_entry_by_text("CN", "other").unwrap(); + let name = nb.build(); + let mut b = openssl::x509::X509::builder().unwrap(); + b.set_version(2).unwrap(); + b.set_subject_name(&name).unwrap(); + b.set_issuer_name(&name).unwrap(); + b.set_pubkey(&pkey).unwrap(); + b.set_not_before(&openssl::asn1::Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + b.set_not_after(&openssl::asn1::Asn1Time::days_from_now(1).unwrap()) + .unwrap(); + b.sign(&pkey, openssl::hash::MessageDigest::sha256()) + .unwrap(); + std::fs::write(&other, b.build().to_pem().unwrap()).unwrap(); + } + let fp2 = certificate_fingerprint(other.to_str().unwrap()).unwrap(); + assert_ne!(fp, fp2); + } + + #[test] + fn an_unreadable_certificate_reports_the_path() { + let e = certificate_fingerprint("/nonexistent/x.pem") + .unwrap_err() + .to_string(); + assert!(e.contains("/nonexistent/x.pem"), "{e}"); + } +}