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
+189 -141
View File
@@ -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
+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