diff --git a/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md b/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md index 2c17394..6bd70e2 100644 --- a/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md +++ b/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md @@ -226,3 +226,75 @@ freeze · capability disappears after a sentinel freeze — **all resolve to the 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-late` → **both 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.