From 84e81f20377bb745592bda4038c4de88286c616f Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 11 Aug 2026 17:30:01 +0000 Subject: [PATCH] tls: extract a shared listener; move FIFA 17's profile into its adapter The redirector was the only host that spoke TLS, so its TLS lived inside it. The roster host needs the same listener, and that made the choice explicit: share this code or copy it. Copying it is what already went wrong. On 2026-08-11 the Rust redirector served one certificate while the container served another. ProtoSSL caches the server certificate per backend, so the redirector -- the first TLS connection of a session -- decided what the client expected, and every later service failed its handshake. Silently: Python's socketserver swallows ssl.SSLError as OSError. Three gates went to it. One place to configure TLS is the structural fix, so it exists before the second host does rather than after. Split along the line the architecture already draws: openfut-tls how to build an acceptor. Game-independent. Knows nothing about which suites any client offers. adapter-fifa17::tls what FIFA 17 was OBSERVED to offer: the six enabled suites, the two refused, the TLS 1.2 window, the EA SNI. Plain strings, so the adapter keeps its lean dependencies -- reading a card table should not build OpenSSL. redirector-host joins the two. Chooses no cipher of its own. Behaviour is unchanged, and shown to be: * tests/fifa17_tls_profile.rs carries over every case from the deleted module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting a module must not quietly delete its evidence. * one test pins the composed values literally against the host as it was when gates 1-14 passed. A "pure refactor" that cannot fail is not a claim, it is an assumption. * the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384 the retail client negotiated at 17:09 today. Two improvements fall out of having one place to look: * the startup banner now prints cert_sha256. The mismatch above raised no error at startup and broke the client much later with nothing logged; it is now the first line of the log. * tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line is gate evidence and gets read by people. Nothing deployed and nothing restarted: FIFA is mid-session on the running redirector, which is untouched. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 8 + Cargo.toml | 1 + openfut-adapter-fifa17/src/lib.rs | 1 + openfut-adapter-fifa17/src/roster/mod.rs | 29 +- openfut-adapter-fifa17/src/tls.rs | 141 +++++ openfut-adapter-fifa17/tests/roster_parity.rs | 3 +- openfut-redirector-host/Cargo.toml | 2 + openfut-redirector-host/src/config.rs | 34 +- openfut-redirector-host/src/lib.rs | 33 +- openfut-redirector-host/src/tls.rs | 358 ------------- .../tests/fifa17_tls_profile.rs | 140 +++++ .../tests/oracle_parity.rs | 10 +- openfut-tls/Cargo.toml | 27 + openfut-tls/src/lib.rs | 484 ++++++++++++++++++ 14 files changed, 887 insertions(+), 384 deletions(-) create mode 100644 openfut-adapter-fifa17/src/tls.rs delete mode 100644 openfut-redirector-host/src/tls.rs create mode 100644 openfut-redirector-host/tests/fifa17_tls_profile.rs create mode 100644 openfut-tls/Cargo.toml create mode 100644 openfut-tls/src/lib.rs 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}"); + } +}