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 <noreply@anthropic.com>
This commit is contained in:
@@ -70,5 +70,6 @@
|
||||
pub mod blaze;
|
||||
pub mod redirector;
|
||||
pub mod roster;
|
||||
pub mod tls;
|
||||
|
||||
pub use blaze::{Adapter, AdapterConfig, Session};
|
||||
|
||||
@@ -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"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rosterupdate version=\"0\"/>\n";
|
||||
pub const ROSTER_XML: &[u8] =
|
||||
b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rosterupdate version=\"0\"/>\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<usize> = 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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user