diff --git a/fifa17-recon/tools/test_capability_negotiation.py b/fifa17-recon/tools/test_capability_negotiation.py index 82c2520..f4ad645 100755 --- a/fifa17-recon/tools/test_capability_negotiation.py +++ b/fifa17-recon/tools/test_capability_negotiation.py @@ -1,23 +1,33 @@ #!/usr/bin/env python3 """Tests for the FIFA 17 verified-patched-client capability negotiation. -Pins the additive empty-My-Packs switch built on top of the P2 65534 sentinel: -the sentinel is suppressed for a session ONLY when the launcher has registered a -verified resolver capability (v1) for the CURRENT FIFA process, bound to the peer -IP, and the decision is frozen at the first /store/purchasegroup. Every failure / -unknown / late / cross-process case is fail-closed to the active sentinel. +The additive empty-My-Packs switch on top of the P2 65534 sentinel: the sentinel is +suppressed for ONE FIFA session only when the launcher has registered a verified +resolver capability (v1) that binds to THAT process's UTAS session (keyed by the +per-login-unique X-UT-SID; source IP + persona are auxiliary). Every failure / +unknown / late / cross-process / cross-session case is fail-closed to the sentinel. -Covers matrix A-J from docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (Task 12/15): - A no-capability, zero packs -> sentinel - B verified v1, zero packs -> clean (no 65534) - C real unopened pack + no capability -> genuine pack, no sentinel - D real unopened pack + capability -> genuine pack, no sentinel - E unsupported version -> endpoint 400 AND mode sentinel - F late capability after sentinel freeze -> stays sentinel - G capability disappears after clean freeze-> stays clean (immutable) - H two concurrent IPs (A verified, B none) -> A clean, B sentinel (no global leak) - I new session via reset clears capability -> fresh unpatched process -> sentinel - J autopatch mismatch => never registers -> sentinel +The initial prototype keyed by source IP alone; this suite proves the hardened +per-session binding, including two sessions that SHARE a source IP. + +Matrix (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md): + A no-capability, zero packs -> sentinel + B verified v1, zero packs -> clean (no 65534) + C real unopened pack + no capability -> genuine pack, no sentinel + D real unopened pack + capability -> genuine pack, no sentinel + E unsupported version / capability -> endpoint 400 AND mode sentinel + F late capability after sentinel freeze -> stays sentinel + G capability disappears after clean freeze -> stays clean (immutable) + H two IPs (A verified, B none) -> A clean, B sentinel (no global leak) + I new session after reset -> fresh unpatched -> sentinel + J autopatch mismatch => never registers -> sentinel + K SAME IP, two sessions (A patched, B not) -> A clean, B sentinel + L SAME IP+persona relaunch (old ok, new not) -> new session sentinel + M SAME IP, failed-patch second session -> first clean, second sentinel + N late registration when sessions are frozen -> does not modify active sessions + O session cleanup / TTL expiry -> capability gone, sentinel + P duplicate registration for a session -> idempotent; no post-freeze change + Q register-before-login (pending consumed) -> clean Standalone unit test in the project style: `python3 test_capability_negotiation.py`. """ @@ -33,13 +43,15 @@ if TOOLS not in sys.path: SENTINEL_ID = 65534 REAL_PACK_ID = 1 +PERSONA = 111001 class _H: - """Minimal request-handler stand-in: peer IP + optional JSON body.""" + """Minimal request-handler stand-in: peer IP, optional X-UT-SID, optional body.""" - def __init__(self, ip, body=None): + def __init__(self, ip, body=None, sid=None): self.client_address = (ip, 54321) + self.headers = {"X-UT-SID": sid} if sid is not None else {} self._body = json.dumps(body).encode("utf-8") if body is not None else b"" @@ -62,166 +74,202 @@ def main(): importlib.reload(fut_accounts) importlib.reload(utas_server) - fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"}) + us = utas_server + CLEAN, SENT = us.FIFA17_MODE_CLEAN, us.FIFA17_MODE_SENTINEL - # ---- deterministic pack topology helpers ------------------------------- - _orig_visible = utas_server.visible_unopened_packs + _orig_visible = us.visible_unopened_packs def set_zero_packs(): - utas_server.visible_unopened_packs = lambda: [] + us.visible_unopened_packs = lambda: [] def set_real_pack(): - utas_server.visible_unopened_packs = lambda: [REAL_PACK_ID] + us.visible_unopened_packs = lambda: [REAL_PACK_ID] def reset_state(): - """Fresh capability store between cases (no cross-case leakage).""" - utas_server._FIFA17_STORE.clear() + us._FIFA17_SESSIONS.clear() + us._FIFA17_PENDING.clear() - def register(ip, version, persona=42, pid=4242): - return utas_server.fifa17_capability_route(_H(ip, { - "capability": "empty_mypacks_resolver", - "version": version, - "personaId": persona, - "fifaPid": pid, + def auth(sid, ip, persona=PERSONA): + """Simulate /ut/auth opening a per-login session with a chosen sid.""" + us.fifa17_open_session(sid, ip, persona) + + def register(ip, version, persona=PERSONA, pid=4242): + return us.fifa17_capability_route(_H(ip, { + "capability": "empty_mypacks_resolver", "version": version, + "personaId": persona, "fifaPid": pid, })) - def store(ip): - status, cat = utas_server.store_catalog(_H(ip)) + def store(sid, ip): + status, cat = us.store_catalog(_H(ip, sid=sid)) assert status == 200, status return _ids(cat) + def mode_of(sid): + return us._FIFA17_SESSIONS[sid]["mode"] + try: # ---- A. no capability, zero packs -> sentinel ---------------------- - reset_state() - set_zero_packs() - ip = "10.0.0.1" - utas_server.fifa17_reset_session(ip) - ids = store(ip) - assert SENTINEL_ID in ids, ids - assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL + reset_state(); set_zero_packs() + auth("sidA", "10.0.0.1") + assert SENTINEL_ID in store("sidA", "10.0.0.1") + assert mode_of("sidA") == SENT print("A no-capability zero-packs -> sentinel: OK") # ---- B. verified v1, zero packs -> clean --------------------------- - reset_state() - set_zero_packs() - ip = "10.0.0.2" - utas_server.fifa17_reset_session(ip) - st, body = register(ip, 1) - assert st == 200 and body == {"status": "OK"}, (st, body) - ids = store(ip) + reset_state(); set_zero_packs() + auth("sidB", "10.0.0.2") + assert register("10.0.0.2", 1)[0] == 200 + ids = store("sidB", "10.0.0.2") assert SENTINEL_ID not in ids, ids - assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_CLEAN + assert mode_of("sidB") == CLEAN print("B verified-v1 zero-packs -> clean: OK") # ---- C. real pack + no capability -> genuine, no sentinel ---------- - reset_state() - set_real_pack() - ip = "10.0.0.3" - utas_server.fifa17_reset_session(ip) - ids = store(ip) - assert SENTINEL_ID not in ids, ids - assert REAL_PACK_ID in ids, ids + reset_state(); set_real_pack() + auth("sidC", "10.0.0.3") + ids = store("sidC", "10.0.0.3") + assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids print("C real-pack no-capability -> genuine, no sentinel: OK") # ---- D. real pack + capability -> genuine, no sentinel ------------- - reset_state() - set_real_pack() - ip = "10.0.0.4" - utas_server.fifa17_reset_session(ip) - register(ip, 1) - ids = store(ip) - assert SENTINEL_ID not in ids, ids - assert REAL_PACK_ID in ids, ids + reset_state(); set_real_pack() + auth("sidD", "10.0.0.4"); register("10.0.0.4", 1) + ids = store("sidD", "10.0.0.4") + assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids print("D real-pack capability -> genuine, no sentinel: OK") - # ---- E. unsupported version -> 400 AND mode sentinel --------------- - reset_state() - set_zero_packs() - ip = "10.0.0.5" - utas_server.fifa17_reset_session(ip) - for bad in (2, 99): - st, body = register(ip, bad) - assert st == 400 and "error" in body, (bad, st, body) - # nothing recorded -> resolver stays None - assert utas_server._FIFA17_STORE[ip]["resolver"] is None - ids = store(ip) - assert SENTINEL_ID in ids, ids - assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL - # a bad capability string with the right version is also rejected - st, body = utas_server.fifa17_capability_route(_H("10.0.0.55", { - "capability": "something_else", "version": 1})) - assert st == 400, (st, body) - print("E unsupported version -> 400 + sentinel: OK") + # ---- E. unsupported version / capability -> 400 + sentinel --------- + reset_state(); set_zero_packs() + auth("sidE", "10.0.0.5") + assert register("10.0.0.5", 2)[0] == 400 + assert register("10.0.0.5", 99)[0] == 400 + assert us.fifa17_capability_route( + _H("10.0.0.5", {"capability": "bogus", "version": 1}))[0] == 400 + assert SENTINEL_ID in store("sidE", "10.0.0.5") + assert mode_of("sidE") == SENT + print("E unsupported version/capability -> 400 + sentinel: OK") - # ---- F. late capability after sentinel freeze -> stays sentinel ---- - reset_state() - set_zero_packs() - ip = "10.0.0.6" - utas_server.fifa17_reset_session(ip) - ids = store(ip) # freeze: sentinel - assert SENTINEL_ID in ids, ids - register(ip, 1) # arrives late; ignored for session - ids = store(ip) - assert SENTINEL_ID in ids, "late capability must not flip a frozen sentinel" - assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL + # ---- F. late capability after sentinel freeze -> sentinel ---------- + reset_state(); set_zero_packs() + auth("sidF", "10.0.0.6") + assert SENTINEL_ID in store("sidF", "10.0.0.6") # freezes sentinel + assert register("10.0.0.6", 1)[0] == 200 # session frozen -> ignored-late + assert SENTINEL_ID in store("sidF", "10.0.0.6") + assert mode_of("sidF") == SENT print("F late capability after sentinel freeze -> sentinel: OK") - # ---- G. capability disappears after clean freeze -> stays clean ---- - reset_state() - set_zero_packs() - ip = "10.0.0.7" - utas_server.fifa17_reset_session(ip) - register(ip, 1) - ids = store(ip) # freeze: clean - assert SENTINEL_ID not in ids, ids - utas_server._FIFA17_STORE[ip]["resolver"] = None # capability vanishes - ids = store(ip) - assert SENTINEL_ID not in ids, "frozen clean mode must be immutable" - assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_CLEAN + # ---- G. capability disappears after clean freeze -> clean ---------- + reset_state(); set_zero_packs() + auth("sidG", "10.0.0.7"); register("10.0.0.7", 1) + assert SENTINEL_ID not in store("sidG", "10.0.0.7") # freezes clean + us._FIFA17_SESSIONS["sidG"]["resolver"] = None # capability vanishes + assert SENTINEL_ID not in store("sidG", "10.0.0.7") + assert mode_of("sidG") == CLEAN print("G capability disappears after clean freeze -> clean: OK") - # ---- H. two concurrent IPs -> no global leak ----------------------- - reset_state() - set_zero_packs() - ip_a, ip_b = "10.0.1.1", "10.0.1.2" - utas_server.fifa17_reset_session(ip_a) - utas_server.fifa17_reset_session(ip_b) - register(ip_a, 1) # A verified, B never registers - ids_a = store(ip_a) - ids_b = store(ip_b) - assert SENTINEL_ID not in ids_a, ids_a - assert SENTINEL_ID in ids_b, ids_b - assert utas_server._FIFA17_STORE[ip_a]["mode"] == utas_server.FIFA17_MODE_CLEAN - assert utas_server._FIFA17_STORE[ip_b]["mode"] == utas_server.FIFA17_MODE_SENTINEL - print("H concurrent IPs (A clean, B sentinel) -> no global leak: OK") + # ---- H. two IPs (A verified, B none) -> no global leak ------------- + reset_state(); set_zero_packs() + auth("sidH1", "10.0.1.1"); register("10.0.1.1", 1) + auth("sidH2", "10.0.1.2") + assert SENTINEL_ID not in store("sidH1", "10.0.1.1") + assert SENTINEL_ID in store("sidH2", "10.0.1.2") + print("H two IPs (A clean, B sentinel) -> no global leak: OK") - # ---- I. reset clears capability across sessions -------------------- - reset_state() - set_zero_packs() - ip = "10.0.2.1" - utas_server.fifa17_reset_session(ip) - register(ip, 1) - ids = store(ip) # session 1: clean - assert SENTINEL_ID not in ids, ids - utas_server.fifa17_reset_session(ip) # relaunch: fresh unpatched process - assert utas_server._FIFA17_STORE[ip] == {"resolver": None, "mode": None} - ids = store(ip) # session 2: no re-register -> sentinel - assert SENTINEL_ID in ids, "capability must not leak across sessions" - print("I new session clears capability -> sentinel: OK") + # ---- I. new session after reset -> fresh unpatched -> sentinel ----- + reset_state(); set_zero_packs() + auth("sidI1", "10.0.1.3"); register("10.0.1.3", 1) + assert SENTINEL_ID not in store("sidI1", "10.0.1.3") # A clean + us.fifa17_clear_pending("10.0.1.3") # relaunch boundary + auth("sidI2", "10.0.1.3") # new SID, autopatch failed + assert SENTINEL_ID in store("sidI2", "10.0.1.3") + print("I new session after reset -> sentinel (no cross-process leak): OK") # ---- J. autopatch mismatch => never registers -> sentinel ---------- - reset_state() - set_zero_packs() - ip = "10.0.3.1" - utas_server.fifa17_reset_session(ip) # autopatch verify FAILED: no register - ids = store(ip) - assert SENTINEL_ID in ids, ids - assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL + reset_state(); set_zero_packs() + auth("sidJ", "10.0.1.4") + assert SENTINEL_ID in store("sidJ", "10.0.1.4") print("J autopatch mismatch (never registers) -> sentinel: OK") - finally: - utas_server.visible_unopened_packs = _orig_visible - print("capability negotiation matrix A-J: OK") + # ---- K. SAME IP, two sessions: patched A clean, unpatched B sent --- + reset_state(); set_zero_packs() + IP = "10.0.2.1" + auth("sidK_A", IP) + assert register(IP, 1)[0] == 200 # A sole candidate -> bound + auth("sidK_B", IP) # B joins, never registers + assert SENTINEL_ID not in store("sidK_A", IP) + assert SENTINEL_ID in store("sidK_B", IP) + print("K same-IP two sessions -> A clean, B sentinel: OK") + + # ---- L. SAME IP+persona relaunch: old ok, new not -> new sentinel -- + reset_state(); set_zero_packs() + IP = "10.0.2.2" + auth("sidL_old", IP, PERSONA); register(IP, 1, PERSONA) + assert SENTINEL_ID not in store("sidL_old", IP) + us.fifa17_clear_pending(IP) + auth("sidL_new", IP, PERSONA) # same persona, unverified + assert SENTINEL_ID in store("sidL_new", IP) + print("L same-IP+persona relaunch -> new session sentinel: OK") + + # ---- M. SAME IP, failed-patch second session ----------------------- + reset_state(); set_zero_packs() + IP = "10.0.2.3" + auth("sidM1", IP); register(IP, 1) + assert SENTINEL_ID not in store("sidM1", IP) + auth("sidM2", IP) # autopatch failed + assert SENTINEL_ID in store("sidM2", IP) + print("M same-IP failed-patch second session -> sentinel: OK") + + # ---- N. late reg when sessions frozen -> no active session change -- + reset_state(); set_zero_packs() + IP = "10.0.2.4" + auth("sidN1", IP); register(IP, 1) + assert SENTINEL_ID not in store("sidN1", IP) # N1 frozen clean + auth("sidN2", IP) + assert SENTINEL_ID in store("sidN2", IP) # N2 frozen sentinel + assert register(IP, 1)[0] == 200 # late: both frozen -> ignored + assert SENTINEL_ID not in store("sidN1", IP) # unchanged + assert SENTINEL_ID in store("sidN2", IP) # unchanged + print("N late registration does not modify active sessions: OK") + + # ---- O. session cleanup / TTL expiry -> capability gone ------------ + reset_state(); set_zero_packs() + IP = "10.0.2.5" + auth("sidO", IP); register(IP, 1) + assert SENTINEL_ID not in store("sidO", IP) # clean while live + us._FIFA17_SESSIONS["sidO"]["last_seen"] = ( + us._fifa17_now() - us.FIFA17_SESSION_TTL - 10.0) + store("sidUNKNOWN", IP) # any op triggers reap + assert "sidO" not in us._FIFA17_SESSIONS, "expired session not reaped" + assert SENTINEL_ID in store("sidO", IP) # gone -> sentinel + print("O session cleanup / TTL expiry -> sentinel: OK") + + # ---- P. duplicate registration -> idempotent, no post-freeze change + reset_state(); set_zero_packs() + IP = "10.0.2.6" + auth("sidP", IP) + assert register(IP, 1)[0] == 200 # bound + assert register(IP, 1)[0] == 200 # duplicate -> ignored-late + assert SENTINEL_ID not in store("sidP", IP) # still clean + assert register(IP, 1)[0] == 200 # after freeze + assert SENTINEL_ID not in store("sidP", IP) # unchanged + assert mode_of("sidP") == CLEAN + print("P duplicate registration -> idempotent: OK") + + # ---- Q. register-before-login: pending consumed at auth -> clean --- + reset_state(); set_zero_packs() + IP = "10.0.2.7" + assert register(IP, 1)[0] == 200 # no session yet -> pending + assert (IP, PERSONA) in us._FIFA17_PENDING + auth("sidQ", IP, PERSONA) # consumes pending + assert (IP, PERSONA) not in us._FIFA17_PENDING # single-use + assert SENTINEL_ID not in store("sidQ", IP) + assert mode_of("sidQ") == CLEAN + print("Q register-before-login pending consumed -> clean: OK") + + finally: + us.visible_unopened_packs = _orig_visible + + print("capability negotiation matrix A-Q: OK") return 0 diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 61e1dab..919ea19 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -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