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:
@@ -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::Negotiated, openfut_tls::TlsError> {
|
||||
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}");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user