fix(fifa17): isolate patched-client capability per session

Harden the empty-My-Packs capability binding so a verified FIFA process can never
enable clean/no-sentinel Store topology for another unverified process that merely
shares its source IP. The prototype keyed the decision by source IP alone; two FIFA
processes (concurrent, or a relaunch) share an IP, so an unpatched process could
inherit a patched one's clean-v1 mode and crash. Source IP is now auxiliary only.

- 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) and opens a session record
  keyed by that SID; the client echoes it on every later call incl.
  /store/purchasegroup (live-confirmed). The legacy constant is still accepted by
  the retired security-question gate only, never to grant clean-v1.
- Session state: _FIFA17_SESSIONS[sid] = {ip, persona, resolver, mode, created,
  last_seen}. Store mode freezes at the first /store/purchasegroup of the session
  and is immutable thereafter. Fail-closed: unknown SID, or a SID presented from a
  different source IP than it was opened on, resolves to the sentinel.
- Launcher capability (out-of-band; cannot know the SID) is matched by (ip, persona)
  as a SINGLE-USE, short-TTL pending, bound to exactly one session at whichever comes
  first: its login (pending predates auth), the registration (session already live),
  or its first store request. Ambiguous same-(ip,persona) concurrent registration is
  ignored-late -> both sentinel (never a wrong clean).
- Session cleanup: activity-based TTL sweep (sessions 3600s idle, pendings 120s);
  reaping only removes expired entries and never affects another live session.
- account_sync now clears only stale pending for the machine (pre-launch hygiene);
  it no longer resets a per-IP mode (there is no per-IP mode any more).

Backend-only: the launcher registration payload (already carries personaId) is
unchanged. Additive; P2 sentinel remains the else-branch and the default.

Tests: matrix A-Q incl. same-IP concurrent (K), same-IP+persona relaunch (L),
same-IP failed-patch (M), late-registration-vs-frozen-sessions (N), TTL expiry (O),
duplicate/idempotent registration (P), and register-before-login pending (Q).
This commit is contained in:
funman300
2026-08-13 04:39:53 +00:00
parent d4c3811665
commit 805d754dc8
2 changed files with 347 additions and 189 deletions
+158 -48
View File
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import copy, datetime, json, os, random, re, sys, threading, http.server
import copy, datetime, json, os, random, re, sys, threading, time, http.server
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -52,62 +52,170 @@ def visible_unopened_packs():
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
# ---- FIFA17 empty-My-Packs capability negotiation (per-IP, session-stable) ----
# ---- FIFA17 empty-My-Packs capability negotiation (PER-SESSION, hardened) ----
# The synthetic 65534 sentinel (store_catalog) is the universal P2 fallback. It is
# suppressed for a session ONLY when the launcher has registered that the CURRENT
# FIFA process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
# Verification is per-FIFA-process; the backend binds it to the peer IP and freezes
# a per-session decision at the first /store/purchasegroup. Fail-closed: any unknown
# / late / absent / wrong-version capability resolves to the active sentinel.
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§7/§9/§11).
# suppressed for ONE FIFA session only when the launcher has registered that THAT
# process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
#
# BINDING: the authoritative key is the per-login-unique UTAS session id (X-UT-SID),
# minted fresh at every /ut/auth and echoed by the client on every later call incl.
# /store/purchasegroup (live-confirmed present on real store requests). The initial
# prototype keyed on source IP ALONE; that was rejected because two FIFA processes
# (concurrent or relaunched) share an IP, so an unverified process could inherit a
# verified one's clean topology and crash. IP + persona are retained only as
# auxiliary data: a fail-closed sid/ip sanity check and the (ip,persona) key for the
# short-lived launcher->session hand-off.
#
# The launcher verifies out-of-band (autopatch) and cannot know the SID, so its
# registration is staged as a SINGLE-USE, short-TTL PENDING keyed by (ip,persona)
# and bound to exactly one FIFA session (directly if that session already exists,
# else consumed at the session's login or its first store request). Fail-closed
# everywhere: unknown / expired / absent / ambiguous / late => sentinel.
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§Session binding).
FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION = 1
FIFA17_MODE_SENTINEL = "sentinel"
FIFA17_MODE_CLEAN = "clean-v1"
_FIFA17_STORE = {} # ip -> {"resolver": Optional[int], "mode": Optional[str]}
_FIFA17_STORE_LOCK = threading.Lock()
FIFA17_SESSION_TTL = 3600.0 # reap a FIFA session after this many idle seconds
FIFA17_PENDING_TTL = 120.0 # a launcher capability may await its session this long
# sid -> {"ip","persona","resolver": Optional[int],"mode": Optional[str],"created","last_seen"}
_FIFA17_SESSIONS = {}
# (ip, persona) -> {"resolver": int, "ts"}: single-use launcher->session hand-off.
_FIFA17_PENDING = {}
_FIFA17_LOCK = threading.Lock()
def _fifa17_now():
return time.monotonic()
def _fifa17_client_ip(h):
"""Peer IP for the request handler, or None when unavailable (e.g. h is None)."""
"""Peer IP for the handler, or None when unavailable (e.g. h is None)."""
try:
return h.client_address[0]
except Exception:
return None
def fifa17_reset_session(ip):
"""Session boundary (/openfut/account/sync): clear capability + unfreeze mode."""
with _FIFA17_STORE_LOCK:
_FIFA17_STORE[ip] = {"resolver": None, "mode": None}
def _fifa17_sid(h):
"""The client's UTAS session id (X-UT-SID) for this request, or None."""
try:
return h.headers.get("X-UT-SID")
except Exception:
return None
def fifa17_register_capability(ip, version):
"""Register a verified resolver capability for ip. Returns the current mode.
If the session's mode is already frozen, the capability is logged as late and
ignored for this session (mode is immutable after the first store request)."""
with _FIFA17_STORE_LOCK:
rec = _FIFA17_STORE.setdefault(ip, {"resolver": None, "mode": None})
rec["resolver"] = version
if rec["mode"] is not None:
log("[fifa17-store] capability arrived after mode freeze; ignored for "
"current session (ip %s)" % ip)
return rec["mode"]
def _fifa17_sidlog(sid):
"""A short, non-secret tag for correlating a session in logs."""
return ("\u2026" + sid[-6:]) if sid else "-"
def fifa17_empty_mypacks_mode(ip):
"""Resolve (and freeze on first call) the empty-My-Packs mode for ip.
def _fifa17_mint_sid():
"""A fresh, per-login-unique UTAS session id (same shape/length as the legacy
constant). Uniqueness -- not unpredictability -- is what the binding needs."""
return "OPENFUT-SID-%016X" % random.getrandbits(64)
Freeze point = first /store/purchasegroup: clean-v1 iff a matching-version
resolver capability is already registered, else the sentinel fallback."""
with _FIFA17_STORE_LOCK:
rec = _FIFA17_STORE.setdefault(ip, {"resolver": None, "mode": None})
def _fifa17_reap_locked(now):
for sid in [s for s, r in _FIFA17_SESSIONS.items()
if now - r["last_seen"] > FIFA17_SESSION_TTL]:
del _FIFA17_SESSIONS[sid]
for key in [k for k, p in _FIFA17_PENDING.items()
if now - p["ts"] > FIFA17_PENDING_TTL]:
del _FIFA17_PENDING[key]
def _fifa17_take_pending_locked(ip, persona, now):
"""Single-use: remove and return a fresh pending resolver for (ip,persona)."""
p = _FIFA17_PENDING.get((ip, persona))
if p is not None and now - p["ts"] <= FIFA17_PENDING_TTL:
del _FIFA17_PENDING[(ip, persona)]
return p["resolver"]
return None
def fifa17_session_known(sid):
"""True if sid is a live session (or the legacy constant, accepted by the
retired security-question gate ONLY -- never used to grant clean store mode)."""
if sid == SID:
return True
with _FIFA17_LOCK:
return sid in _FIFA17_SESSIONS
def fifa17_open_session(sid, ip, persona):
"""/ut/auth: open a per-login session and bind any pending launcher capability
for (ip,persona) that arrived before login."""
if not sid:
return
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
resolver = _fifa17_take_pending_locked(ip, persona, now)
_FIFA17_SESSIONS[sid] = {"ip": ip, "persona": persona, "resolver": resolver,
"mode": None, "created": now, "last_seen": now}
log("[fifa17-store] session opened %s (ip=%s persona=%s resolver=%s)"
% (_fifa17_sidlog(sid), ip, persona, resolver))
def fifa17_clear_pending(ip):
"""/openfut/account/sync hygiene: drop any stale pending for this machine so a
new launch's unverified session cannot inherit a leftover capability."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
for key in [k for k in _FIFA17_PENDING if k[0] == ip]:
del _FIFA17_PENDING[key]
def fifa17_register_capability(ip, persona, version):
"""Launcher registration. Returns one of:
"bound" exactly one live, unfrozen, unbound session for (ip,persona)
existed (registration after login -- the common case): bound now.
"pending" no session for (ip,persona) yet (before login): staged single-use.
"ignored-late" a session for (ip,persona) exists but is frozen or ambiguous
(>1 unbound): NOT staged, so no later/unverified process can
inherit it. Fail-closed.
Never authorizes more than one session."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
sessions = [r for r in _FIFA17_SESSIONS.values()
if r["ip"] == ip and r["persona"] == persona]
candidates = [r for r in sessions if r["mode"] is None and r["resolver"] is None]
if len(candidates) == 1:
candidates[0]["resolver"] = version
return "bound"
if sessions:
return "ignored-late"
_FIFA17_PENDING[(ip, persona)] = {"resolver": version, "ts": now}
return "pending"
def fifa17_empty_mypacks_mode(sid, ip):
"""Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
Freeze point = the first /store/purchasegroup of the session. Fail-closed: an
unknown session, or a sid presented from a different IP than it was opened on,
resolves to the sentinel."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
rec = _FIFA17_SESSIONS.get(sid)
if rec is None:
return FIFA17_MODE_SENTINEL
rec["last_seen"] = now
if rec["ip"] is not None and ip is not None and rec["ip"] != ip:
log("[fifa17-store] sid %s ip mismatch (session %s != request %s) -> sentinel"
% (_fifa17_sidlog(sid), rec["ip"], ip))
return FIFA17_MODE_SENTINEL
if rec["mode"] is None:
if rec["resolver"] is None:
rec["resolver"] = _fifa17_take_pending_locked(rec["ip"], rec["persona"], now)
rec["mode"] = (FIFA17_MODE_CLEAN
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
else FIFA17_MODE_SENTINEL)
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
% (ip, rec["mode"]))
% (_fifa17_sidlog(sid), rec["mode"]))
return rec["mode"]
@@ -161,7 +269,7 @@ def security_question_route(h):
well-formed value without retaining or comparing it. Account selection has
already initialized the server-owned verified compatibility state.
"""
if h.headers.get("X-UT-SID") != SID:
if not fifa17_session_known(h.headers.get("X-UT-SID")):
log("[FUT] security-question request has no matching OpenFUT session")
return 400, {"reason": "invalid_session"}
@@ -252,17 +360,19 @@ def auth_body(h=None):
except Exception as e: # adoption must never break auth
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
% (type(e).__name__, e, before[0], before[1]))
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
sid = _fifa17_mint_sid()
fifa17_open_session(sid, _fifa17_client_ip(h), ACCOUNT.persona_id)
return {"protocol": 1, "sid": sid, "serverTime": now(), "lastOnlineTime": now()}
def account_sync_route(h):
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
# Session boundary: each launcher account-sync starts a fresh per-IP FIFA17
# capability session (unfreeze mode + clear any prior capability). A new FIFA
# process must re-verify; nothing leaks across processes.
# Pre-launch hygiene: drop any stale launcher capability still pending for this
# machine so a new launch's unverified FIFA session cannot inherit it. The real
# per-process session is opened later, at /ut/auth (keyed by the minted X-UT-SID).
ip = _fifa17_client_ip(h)
fifa17_reset_session(ip)
log(" ACCOUNT: reset FIFA17 empty-mypacks capability session for ip %s" % ip)
fifa17_clear_pending(ip)
log(" ACCOUNT: cleared stale FIFA17 pending capability for ip %s" % ip)
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
account = activate_account(body)
@@ -293,11 +403,11 @@ def fifa17_capability_route(h):
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
return 400, {"error": "unsupported capability"}
ip = _fifa17_client_ip(h)
fifa17_register_capability(ip, version)
persona = body.get("personaId", "?")
persona = body.get("personaId")
fifa_pid = body.get("fifaPid", "?")
log("[fifa17-store] registered capability empty_mypacks_resolver=%s for %s "
"(persona %s, fifa_pid %s)" % (version, ip, persona, fifa_pid))
status = fifa17_register_capability(ip, persona, version)
log("[fifa17-store] capability empty_mypacks_resolver=%s ip=%s persona=%s "
"fifa_pid=%s -> %s" % (version, ip, persona, fifa_pid, status))
return 200, {"status": "OK"}
@@ -3524,9 +3634,9 @@ def store_catalog(h):
if not owned_ids:
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
# this peer IP is committed here at the first /store/purchasegroup and is
# immutable for the session thereafter.
mode = fifa17_empty_mypacks_mode(_fifa17_client_ip(h))
# this FIFA session (keyed by its X-UT-SID) is committed here at the first
# /store/purchasegroup and is immutable for the session thereafter.
mode = fifa17_empty_mypacks_mode(_fifa17_sid(h), _fifa17_client_ip(h))
if mode == FIFA17_MODE_CLEAN:
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of