diff --git a/docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md b/docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md new file mode 100644 index 0000000..26d7ad4 --- /dev/null +++ b/docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md @@ -0,0 +1,113 @@ +# FIFA 17 "FUT Squad Update" download failure — root cause + +**Status:** root-caused, not yet fixed. Blocks any FIFA session pointed at a stack whose +roster server presents the current self-signed certificate. + +**Symptom (client):** entering the FUT hub shows + +> An error occurred downloading the FUT Squad Update. Please try again. + +Recovered from the live client's memory with `scripts/client-error-string.py`, not from a +log. Nothing server-side reports an error: every UTAS route answers 200 and the Blaze +session stays healthy, answering PINGs while the client sits on the dialog. + +## What it is + +"Squad Update" here is the **roster update**, not the player's lineup. The Blaze responder +advertises it as the FUT loading gate (`blaze_responder_v3b.py`): + +```python +ROSTER_HOST = "%s:8081" % _ADVERTISE +("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST) +("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST) +``` + +The client fetches that URL over TLS, **by IP**, from `roster_server.py`. + +## Root cause + +The client completes TCP, sends a ClientHello, receives the certificate, and then +**aborts the handshake itself**. Captured and decoded from the wire: + +``` +CLIENT: ClientHello client_version = TLS1.2 + offers 8 suites: RSA_AES_256_GCM_SHA384, RSA_AES_128_GCM_SHA256, + RSA_AES_256_CBC_SHA256, RSA_AES_128_CBC_SHA256, RSA_AES_256_CBC_SHA, + RSA_AES_128_CBC_SHA, RSA_RC4_128_SHA, RSA_RC4_128_MD5 +SERVER: ServerHello TLS1.2 -> Certificate (909 B) -> ServerHelloDone +CLIENT: Alert level=fatal desc=certificate_unknown +``` + +Protocol and cipher selection are **compatible** — TLS1.2 with an RSA suite is offered and +chosen. The client rejects the **certificate**: + +``` +subject/issuer = CN = winter15.gosredirector.ea.com (self-signed, CA:TRUE) +SAN = DNS:winter15.gosredirector.ea.com, DNS:*.gosredirector.ea.com, DNS:*.ea.com +notBefore = Aug 13 01:12:30 2026 +sha256 = FF:66:69:48:E2:02:AB:F4:A4:40:78:B1:8C:E1:F9:65:96:32:BB:FF:1F:3D:CC:BC:07:54:02:11:8F:4D:12:3F +``` + +Two facts matter: + +1. **The SAN carries DNS names only, no `iPAddress` entry**, while the advertised URL is an + IP literal (`https://10.10.0.120:8081/...`). A DNS-only SAN cannot match an IP dial. +2. **The certificate was regenerated on Aug 13.** `roster_server.py` loads the same + `redir_cert.pem`/`redir_key.pem` as the Blaze redirector, so the redirector rejects + identically — visible as `REDIR REJECTED ... TLS/SSL connection has been closed (EOF)` + in the Blaze log. That is tolerated only because the client then reaches Blaze main over + plain TCP; the roster fetch has no such fallback, so it is where the failure surfaces. + +`roster_server.py` still carries the comment *"ProtoSSL cert-verify is patched (autopatch), +so our self-signed cert is accepted."* That assumption no longer holds for this path. +autopatch demonstrably patched both gates in the failing process — the log shows +`pid 56298: PATCHED cert gates` and the live bytes read back as the patched patterns +(`909090909090` at `0x146132548`, `31c0c3` at `0x1461361b0`) — and the client still sent +`certificate_unknown`. So **those two gates do not govern this validation**; either another +check exists on the roster path, or it validates before reaching them. + +## Ruled out, with evidence + +| Hypothesis | Evidence against | +| --- | --- | +| Missing/mistyped UTAS field | 0 structural differences across 14 hub routes vs production (`scripts/hub-diff-prod-staging.py`) | +| Player squad shape | matches production field-for-field, including `squadType`, `custom`, `chemistry`, `manager` | +| Squad save | PUT/GET round-trips exactly, every field and slot | +| Cert gates unpatched | autopatch log + live byte read both confirm patched | +| Roster server broken | serves `200`, `application/xml` over TLS1.2 `AES256-GCM-SHA384` | +| Advertised host wrong | production and staging Blaze envs are identical for `OPENFUT_ADVERTISE`, `POW_*`; the scripts are byte-identical | +| TLS version mismatch | client offers TLS1.2 and the server selects it | +| Blaze session | healthy, PING/PING_REPLY throughout | + +## Probing gotcha + +A default modern Python TLS context gets `SSLV3_ALERT_HANDSHAKE_FAILURE` against this +server and looks like a server fault. It is not — the server requires legacy-compatible +cipher selection. Probe with `ALL:@SECLEVEL=0`, and note it rejects TLS1.0/1.1 outright +(`TLSV1_ALERT_PROTOCOL_VERSION`), accepting only TLS1.2. + +## Why production appears unaffected + +Unresolved, and worth stating plainly rather than guessing. Production advertises the same +URL, backed by the same server and certificate, so the same rejection should occur. The +operator reports production is clean. The likely explanation is that a production client +does not re-fetch the roster (already satisfied), while a session against a different Blaze +forces the fetch — but that has not been measured. The known-good hook log used as a +control (Aug 12 19:13) **predates the Aug 13 certificate regeneration**, so it is not +evidence about the current certificate. + +## Fix options (none applied) + +1. **Reissue the certificate with an `iPAddress` SAN** for the advertised address. Smallest + change, addresses the concrete mismatch, and benefits the redirector too. Note + `openfut-tls` exists precisely so the redirector and roster hosts cannot configure TLS + separately — the 2026-08-11 mismatch it was written for is the same class of bug. +2. **Serve the roster over plain HTTP** for an isolated stack, by pointing + `ROSTERUPDATE_URL`/`ROSTER_URL` at a staging-owned HTTP server. Sidesteps TLS entirely; + only acceptable off production. +3. **Find the real validation site** on the roster path and extend the patch set. Most + faithful, most work, and the only option that restores the original assumption. + +Option 1 is the recommended starting point because it is testable without the client: +a probe that validates hostname/IP against the presented certificate will fail before the +fix and pass after.