89f77470f3
TLS DEPENDENCY, as directed: the openssl crate directly with the `vendored` feature. NOT native-tls. native-tls abstracts over whatever the platform provides; here the requirement is the opposite -- precise, evidenced behaviour for one legacy client -- which needs explicit control of the cipher list, protocol floor/ceiling and security level. Vendored so a distro libssl update cannot silently change whether FIFA 17 can connect. Scoped to this crate alone. Neither OpenFUT Core nor the generic protocol crates gain an OpenSSL dependency. CIPHERS driven by the captured retail ClientHello, not by generic legacy assumptions. The six RSA+AES suites it offers are enabled; RC4 and MD5 are deliberately NOT, even though the client offers them -- it already negotiates AES256-GCM-SHA384, so resurrecting RC4 for completeness would weaken the service for nothing. TLS 1.2 floor and ceiling, matching the observed client; the floor is not dropped to 1.0 pre-emptively because "the oracle permits it" is not "the client requires it". SECURITY LEVEL IS NOT LOWERED. Tried the default policy first, as directed, and OpenSSL 3.6.3 accepts static-RSA/AES without weakening. No SECLEVEL change was needed and none is applied; it remains overridable per-listener with evidence. CERTIFICATE: the proven Python redirector's material is reused, so the TLS implementation stays the only variable in an A/B. Verified RSA-2048, CN winter15.gosredirector.ea.com, cert/key modulus match; the key stays gitignored. SHARED CONFIG. New openfut-host-config is now the only crate that reads the environment, and both hosts resolve endpoints through it. Two hosts each parsing OPENFUT_ADVERTISE would be exactly the "separate helpers constructing endpoints from different sources of truth" the address audit forbids. VERIFICATION BY REAL HANDSHAKE, not by enumeration. The crate exposes no accessor for a context's configured suites at this version, which turned out better: the host now rehearses the retail handshake at startup with a client restricted to exactly FIFA's eight suites and REFUSES TO SERVE if it fails, so a cipher/version misconfiguration surfaces at boot rather than as an unexplained failure during a live gate. Gates 1-5 pass: TLS config unit tests; a FIFA-suite-only client negotiates TLSv1.2/AES256-GCM-SHA384; each enabled RSA+AES suite negotiable alone; an RC4-only client is refused; an ECDHE-only client is refused (proving no modern policy was silently inherited); a full HTTPS round-trip returns bytes IDENTICAL to the Python oracle's recorded response. Cargo.lock committed for reproducibility: openssl 0.10.81, openssl-sys 0.9.117, openssl-src 300.6.1+3.6.3 (OpenSSL 3.6.3). Updating openssl-src is NOT a routine bump -- it requires re-running the FIFA compatibility gates. Gates 6-14 need the retail client and are next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
359 lines
13 KiB
Rust
359 lines
13 KiB
Rust
//! 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<u32>,
|
|
}
|
|
|
|
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<String>, key_path: impl Into<String>) -> 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<SslAcceptor, TlsError> {
|
|
// `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<Negotiated, TlsError> {
|
|
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<Negotiated, String> {
|
|
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}");
|
|
}
|
|
}
|