Files
OpenFUT/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
T
funman300 a82407c686 docs(fifa17): harden patched-client session binding
Record the per-IP -> per-session correction: why source-IP-only was unsafe (two
FIFA processes share an IP), the authoritative per-login X-UT-SID key with IP and
persona as auxiliary, the Capability/StoreMode state machine, the single-use
short-TTL launcher->session pending hand-off, activity-based session cleanup, and
the documented fail-closed residual for genuinely simultaneous same-(ip,persona)
logins. Design history is retained; the per-IP prototype is marked superseded.
2026-08-13 04:39:53 +00:00

16 KiB
Raw Blame History

FIFA 17 — verified patched-client capability negotiation

Goal: let the FIFA 17 backend suppress the synthetic My-Packs sentinel (id 65534) only when the current FIFA process has positively verified that the CardsDLL resolver guard is active (JNZ→JG at RVA 0x14858). Unpatched / unsupported / unknown / failed-patch clients keep receiving the existing P2 active sentinel.

Core principle: the capability is not "this launcher supports the patch"; it is "the resolver guard was verified in this particular FIFA process."

This document is the design + the cross-component contract. It is deliberately additive: the P2 active-sentinel path (docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md) remains the default and the universal fallback.


1. Architecture inventory (as-built, verified by reading the code)

Data flow today (launch of one FIFA process):

LauncherApp::launch_game (openfut-launcher/src/app.rs:450)
  -> account_sync::sync           POST /openfut/account/sync  (:8099)   [REQUIRED; launch is gated on it]
  -> ensure_local_services()      spawn LSX, then autopatch.py --launcher-pid <launcher_pid>
  -> game_launch::launch          umu-run FIFA17.exe (grandchild; launcher never learns FIFA PID)
FIFA process
  -> autopatch.py                 self-discovers FIFA by comm=='FIFA17.exe'; patches /proc/<pid>/mem each tick
  -> FIFA -> backend              POST /ut/auth (login) ; GET /store/purchasegroup ; ...  (:8099)

Facts that shape the design:

  • Launcher ↔ autopatch IPC = one-way stdout only. local_services::spawn (openfut-launcher/src/local_services.rs:279-296) pipes autopatch stdout/stderr into the launcher LogBuffer line-by-line as [autopatch] <line>. There is no socket / named pipe / status-file readback. --launcher-pid is the launcher's own pid (local_services.rs:66), used for liveness, not to identify FIFA.
  • Launcher ↔ backend = exactly one control call: account_sync::sync (openfut-launcher/src/account_sync.rs:43) — a tiny stdlib-HTTP POST /openfut/account/sync on openfut_account_sync_port (default 8099), sent once per launch, before FIFA starts, and launch is blocked unless it succeeds (utas_server.py:1204). This is the reliable per-FIFA-process session boundary.
  • Backend is single-account, stateless-per-request, threaded. SID is a fixed module constant shared by all clients (utas_server.py:32); account identity is one global ACCOUNT singleton. There is no per-session identity in requests. The only per-connection discriminator available at every handler is self.client_address[0] (peer IP), currently unused. Server is ThreadingHTTPServer (utas_server.py:3784); module is import-safe (server under if __name__ == "__main__").
  • No bridge/proxy in the FIFA-17 path. FIFA reaches the Python backend's published :8099 directly (client-side DNAT/hosts redirect); the openfut-bridge is legacy FIFA-23. Docker's iptables DNAT preserves the source IP for external LAN clients. The launcher and FIFA run on the same client machine, so the backend observes them under the same peer IP regardless of NAT.

2. Capability transport — options and choice

Ranked against the as-built architecture:

autopatch → launcher (chosen: structured stdout line).

  1. Structured stdout line (CHOSEN). Reuses the existing one-way pipe the launcher already reads. It is live (only the current autopatch child's stdout), inherently child-bound, and carries zero stale-file risk — a previous launch's capability cannot leak because nothing is persisted. Smallest possible change. Format is a machine-readable token (§4).
  2. Status file in $XDG_RUNTIME_DIR keyed by launcher-pid+FIFA-pid+version+timestamp — works but needs explicit staleness handling and cleanup; more moving parts.
  3. Unix-domain socket — most capable but overkill; there is no bidirectional need.

launcher → backend (chosen: sibling HTTP endpoint on the account-sync port).

  • A. Existing session-init channel (CHOSEN). Add POST /openfut/fifa17/capability next to the existing /openfut/account/sync (same port 8099, same tiny stdlib-HTTP client). It cannot ride inside account_sync because the capability is only known after autopatch verifies (which happens after account_sync + FIFA start), so it is a separate, later call — but on the same proven transport.
  • B. Blaze/login metadata — rejected: no OpenFUT-owned field is available without risking a field FIFA depends on, and Blaze runs in a separate responder.
  • C. New local IPC + backend side-channel — unnecessary; A already exists.
  • D. Server-wide "assume patched" config — dev/testing fallback only; cannot distinguish patched vs unpatched clients, so never the production mechanism.

3. The capability (name + version + VERIFIED semantics)

  • Name: fifa17.empty_mypacks_resolver, integer version, current 1.
  • VERIFIED (v1) means, for THIS FIFA process: the CardsDLL tested build was recognised AND the live bytes at RVA 0x14858 are 7f 0f (JG) after autopatch enforcement — i.e. guarded_action returned "patch" (was 75 0f, written, re-read as 7f 0f) or "noop" (already 7f 0f).
  • It explicitly does NOT mean any of: "autopatch.py contains the guard code", "the launcher build is new enough", or "a config flag is set". The signal represents observed runtime enforcement on the specific process, nothing less.

4. autopatch verification state + emitted line

Per-FIFA-pid guard status (fail-closed; never loosens the existing byte guard):

state meaning
NOT_ATTEMPTED CardsDLL not yet mapped / guard not evaluated for this pid
VERIFIED live bytes == 7f 0f after enforcement (from patch or noop)
UNSUPPORTED_BUILD live bytes are neither the known original nor patched (guarded_actionskip)
WRITE_FAILED /proc/<pid>/mem write raised
VERIFY_FAILED post-write re-read != 7f 0f

Only VERIFIED advertises capability. On transition to VERIFIED, autopatch emits once per FIFA pid on stdout:

[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>

Any non-verified terminal state emits an explicit, non-advertising status line, e.g.:

[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=<pid> (no capability advertised)

5. Launcher per-process capability state

pub struct Fifa17ClientCapabilities { pub empty_mypacks_resolver: Option<u32> }
  • Starts UNKNOWN (None) at each launch.
  • Becomes Some(1) when the launcher parses a valid capability line from the current autopatch child's stdout (parse_capability_line).
  • Discarded when autopatch stops / FIFA exits / launcher exits / next launch. It is never persisted and never reused for a later FIFA process — staleness is structurally impossible.

On first Some(v), the launcher registers the capability with the backend (§6) once.

6. Launcher → backend registration + binding

POST /openfut/fifa17/capability (port = openfut_account_sync_port, 8099), body:

{"capability":"empty_mypacks_resolver","version":1,"personaId":<id>,"fifaPid":<pid>}
  • Binding key = source IP (self.client_address[0]). The registration arrives from the client machine's IP; FIFA's /store/purchasegroup requests arrive from the same IP (same machine). personaId/fifaPid are for logging only (the backend is single-account, so persona cannot discriminate clients).
  • Concurrency: distinct client machines → distinct peer IPs → independent decisions (no global state). Two FIFA processes on one machine share an IP — an accepted limitation (the backend is single-account anyway); documented in §Trust.

7. Backend session-stable decision

Per-IP record (guarded by a lock; threaded server):

_FIFA17_STORE[ip] = {"resolver": Option[int], "mode": Option[str]}   # mode: None|"sentinel"|"clean-v1"
  • Reset (session boundary): /openfut/account/sync from ip sets {resolver: None, mode: None}. This is the launcher's required per-launch call, so every new FIFA process starts from a clean, unfrozen record — no cross-process leak.
  • Register: /openfut/fifa17/capability from ip sets resolver = version. If mode is already frozen, it is logged as late and ignored for this session.
  • Freeze point = first /store/purchasegroup from ip (§9): if mode is None, set mode = "clean-v1" iff resolver == 1 else "sentinel", and log once. Thereafter mode is immutable for the session.
  • Default / fail-closed: an IP with no record (no account-sync, no capability), an unknown resolver version, a late capability, or a disappeared capability all resolve to (or remain) "sentinel".

8. Freeze point rationale

Freeze at first /store/purchasegroup, not at login/account-sync. account-sync fires before FIFA starts and before autopatch can verify, so freezing there would always be sentinel. First Store request is the earliest moment at which a genuine capability can already be registered (autopatch verifies at process start; the user opens the Store later), while still being a single, well-defined topology commit for the session. Once Store topology is served, it must not change (the F3 experiment proved a mid-session flip can leave a stale positive ordinal that crashes even the sign-only guard — see FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART III/IV and the SESSION-STABLE invariant).

9. Store behaviour (additive switch)

At store_catalog, only the zero-owned-packs branch changes:

if not owned_ids:
    if fifa17_empty_mypacks_mode(client_ip) == "clean-v1":
        pass                      # patched client: emit NO mypacks group; guard routes -1 to Browse
    else:
        <append active 65534 sentinel exactly as today>   # P2 fallback (unchanged)

Untouched: real owned-pack rendering, PACK_CATALOG, pack 70, normal packs 1/5/6/7, profile state, all store env flags. Default remains sentinel. This lives in the FIFA-17 Python backend only — never in game-independent OpenFUT Core.

client zero packs real unopened pack
verified v1 no sentinel (clean) genuine My Packs, no sentinel
no / unknown capability active 65534 sentinel genuine My Packs, no sentinel

10. Trust model (Task 14)

This is not anti-cheat / attestation. OpenFUT assumes the user controls the launcher/client machine and the server is a private preservation environment. The verification exists to prevent accidents: a stale capability, an unsupported CardsDLL build, a failed autopatch, the wrong process, or an unpatched client receiving no sentinel and crashing. No signatures / PKI / remote attestation.

Isolation across distinct client machines relies on the backend observing distinct peer IPs (source-IP-preserving publish; Docker's default for external LAN via iptables DNAT). Two FIFA processes on one machine cannot be distinguished by IP — accepted, since the backend is single-account. The single-client production case is unaffected by NAT because launcher and FIFA share one IP.

11. Fail-closed matrix (Task 15) — every failure ⇒ sentinel

autopatch missing / not run · guard UNSUPPORTED_BUILD / WRITE_FAILED / VERIFY_FAILED · launcher cannot parse the line · registration POST fails · account-sync never called · unknown capability version · capability arrives after freeze · capability disappears after a sentinel freeze — all resolve to the active 65534 sentinel. Asserted by tests (matrix AJ) and this document.

12. P2 retained (Task 16)

The active-sentinel implementation is not removed. It is the else-branch of the switch and the universal default for unpatched clients, unsupported builds, failed patches, unknown launchers, and late capabilities. The clean path is purely additive.


13. Session binding (hardening — supersedes the per-IP prototype)

History. The first implementation keyed the backend capability/store-mode by source IP alone (§7 as originally written). That was rejected before deployment: two FIFA processes that share a source IP — concurrent, or a relaunch — would share the key, so an unverified process could inherit a verified one's clean-v1 topology and crash on the empty-My-Packs resolver. Source IP is now auxiliary only (logging, a fail-closed sid/ip sanity check, and the pending hand-off key). This history is retained deliberately; do not treat per-IP as the design.

Authoritative key = the per-login UTAS session id (X-UT-SID). /ut/auth now mints a fresh unique SID per login (was a shared constant OPENFUT-SID-…0001); the client echoes it on every later call, and it is live-confirmed present on real /store/purchasegroup requests. The SID uniquely identifies one FIFA process/login: a relaunch re-auths → new SID; two concurrent logins → two SIDs. The legacy constant is still accepted by the retired security-question gate only, and is never used to grant clean-v1. A store request whose SID was opened on a different source IP is fail-closed to sentinel (sid/ip sanity check).

Why not persona alone: the backend is single-account, so personaId cannot distinguish two sessions, and a relaunch keeps the same persona — persona alone would leak a prior session's mode. Persona is used only (with IP) to key the pending hand-off.

State machine (per session, keyed by SID)

Capability : Unknown | ResolverV1
StoreMode  : Unfrozen | Sentinel | CleanV1

/ut/auth (new SID)         : Capability=Unknown, StoreMode=Unfrozen, record {ip,persona}
                             + consume any pending (ip,persona)  -> Capability=ResolverV1
capability registered      : bind to the one live Unfrozen/Unbound session for (ip,persona)
                             -> Capability=ResolverV1 ; else stage single-use pending ;
                             else (a session exists but is frozen/ambiguous) -> ignored-late
first /store/purchasegroup : Unfrozen + ResolverV1 -> freeze CleanV1
                             Unfrozen + otherwise   -> freeze Sentinel   (consume pending first)
late capability            : StoreMode already frozen -> unchanged (ignored-late, not staged)
capability lost/cleared    : after a CleanV1 freeze -> stays CleanV1 (mode is cached)
session idle > TTL / reaped: session discarded (a later store with that SID -> Sentinel)

Registration order + pending hand-off

The verified capability is known only after the FIFA process exists, CardsDLL is loaded, and autopatch confirms the JG bytes — which may land before or after /ut/auth, but reliably before the user opens the Store. The launcher cannot know the SID, so its registration is matched to a session by (source_ip, persona) as a single-use, short-TTL pending (FIFA17_PENDING_TTL = 120s) that is consumed by exactly one session, at whichever of these happens first for that session: its /ut/auth (pending predates login), the registration itself (session already live — bound directly), or its first store request (lazy). If the Store is reached before a capability binds, the session freezes Sentinel (fail-closed); a later capability does not change it.

Session cleanup (Task 10)

  • creation: at /ut/auth.
  • last activity: bumped on every /store/purchasegroup for the session.
  • freeze: first /store/purchasegroup.
  • expiry: lazy sweep on every session op removes sessions idle for FIFA17_SESSION_TTL = 3600s and pendings older than FIFA17_PENDING_TTL. Explicit Blaze/UTAS teardown is not reliably observable at this handler, so a conservative activity-based TTL is used instead. Reaping only removes expired entries and never affects another live session from the same IP/persona (keyed by distinct SIDs).

Residual limitation (documented, fail-closed)

FIFA carries no launcher-controllable per-process token, so two simultaneous logins from the same (ip, persona) cannot be disambiguated at the instant a capability is registered while both are Unfrozen/Unbound. That ambiguous case resolves to ignored-lateboth freeze Sentinel (safe: an unverified process is never granted clean). The normal one-launcher-per-FIFA and sequential-relaunch flows bind correctly (proven by matrix K/L/M). This is a UX conservativeness, never a safety hole.