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:
@@ -1,23 +1,33 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Tests for the FIFA 17 verified-patched-client capability negotiation.
|
"""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 additive empty-My-Packs switch on top of the P2 65534 sentinel: the sentinel is
|
||||||
the sentinel is suppressed for a session ONLY when the launcher has registered a
|
suppressed for ONE FIFA session only when the launcher has registered a verified
|
||||||
verified resolver capability (v1) for the CURRENT FIFA process, bound to the peer
|
resolver capability (v1) that binds to THAT process's UTAS session (keyed by the
|
||||||
IP, and the decision is frozen at the first /store/purchasegroup. Every failure /
|
per-login-unique X-UT-SID; source IP + persona are auxiliary). Every failure /
|
||||||
unknown / late / cross-process case is fail-closed to the active sentinel.
|
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):
|
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
|
A no-capability, zero packs -> sentinel
|
||||||
B verified v1, zero packs -> clean (no 65534)
|
B verified v1, zero packs -> clean (no 65534)
|
||||||
C real unopened pack + no capability -> genuine pack, no sentinel
|
C real unopened pack + no capability -> genuine pack, no sentinel
|
||||||
D real unopened pack + capability -> genuine pack, no sentinel
|
D real unopened pack + capability -> genuine pack, no sentinel
|
||||||
E unsupported version -> endpoint 400 AND mode sentinel
|
E unsupported version / capability -> endpoint 400 AND mode sentinel
|
||||||
F late capability after sentinel freeze -> stays sentinel
|
F late capability after sentinel freeze -> stays sentinel
|
||||||
G capability disappears after clean freeze -> stays clean (immutable)
|
G capability disappears after clean freeze -> stays clean (immutable)
|
||||||
H two concurrent IPs (A verified, B none) -> A clean, B sentinel (no global leak)
|
H two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
|
||||||
I new session via reset clears capability -> fresh unpatched process -> sentinel
|
I new session after reset -> fresh unpatched -> sentinel
|
||||||
J autopatch mismatch => never registers -> 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`.
|
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
|
SENTINEL_ID = 65534
|
||||||
REAL_PACK_ID = 1
|
REAL_PACK_ID = 1
|
||||||
|
PERSONA = 111001
|
||||||
|
|
||||||
|
|
||||||
class _H:
|
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.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""
|
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(fut_accounts)
|
||||||
importlib.reload(utas_server)
|
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 = us.visible_unopened_packs
|
||||||
_orig_visible = utas_server.visible_unopened_packs
|
|
||||||
|
|
||||||
def set_zero_packs():
|
def set_zero_packs():
|
||||||
utas_server.visible_unopened_packs = lambda: []
|
us.visible_unopened_packs = lambda: []
|
||||||
|
|
||||||
def set_real_pack():
|
def set_real_pack():
|
||||||
utas_server.visible_unopened_packs = lambda: [REAL_PACK_ID]
|
us.visible_unopened_packs = lambda: [REAL_PACK_ID]
|
||||||
|
|
||||||
def reset_state():
|
def reset_state():
|
||||||
"""Fresh capability store between cases (no cross-case leakage)."""
|
us._FIFA17_SESSIONS.clear()
|
||||||
utas_server._FIFA17_STORE.clear()
|
us._FIFA17_PENDING.clear()
|
||||||
|
|
||||||
def register(ip, version, persona=42, pid=4242):
|
def auth(sid, ip, persona=PERSONA):
|
||||||
return utas_server.fifa17_capability_route(_H(ip, {
|
"""Simulate /ut/auth opening a per-login session with a chosen sid."""
|
||||||
"capability": "empty_mypacks_resolver",
|
us.fifa17_open_session(sid, ip, persona)
|
||||||
"version": version,
|
|
||||||
"personaId": persona,
|
def register(ip, version, persona=PERSONA, pid=4242):
|
||||||
"fifaPid": pid,
|
return us.fifa17_capability_route(_H(ip, {
|
||||||
|
"capability": "empty_mypacks_resolver", "version": version,
|
||||||
|
"personaId": persona, "fifaPid": pid,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def store(ip):
|
def store(sid, ip):
|
||||||
status, cat = utas_server.store_catalog(_H(ip))
|
status, cat = us.store_catalog(_H(ip, sid=sid))
|
||||||
assert status == 200, status
|
assert status == 200, status
|
||||||
return _ids(cat)
|
return _ids(cat)
|
||||||
|
|
||||||
|
def mode_of(sid):
|
||||||
|
return us._FIFA17_SESSIONS[sid]["mode"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ---- A. no capability, zero packs -> sentinel ----------------------
|
# ---- A. no capability, zero packs -> sentinel ----------------------
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidA", "10.0.0.1")
|
||||||
ip = "10.0.0.1"
|
assert SENTINEL_ID in store("sidA", "10.0.0.1")
|
||||||
utas_server.fifa17_reset_session(ip)
|
assert mode_of("sidA") == SENT
|
||||||
ids = store(ip)
|
|
||||||
assert SENTINEL_ID in ids, ids
|
|
||||||
assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL
|
|
||||||
print("A no-capability zero-packs -> sentinel: OK")
|
print("A no-capability zero-packs -> sentinel: OK")
|
||||||
|
|
||||||
# ---- B. verified v1, zero packs -> clean ---------------------------
|
# ---- B. verified v1, zero packs -> clean ---------------------------
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidB", "10.0.0.2")
|
||||||
ip = "10.0.0.2"
|
assert register("10.0.0.2", 1)[0] == 200
|
||||||
utas_server.fifa17_reset_session(ip)
|
ids = store("sidB", "10.0.0.2")
|
||||||
st, body = register(ip, 1)
|
|
||||||
assert st == 200 and body == {"status": "OK"}, (st, body)
|
|
||||||
ids = store(ip)
|
|
||||||
assert SENTINEL_ID not in ids, ids
|
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")
|
print("B verified-v1 zero-packs -> clean: OK")
|
||||||
|
|
||||||
# ---- C. real pack + no capability -> genuine, no sentinel ----------
|
# ---- C. real pack + no capability -> genuine, no sentinel ----------
|
||||||
reset_state()
|
reset_state(); set_real_pack()
|
||||||
set_real_pack()
|
auth("sidC", "10.0.0.3")
|
||||||
ip = "10.0.0.3"
|
ids = store("sidC", "10.0.0.3")
|
||||||
utas_server.fifa17_reset_session(ip)
|
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
|
||||||
ids = store(ip)
|
|
||||||
assert SENTINEL_ID not in ids, ids
|
|
||||||
assert REAL_PACK_ID in ids, ids
|
|
||||||
print("C real-pack no-capability -> genuine, no sentinel: OK")
|
print("C real-pack no-capability -> genuine, no sentinel: OK")
|
||||||
|
|
||||||
# ---- D. real pack + capability -> genuine, no sentinel -------------
|
# ---- D. real pack + capability -> genuine, no sentinel -------------
|
||||||
reset_state()
|
reset_state(); set_real_pack()
|
||||||
set_real_pack()
|
auth("sidD", "10.0.0.4"); register("10.0.0.4", 1)
|
||||||
ip = "10.0.0.4"
|
ids = store("sidD", "10.0.0.4")
|
||||||
utas_server.fifa17_reset_session(ip)
|
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
|
||||||
register(ip, 1)
|
|
||||||
ids = store(ip)
|
|
||||||
assert SENTINEL_ID not in ids, ids
|
|
||||||
assert REAL_PACK_ID in ids, ids
|
|
||||||
print("D real-pack capability -> genuine, no sentinel: OK")
|
print("D real-pack capability -> genuine, no sentinel: OK")
|
||||||
|
|
||||||
# ---- E. unsupported version -> 400 AND mode sentinel ---------------
|
# ---- E. unsupported version / capability -> 400 + sentinel ---------
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidE", "10.0.0.5")
|
||||||
ip = "10.0.0.5"
|
assert register("10.0.0.5", 2)[0] == 400
|
||||||
utas_server.fifa17_reset_session(ip)
|
assert register("10.0.0.5", 99)[0] == 400
|
||||||
for bad in (2, 99):
|
assert us.fifa17_capability_route(
|
||||||
st, body = register(ip, bad)
|
_H("10.0.0.5", {"capability": "bogus", "version": 1}))[0] == 400
|
||||||
assert st == 400 and "error" in body, (bad, st, body)
|
assert SENTINEL_ID in store("sidE", "10.0.0.5")
|
||||||
# nothing recorded -> resolver stays None
|
assert mode_of("sidE") == SENT
|
||||||
assert utas_server._FIFA17_STORE[ip]["resolver"] is None
|
print("E unsupported version/capability -> 400 + sentinel: OK")
|
||||||
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")
|
|
||||||
|
|
||||||
# ---- F. late capability after sentinel freeze -> stays sentinel ----
|
# ---- F. late capability after sentinel freeze -> sentinel ----------
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidF", "10.0.0.6")
|
||||||
ip = "10.0.0.6"
|
assert SENTINEL_ID in store("sidF", "10.0.0.6") # freezes sentinel
|
||||||
utas_server.fifa17_reset_session(ip)
|
assert register("10.0.0.6", 1)[0] == 200 # session frozen -> ignored-late
|
||||||
ids = store(ip) # freeze: sentinel
|
assert SENTINEL_ID in store("sidF", "10.0.0.6")
|
||||||
assert SENTINEL_ID in ids, ids
|
assert mode_of("sidF") == SENT
|
||||||
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
|
|
||||||
print("F late capability after sentinel freeze -> sentinel: OK")
|
print("F late capability after sentinel freeze -> sentinel: OK")
|
||||||
|
|
||||||
# ---- G. capability disappears after clean freeze -> stays clean ----
|
# ---- G. capability disappears after clean freeze -> clean ----------
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidG", "10.0.0.7"); register("10.0.0.7", 1)
|
||||||
ip = "10.0.0.7"
|
assert SENTINEL_ID not in store("sidG", "10.0.0.7") # freezes clean
|
||||||
utas_server.fifa17_reset_session(ip)
|
us._FIFA17_SESSIONS["sidG"]["resolver"] = None # capability vanishes
|
||||||
register(ip, 1)
|
assert SENTINEL_ID not in store("sidG", "10.0.0.7")
|
||||||
ids = store(ip) # freeze: clean
|
assert mode_of("sidG") == 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
|
|
||||||
print("G capability disappears after clean freeze -> clean: OK")
|
print("G capability disappears after clean freeze -> clean: OK")
|
||||||
|
|
||||||
# ---- H. two concurrent IPs -> no global leak -----------------------
|
# ---- H. two IPs (A verified, B none) -> no global leak -------------
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidH1", "10.0.1.1"); register("10.0.1.1", 1)
|
||||||
ip_a, ip_b = "10.0.1.1", "10.0.1.2"
|
auth("sidH2", "10.0.1.2")
|
||||||
utas_server.fifa17_reset_session(ip_a)
|
assert SENTINEL_ID not in store("sidH1", "10.0.1.1")
|
||||||
utas_server.fifa17_reset_session(ip_b)
|
assert SENTINEL_ID in store("sidH2", "10.0.1.2")
|
||||||
register(ip_a, 1) # A verified, B never registers
|
print("H two IPs (A clean, B sentinel) -> no global leak: OK")
|
||||||
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")
|
|
||||||
|
|
||||||
# ---- I. reset clears capability across sessions --------------------
|
# ---- I. new session after reset -> fresh unpatched -> sentinel -----
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidI1", "10.0.1.3"); register("10.0.1.3", 1)
|
||||||
ip = "10.0.2.1"
|
assert SENTINEL_ID not in store("sidI1", "10.0.1.3") # A clean
|
||||||
utas_server.fifa17_reset_session(ip)
|
us.fifa17_clear_pending("10.0.1.3") # relaunch boundary
|
||||||
register(ip, 1)
|
auth("sidI2", "10.0.1.3") # new SID, autopatch failed
|
||||||
ids = store(ip) # session 1: clean
|
assert SENTINEL_ID in store("sidI2", "10.0.1.3")
|
||||||
assert SENTINEL_ID not in ids, ids
|
print("I new session after reset -> sentinel (no cross-process leak): OK")
|
||||||
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")
|
|
||||||
|
|
||||||
# ---- J. autopatch mismatch => never registers -> sentinel ----------
|
# ---- J. autopatch mismatch => never registers -> sentinel ----------
|
||||||
reset_state()
|
reset_state(); set_zero_packs()
|
||||||
set_zero_packs()
|
auth("sidJ", "10.0.1.4")
|
||||||
ip = "10.0.3.1"
|
assert SENTINEL_ID in store("sidJ", "10.0.1.4")
|
||||||
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
|
|
||||||
print("J autopatch mismatch (never registers) -> sentinel: OK")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
|||||||
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
* 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.
|
* [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
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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)
|
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
|
# 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
|
# suppressed for ONE FIFA session only when the launcher has registered that THAT
|
||||||
# FIFA process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
|
# 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
|
# BINDING: the authoritative key is the per-login-unique UTAS session id (X-UT-SID),
|
||||||
# / late / absent / wrong-version capability resolves to the active sentinel.
|
# minted fresh at every /ut/auth and echoed by the client on every later call incl.
|
||||||
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§7/§9/§11).
|
# /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_EMPTY_MYPACKS_RESOLVER_VERSION = 1
|
||||||
FIFA17_MODE_SENTINEL = "sentinel"
|
FIFA17_MODE_SENTINEL = "sentinel"
|
||||||
FIFA17_MODE_CLEAN = "clean-v1"
|
FIFA17_MODE_CLEAN = "clean-v1"
|
||||||
_FIFA17_STORE = {} # ip -> {"resolver": Optional[int], "mode": Optional[str]}
|
FIFA17_SESSION_TTL = 3600.0 # reap a FIFA session after this many idle seconds
|
||||||
_FIFA17_STORE_LOCK = threading.Lock()
|
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):
|
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:
|
try:
|
||||||
return h.client_address[0]
|
return h.client_address[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fifa17_reset_session(ip):
|
def _fifa17_sid(h):
|
||||||
"""Session boundary (/openfut/account/sync): clear capability + unfreeze mode."""
|
"""The client's UTAS session id (X-UT-SID) for this request, or None."""
|
||||||
with _FIFA17_STORE_LOCK:
|
try:
|
||||||
_FIFA17_STORE[ip] = {"resolver": None, "mode": None}
|
return h.headers.get("X-UT-SID")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fifa17_register_capability(ip, version):
|
def _fifa17_sidlog(sid):
|
||||||
"""Register a verified resolver capability for ip. Returns the current mode.
|
"""A short, non-secret tag for correlating a session in logs."""
|
||||||
|
return ("\u2026" + sid[-6:]) if sid else "-"
|
||||||
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_empty_mypacks_mode(ip):
|
def _fifa17_mint_sid():
|
||||||
"""Resolve (and freeze on first call) the empty-My-Packs mode for ip.
|
"""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."""
|
def _fifa17_reap_locked(now):
|
||||||
with _FIFA17_STORE_LOCK:
|
for sid in [s for s, r in _FIFA17_SESSIONS.items()
|
||||||
rec = _FIFA17_STORE.setdefault(ip, {"resolver": None, "mode": None})
|
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["mode"] is None:
|
||||||
|
if rec["resolver"] is None:
|
||||||
|
rec["resolver"] = _fifa17_take_pending_locked(rec["ip"], rec["persona"], now)
|
||||||
rec["mode"] = (FIFA17_MODE_CLEAN
|
rec["mode"] = (FIFA17_MODE_CLEAN
|
||||||
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
|
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
|
||||||
else FIFA17_MODE_SENTINEL)
|
else FIFA17_MODE_SENTINEL)
|
||||||
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
|
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
|
||||||
% (ip, rec["mode"]))
|
% (_fifa17_sidlog(sid), rec["mode"]))
|
||||||
return 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
|
well-formed value without retaining or comparing it. Account selection has
|
||||||
already initialized the server-owned verified compatibility state.
|
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")
|
log("[FUT] security-question request has no matching OpenFUT session")
|
||||||
return 400, {"reason": "invalid_session"}
|
return 400, {"reason": "invalid_session"}
|
||||||
|
|
||||||
@@ -252,17 +360,19 @@ def auth_body(h=None):
|
|||||||
except Exception as e: # adoption must never break auth
|
except Exception as e: # adoption must never break auth
|
||||||
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
|
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
|
||||||
% (type(e).__name__, e, before[0], before[1]))
|
% (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):
|
def account_sync_route(h):
|
||||||
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
|
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
|
||||||
# Session boundary: each launcher account-sync starts a fresh per-IP FIFA17
|
# Pre-launch hygiene: drop any stale launcher capability still pending for this
|
||||||
# capability session (unfreeze mode + clear any prior capability). A new FIFA
|
# machine so a new launch's unverified FIFA session cannot inherit it. The real
|
||||||
# process must re-verify; nothing leaks across processes.
|
# per-process session is opened later, at /ut/auth (keyed by the minted X-UT-SID).
|
||||||
ip = _fifa17_client_ip(h)
|
ip = _fifa17_client_ip(h)
|
||||||
fifa17_reset_session(ip)
|
fifa17_clear_pending(ip)
|
||||||
log(" ACCOUNT: reset FIFA17 empty-mypacks capability session for ip %s" % ip)
|
log(" ACCOUNT: cleared stale FIFA17 pending capability for ip %s" % ip)
|
||||||
try:
|
try:
|
||||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||||
account = activate_account(body)
|
account = activate_account(body)
|
||||||
@@ -293,11 +403,11 @@ def fifa17_capability_route(h):
|
|||||||
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
|
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
|
||||||
return 400, {"error": "unsupported capability"}
|
return 400, {"error": "unsupported capability"}
|
||||||
ip = _fifa17_client_ip(h)
|
ip = _fifa17_client_ip(h)
|
||||||
fifa17_register_capability(ip, version)
|
persona = body.get("personaId")
|
||||||
persona = body.get("personaId", "?")
|
|
||||||
fifa_pid = body.get("fifaPid", "?")
|
fifa_pid = body.get("fifaPid", "?")
|
||||||
log("[fifa17-store] registered capability empty_mypacks_resolver=%s for %s "
|
status = fifa17_register_capability(ip, persona, version)
|
||||||
"(persona %s, fifa_pid %s)" % (version, ip, persona, fifa_pid))
|
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"}
|
return 200, {"status": "OK"}
|
||||||
|
|
||||||
|
|
||||||
@@ -3524,9 +3634,9 @@ def store_catalog(h):
|
|||||||
if not owned_ids:
|
if not owned_ids:
|
||||||
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
|
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
|
||||||
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
|
# §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
|
# this FIFA session (keyed by its X-UT-SID) is committed here at the first
|
||||||
# immutable for the session thereafter.
|
# /store/purchasegroup and is immutable for the session thereafter.
|
||||||
mode = fifa17_empty_mypacks_mode(_fifa17_client_ip(h))
|
mode = fifa17_empty_mypacks_mode(_fifa17_sid(h), _fifa17_client_ip(h))
|
||||||
if mode == FIFA17_MODE_CLEAN:
|
if mode == FIFA17_MODE_CLEAN:
|
||||||
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
|
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
|
||||||
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
|
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
|
||||||
|
|||||||
Reference in New Issue
Block a user