5d5198f5d1
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.
WORKING END TO END (live-verified this session):
* match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
(0x180121b60). Play a match, get coins, W/D/L updates.
* packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
* quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
were destroyed for 0 coins. Now credits discardValue.
* POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
ROSTERUPDATE_URL. FUT_POW=1.
* account backend -- fut_account.py replaces 7 hardcoded copies of the persona
across 5 files; club/persona/online-profile editable via CLI.
CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
* FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
take externalPriceId(0x11a), not amount/currency.
* FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
unique among FUT deserializers) and parses only itemData -> dreamSquads.
* class -> deserializer resolution: the name literal is preceded by a 4-BYTE
HEADER and the factory LEA points at the header, so look up name_addr - 4.
Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
Draft schemas.
* live-only endpoints the request table never lists: ut/%s/squad/list,
ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
table is a floor, not a ceiling -- the log is the only ground truth.
* 163 RS4 call names exist; we served 17. All now served.
FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).
UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).
Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).
Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
1391 lines
59 KiB
Python
1391 lines
59 KiB
Python
#!/usr/bin/env python3
|
|
# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT
|
|
# sourced from fut_account.py here. The live pair is lsx_responder_v2.py +
|
|
# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only.
|
|
"""FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication.
|
|
|
|
WHAT IS NEW vs v2
|
|
-----------------
|
|
* Util::fetchClientConfig (9/1) answered for real, per-CFID, with a proper
|
|
FetchConfigResponse{CONF: map<string,string>}. Unknown CFID -> EMPTY MAP
|
|
(a present-but-empty CONF), never an empty frame.
|
|
* Authentication (component 0x0001):
|
|
login (1/0x0A) -> forged offline LoginResponse (persona 33068179/CAGE)
|
|
logout (1/0x46) -> empty REPLY, and logged as a FAILURE SIGNAL
|
|
getAuthToken (1/0x24), listUserEntitlements2 (1/0x1D)
|
|
* UserSessions (0x7802) NOTIFICATIONs pushed unsolicited:
|
|
0x0008 UserAuthenticated (Blaze::UserSessionLoginInfo)
|
|
0x0001 UserSessionExtendedDataUpdate
|
|
0x0002 UserAdded (Blaze::UserData)
|
|
* Util::postAuth (9/8), setClientState (9/0x1C), userSettingsLoad (9/0x0A),
|
|
setClientMetrics (9/0x16), AssociationLists::getLists (25/6),
|
|
UserSessions::updateNetworkInfo (0x7802/0x14).
|
|
* Optional local Nucleus OAuth stub on 42131 serving POST /connect/token, with
|
|
nucleusConnect / nucleusConnectTrusted in the BlazeSDK config pointed at it.
|
|
* PingResponse now carries ONLY STIM (FIFA 17's PingResponse @0x144875560 has
|
|
exactly one member; v2's extra TIME was Mirror's-Edge-Catalyst's field).
|
|
|
|
TWO-LAYER ORDERING -- READ THIS FIRST
|
|
-------------------------------------
|
|
This file is layer 2. Layer 1 is Origin/LSX on 127.0.0.1:4216. The client's
|
|
`origin.nav` gates FUT on `OriginIsOnlineTrue` and only then runs
|
|
`futBlazeLogin`; without a layer-1 auth code the client sends
|
|
`Authentication::logout` (1/0x46) instead of `login` and shows
|
|
"Unable to connect to the EA Servers ... log in to Origin in Online Mode".
|
|
Start `lsx_responder.py` (before FIFA) or apply `lsx_force_online.py` FIRST.
|
|
Receiving 1/0x46 here means layer 1 is still broken.
|
|
|
|
CLEAN ROOM. Every TDF tag/type below comes from FIFA17.exe's own in-process TDF
|
|
reflection metadata that we walked in live memory, plus our own captured wire
|
|
bytes. Independent third-party clean-room BlazeSDK-15.x reimplementations were
|
|
consulted only to cross-check *structure*. No EA/FIFA leaked source was used.
|
|
|
|
Run: python3 blaze_responder_v3.py (binds 42127 + 42130 [+ 42131])
|
|
Selftest: python3 blaze_responder_v3.py --selftest
|
|
Log: /tmp/blaze_responder.log
|
|
Frames: /tmp/blaze_rx/
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import binascii
|
|
import json
|
|
import os
|
|
import random
|
|
import socket
|
|
import ssl
|
|
import string
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import heat2 # noqa: E402
|
|
from heat2 import ( # noqa: E402
|
|
INT, STRING, BLOB, STRUCT, LIST, MAP, encode_tdf, decode_tdf,
|
|
)
|
|
|
|
# ================================================================== identity
|
|
# SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py.
|
|
# Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language).
|
|
# A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26),
|
|
# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND.
|
|
|
|
PERSONA_ID = 33068179
|
|
PERSONA_NAME = "CAGE"
|
|
USER_ID = 33068179 # blazeId / userId; same value keeps BUID==UID==PID
|
|
EXT_ID = 33068179 # XREF externalId
|
|
EMAIL = "cage@openfut.local"
|
|
PERSONA_NAMESPACE = "cem_ea_id" # must equal PreAuthResponse.NASP
|
|
CLIENT_PLATFORM = 4 # Blaze::ClientPlatformType -> pc
|
|
PERSONA_STATUS = 2 # PersonaStatus::Code -> ACTIVE (verified live: table 0x14487ad20, ACTIVE==2)
|
|
USER_SESSION_TYPE = 0 # Blaze::UserSessionType -> normal/console user
|
|
ACCOUNT_LOCALE_FALLBACK = 0x656E5553 # 'enUS'; overwritten by the client's own
|
|
# PreAuthRequest LANG/LOC when we see it.
|
|
|
|
CONTENT_ID = "1027460" # FIFA 17 EA offer id (retail)
|
|
ENTITLEMENT_TAG = "ONLINE_ACCESS" # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe
|
|
ENTITLEMENT_GROUP = "FIFA17PC"
|
|
|
|
TITLE_ID = "309111"
|
|
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
|
|
PLATFORM = "pc"
|
|
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
|
|
|
# ================================================================== config
|
|
|
|
HOST = "127.0.0.1"
|
|
REDIR_PORT = 42127
|
|
BLAZE_PORT = 42130
|
|
NUCLEUS_PORT = 42131
|
|
BLAZE_IP_STR = "127.0.0.1"
|
|
BLAZE_IP_U32 = (127 << 24) | 1
|
|
LOG = "/tmp/blaze_responder.log"
|
|
RXDIR = "/tmp/blaze_rx"
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
CERT = os.path.join(HERE, "redir_cert.pem")
|
|
KEY = os.path.join(HERE, "redir_key.pem")
|
|
|
|
# Serve a local OAuth stub and advertise it as nucleusConnect. The client's
|
|
# LoginStateMachineImpl builds "<nucleusConnect>/connect/token", POSTs
|
|
# grant_type=client_credentials, and scrapes '"access_token" : "'.
|
|
NUCLEUS_STUB_ENABLED = True
|
|
EMIT_NUCLEUS_URLS = True
|
|
NUCLEUS_BASE = "http://%s:%d" % (HOST, NUCLEUS_PORT)
|
|
|
|
# Any RPC we do not implement still gets an empty REPLY so the client's request
|
|
# never times out. Flip to False to find out what it truly blocks on.
|
|
REPLY_EMPTY_TO_UNKNOWN = True
|
|
|
|
# Push the UserAuthenticated notification BEFORE writing the login reply
|
|
# (grid-blaze order) or after (pamplona order). Both are reported to work.
|
|
NOTIFY_BEFORE_LOGIN_REPLY = False
|
|
|
|
DUMP_FRAMES = True
|
|
|
|
_log_lock = threading.Lock()
|
|
|
|
|
|
def log(m: str) -> None:
|
|
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
|
|
with _log_lock:
|
|
print(line, flush=True)
|
|
try:
|
|
with open(LOG, "a") as fh:
|
|
fh.write(line + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def hexdump(b: bytes, limit: int = 512) -> str:
|
|
out = []
|
|
for i in range(0, min(len(b), limit), 16):
|
|
chunk = b[i:i + 16]
|
|
txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
|
|
out.append(" %04x: %-47s %s"
|
|
% (i, binascii.hexlify(chunk, " ").decode(), txt))
|
|
if len(b) > limit:
|
|
out.append(" ... (%d more bytes)" % (len(b) - limit))
|
|
return "\n".join(out)
|
|
|
|
|
|
# ================================================================== Fire2
|
|
#
|
|
# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame /
|
|
# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them):
|
|
#
|
|
# [0:4] u32 payload length
|
|
# [4:6] u16 metadata length
|
|
# [6:8] u16 component
|
|
# [8:10] u16 command (== notification id on NOTIFICATION)
|
|
# [10:13] u24 msgNum
|
|
# [13] u8 (msgType << 5) | (userIndex & 0x1F)
|
|
# [14] u8 options
|
|
# [15] u8 reserved
|
|
# wire = header(16) || metadata || payload
|
|
#
|
|
# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame).
|
|
|
|
FIRE2_HDR = 16
|
|
|
|
MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6)
|
|
MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION",
|
|
3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"}
|
|
|
|
COMP_AUTH = 0x0001
|
|
COMP_GAMEMANAGER = 0x0004
|
|
COMP_REDIRECTOR = 0x0005
|
|
COMP_STATS = 0x0007
|
|
COMP_UTIL = 0x0009
|
|
COMP_MESSAGING = 0x000F
|
|
COMP_ASSOCLISTS = 0x0019
|
|
COMP_GAMEREPORTING = 0x001C
|
|
COMP_USERSESSIONS = 0x7802
|
|
|
|
# ---- Util (0x0009) command table, recovered from the binary's own
|
|
# getCommandName switch (jump table 0x141b17af4).
|
|
CMD_FETCHCLIENTCONFIG = 0x0001
|
|
CMD_PING = 0x0002
|
|
CMD_PREAUTH = 0x0007
|
|
CMD_POSTAUTH = 0x0008
|
|
CMD_USERSETTINGSLOAD = 0x000A
|
|
CMD_USERSETTINGSSAVE = 0x000B
|
|
CMD_SETCLIENTMETRICS = 0x0016
|
|
CMD_SETCLIENTSTATE = 0x001C
|
|
|
|
UTIL_CMDS = {
|
|
0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData",
|
|
0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer",
|
|
0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad",
|
|
0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll",
|
|
0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser",
|
|
0x14: "filterForProfanity", 0x15: "fetchQosConfig",
|
|
0x16: "setClientMetrics", 0x17: "setConnectionState",
|
|
0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing",
|
|
0x1C: "setClientState",
|
|
}
|
|
|
|
# ---- Authentication (0x0001) command table. Recovered by CALLING the client's
|
|
# own getCommandName (0x146e0d2a0) in-process over ids 1..320 -- the name
|
|
# pool is Denuvo-mutated and statically unrecoverable. Validated against
|
|
# Util (reproduced preAuth=7/ping=2/fetchClientConfig=1) and cross-checked
|
|
# against a static REST-binding struct (0x143896a80 -> trustedLogin=0x0B).
|
|
CMD_LOGIN = 0x000A
|
|
CMD_TRUSTEDLOGIN = 0x000B
|
|
CMD_LISTUSERENTITLEMENTS2 = 0x001D
|
|
CMD_GETAUTHTOKEN = 0x0024
|
|
CMD_EXPRESSLOGIN = 0x003C
|
|
CMD_LOGOUT = 0x0046 # <-- the "we gave up" RPC, NOT a login
|
|
CMD_GETPERSONA = 0x005A
|
|
CMD_LISTPERSONAS = 0x0064
|
|
|
|
AUTH_CMDS = {
|
|
0x0A: "login", 0x0B: "trustedLogin", 0x14: "updateAccount",
|
|
0x15: "upgradeAccount", 0x1D: "listUserEntitlements2", 0x1E: "getAccount",
|
|
0x1F: "grantEntitlement", 0x20: "listEntitlements", 0x22: "getUseCount",
|
|
0x23: "decrementUseCount", 0x24: "getAuthToken", 0x26: "getPasswordRules",
|
|
0x27: "grantEntitlement2", 0x2B: "modifyEntitlement2", 0x2C: "consumecode",
|
|
0x2D: "passwordForgot", 0x2F: "getPrivacyPolicyContent",
|
|
0x30: "listPersonaEntitlements2", 0x33: "checkAgeReq", 0x34: "getOptIn",
|
|
0x35: "enableOptIn", 0x36: "disableOptIn", 0x3C: "expressLogin",
|
|
0x46: "logout", 0x5A: "getPersona", 0x64: "listPersonas",
|
|
0x65: "expressCreateAccount", 0xE6: "createWalUserSession",
|
|
0xF1: "acceptLegalDocs", 0xF2: "getEmailOptInSettings",
|
|
0xF6: "getTermsOfServiceContent", 0x104: "getOriginPersona",
|
|
0x10E: "checkEmail", 0x118: "getPersonaNameSuggestions", 0x122: "guestLogin",
|
|
}
|
|
|
|
# ---- UserSessions (0x7802). Commands and NOTIFICATIONS live in separate
|
|
# number spaces. Notification ids decoded statically from the client's
|
|
# own getNotificationName jump table at 0x141b03f70 (clean, unmutated).
|
|
NOTIFY_USER_EXTENDED_DATA_UPDATE = 0x0001
|
|
NOTIFY_USER_ADDED = 0x0002
|
|
NOTIFY_USER_REMOVED = 0x0003
|
|
NOTIFY_USER_UPDATED = 0x0005
|
|
NOTIFY_USER_AUTHENTICATED = 0x0008
|
|
NOTIFY_USER_UNAUTHENTICATED = 0x0009
|
|
NOTIFY_SERVER_DRAINING = 0x000C
|
|
|
|
USERSESSIONS_NOTIFY_NAMES = {
|
|
0x01: "UserSessionExtendedDataUpdate", 0x02: "UserAdded",
|
|
0x03: "UserRemoved", 0x05: "UserUpdated", 0x08: "UserAuthenticated",
|
|
0x09: "UserUnauthenticated", 0x0C: "ServerDraining",
|
|
}
|
|
|
|
CMD_UPDATENETWORKINFO = 0x0014 # UserSessions command space
|
|
CMD_GETLISTS = 0x0006 # AssociationLists
|
|
|
|
COMP_NAMES = {
|
|
COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager",
|
|
COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util",
|
|
COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists",
|
|
COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions",
|
|
}
|
|
|
|
|
|
def rpc_name(component: int, command: int, msg_type: int = MESSAGE) -> str:
|
|
comp = COMP_NAMES.get(component, "Component:0x%04x" % component)
|
|
if component == COMP_USERSESSIONS and msg_type == NOTIFICATION:
|
|
cmd = USERSESSIONS_NOTIFY_NAMES.get(command, "notify:0x%04x" % command)
|
|
return "%s::<%s>" % (comp, cmd)
|
|
if component == COMP_UTIL:
|
|
cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command)
|
|
elif component == COMP_AUTH:
|
|
cmd = AUTH_CMDS.get(command, "cmd:0x%04x" % command)
|
|
else:
|
|
cmd = "cmd:0x%04x" % command
|
|
return "%s::%s" % (comp, cmd)
|
|
|
|
|
|
def fire2(component: int, command: int, msg_num: int, msg_type: int,
|
|
payload: bytes = b"", metadata: bytes = b"",
|
|
user_index: int = 0, options: int = 0) -> bytes:
|
|
h = bytearray(16)
|
|
struct.pack_into(">I", h, 0, len(payload))
|
|
struct.pack_into(">H", h, 4, len(metadata))
|
|
struct.pack_into(">H", h, 6, component & 0xFFFF)
|
|
struct.pack_into(">H", h, 8, command & 0xFFFF)
|
|
h[10] = (msg_num >> 16) & 0xFF
|
|
h[11] = (msg_num >> 8) & 0xFF
|
|
h[12] = msg_num & 0xFF
|
|
h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F)
|
|
h[14] = options & 0xFF
|
|
h[15] = 0
|
|
return bytes(h) + metadata + payload
|
|
|
|
|
|
def parse_fire2_header(buf: bytes) -> dict:
|
|
return dict(
|
|
payload_len=struct.unpack_from(">I", buf, 0)[0],
|
|
metadata_len=struct.unpack_from(">H", buf, 4)[0],
|
|
component=struct.unpack_from(">H", buf, 6)[0],
|
|
command=struct.unpack_from(">H", buf, 8)[0],
|
|
msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12],
|
|
msg_type=(buf[13] >> 5) & 0x07,
|
|
user_index=buf[13] & 0x1F,
|
|
options=buf[14],
|
|
reserved=buf[15],
|
|
)
|
|
|
|
|
|
def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes:
|
|
"""A Blaze reply echoes component/command/msgNum/userIndex verbatim and only
|
|
overwrites the msgType bits (byte[13] = 0x20 for REPLY + userIndex 0)."""
|
|
return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type,
|
|
payload, user_index=hdr["user_index"])
|
|
|
|
|
|
def notification(component: int, notify_id: int, payload: bytes = b"",
|
|
user_index: int = 0) -> bytes:
|
|
"""Unsolicited server push: msgType = NOTIFICATION (2) -> byte[13] = 0x40,
|
|
msgNum = 0 (notifications are not correlated to a request)."""
|
|
return fire2(component, notify_id, 0, NOTIFICATION, payload,
|
|
user_index=user_index)
|
|
|
|
|
|
# ================================================================== session
|
|
|
|
class Session(object):
|
|
"""Per-connection forged session state."""
|
|
|
|
def __init__(self):
|
|
self.session_key = make_session_key()
|
|
self.auth_code = "" # whatever LoginRequest.AUTH carried
|
|
self.account_locale = ACCOUNT_LOCALE_FALLBACK
|
|
self.service_name = "fifa-2017-pc"
|
|
self.logged_in = False
|
|
self.login_time = 0
|
|
|
|
|
|
def make_session_key() -> str:
|
|
"""Real Blaze session keys look like <16 hex>_<44 base64-ish chars>.
|
|
The client never validates it -- grid-blaze literally ships "0" -- but the
|
|
SAME string must appear in LoginResponse.SESS.KEY and in the
|
|
UserAuthenticated notification's KEY, so we mint it once per session."""
|
|
alpha = string.ascii_letters + string.digits + "$*"
|
|
return ("%016x_" % random.getrandbits(64)) + \
|
|
"".join(random.choice(alpha) for _ in range(44))
|
|
|
|
|
|
# ============================================== Util::fetchClientConfig (9/1)
|
|
#
|
|
# Response type: Blaze::Util::FetchConfigResponse @0x1448752e0 -- a SINGLE
|
|
# member `CONF` : map<string,string>. NOT double-nested: the extra nesting only
|
|
# exists inside PreAuthResponse, where CONF is itself a FetchConfigResponse
|
|
# whose own single member is also called CONF. Easy to get wrong.
|
|
|
|
# Keys verified present as string literals in FIFA17.exe (owner in comment).
|
|
# Anything not in the binary is silently ignored, so the map is kept minimal.
|
|
# All time values are MICROSECONDS -- the client divides by 1000 to get ms.
|
|
def blazesdk_config() -> list:
|
|
cfg = [
|
|
("associationListSkipInitialSet", "1"), # 0x143b6eb88 AssocListAPI
|
|
("autoReconnectEnabled", "1"), # 0x1438a0a68 ConnMgr
|
|
("connIdleTimeout", "90000000"), # 0x1438a0a58 ConnMgr
|
|
("defaultRequestTimeout", "30000000"), # 0x1438a0a40 ConnMgr
|
|
("enableQosBandwidthTest", "false"), # 0x1438a0a08 clears bit1
|
|
("enableQosFirewallTest", "false"), # 0x1438a09f0 clears bit0
|
|
("maxReconnectAttempts", "5"), # 0x1438a0a80 ConnMgr
|
|
("pingPeriod", "20000000"), # 0x1438a0a30 ConnMgr
|
|
("userManagerMaxCachedUsers", "128"), # UserManager
|
|
("voipHeadsetUpdateRate", "0"), # VoIP
|
|
]
|
|
if EMIT_NUCLEUS_URLS:
|
|
# LoginStateMachineImpl (0x14389fd50-0x14389fef8) builds
|
|
# "<nucleusConnect>/connect/token", POSTs grant_type=client_credentials,
|
|
# and scrapes '"access_token" : "' out of the reply. Point it at our own
|
|
# stub (see nucleus_handle) so it can never reach a real EA host.
|
|
cfg += [
|
|
("nucleusConnect", NUCLEUS_BASE), # 0x14389fef8
|
|
("nucleusConnectTrusted", NUCLEUS_BASE), # 0x14389fdf8
|
|
]
|
|
return sorted(cfg)
|
|
|
|
|
|
# The OSDK_* sections are NOT Blaze plumbing. They are FIFA's own OSDK
|
|
# (8.01.03.00-fifa.01) ResourceLoader tuning maps; every key falls back to a
|
|
# built-in default, which is why our empty replies did not by themselves kill
|
|
# the login. Answering them non-empty is cheap insurance and removes a
|
|
# variable. Key names are literals observed in FIFA17.exe.
|
|
OSDK_CORE = [
|
|
("OSDK_PRESENCE_DELAY", "5"),
|
|
("OSDK_PRESENCE_POLL", "60"),
|
|
("OSDK_ANTIGRIEFING_MAX_COUNT", "0"),
|
|
("OSDK_ARENA_ENABLED", "0"),
|
|
]
|
|
OSDK_CLIENT = [
|
|
("OSDK_CLUBS_MAX_SEARCH_RESULT", "50"),
|
|
("OSDK_CLUBS_LOAD_MEMBER_PAGE_SIZE", "25"),
|
|
("OSDK_CLUBS_MAX_USERS_FOR_GAME", "22"),
|
|
("OSDK_CLUBS_LEADERBOARD_CLUB_MAX", "100"),
|
|
("OSDK_CLUBS_INCOME_SEARCH_MAX", "100"),
|
|
]
|
|
# OSDK_NUCLEUS is Nucleus *tuning* only -- no URL keys were found in it. The
|
|
# real Nucleus endpoints are BlazeSDK-level (nucleusConnect, above).
|
|
OSDK_NUCLEUS = [
|
|
("OSDK_NUCLEUS_ENABLED", "1"),
|
|
("OSDK_NUCLEUS_POLL", "60"),
|
|
("OSDK_NUCLEUS_RETRY_COUNT", "3"),
|
|
("OSDK_NUCLEUS_TIMEOUT", "30"),
|
|
]
|
|
# Keep the online storefront and abuse-report web views switched OFF: with no
|
|
# EA web backend reachable, an enabled one is a hang waiting to happen.
|
|
OSDK_WEBOFFER = [
|
|
("OSDK_WEBOFFER_ENABLED", "0"),
|
|
("OSDK_WEBOFFER_URL", ""),
|
|
]
|
|
OSDK_ABUSE_REPORTING = [
|
|
("OSDK_ABUSE_REPORTING_ENABLED", "0"),
|
|
("OSDK_ABUSE_NUM_TYPES", "0"),
|
|
]
|
|
OSDK_TICKER = [
|
|
("OSDK_TICKER_ENABLED", "0"),
|
|
]
|
|
# Not requested by FIFA 17 in our capture, but both independent clean-room
|
|
# emulators answer it identically; harmless to have ready.
|
|
IDENTITY_PARAMS = [
|
|
("display", "console2/welcome"),
|
|
("redirect_uri", "http://127.0.0.1/success"),
|
|
]
|
|
|
|
CLIENT_CONFIGS = {
|
|
"BlazeSDK": None, # built dynamically, see below
|
|
"OSDK_CORE": OSDK_CORE,
|
|
"OSDK_CLIENT": OSDK_CLIENT,
|
|
"OSDK_NUCLEUS": OSDK_NUCLEUS,
|
|
"OSDK_WEBOFFER": OSDK_WEBOFFER,
|
|
"OSDK_ABUSE_REPORTING": OSDK_ABUSE_REPORTING,
|
|
"OSDK_XMS_ABUSE_REPORTING": OSDK_ABUSE_REPORTING,
|
|
"OSDK_TICKER": OSDK_TICKER,
|
|
"IdentityParams": IDENTITY_PARAMS,
|
|
}
|
|
|
|
|
|
def client_config_for(cfid: str) -> list:
|
|
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
|
|
still wrap in a present CONF field -- never an empty frame)."""
|
|
if cfid == "BlazeSDK":
|
|
return blazesdk_config()
|
|
return sorted(CLIENT_CONFIGS.get(cfid) or [])
|
|
|
|
|
|
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
|
"""Blaze::Util::FetchConfigResponse -- single member CONF : map<str,str>."""
|
|
return OrderedDict([
|
|
("CONF", (MAP, (STRING, STRING, client_config_for(cfid)))),
|
|
])
|
|
|
|
|
|
# ================================================== Util::preAuth (9/7) reply
|
|
|
|
COMPONENT_IDS = [
|
|
COMP_AUTH, COMP_GAMEMANAGER, COMP_REDIRECTOR, COMP_STATS, COMP_UTIL,
|
|
COMP_MESSAGING, COMP_ASSOCLISTS, COMP_GAMEREPORTING, COMP_USERSESSIONS,
|
|
]
|
|
|
|
|
|
def qos_config() -> "OrderedDict":
|
|
"""Blaze::QosConfigInfo -- 4 members per reflection (FIFA 17's descriptor
|
|
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
|
return OrderedDict([
|
|
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
|
("PSA", (STRING, "127.0.0.1")),
|
|
("PSP", (INT, 17502)),
|
|
]))),
|
|
("LNP", (INT, 10)),
|
|
("LTPS", (MAP, (STRING, STRUCT, []))),
|
|
("TIME", (INT, 5000000)),
|
|
])
|
|
|
|
|
|
def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict":
|
|
return OrderedDict([
|
|
("ASRC", (STRING, TITLE_ID)), # authenticationSource
|
|
("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds
|
|
("CLID", (STRING, CLIENT_ID)), # clientId
|
|
("CONF", (STRUCT, fetch_config_response_fields("BlazeSDK"))),
|
|
("ESRC", (STRING, TITLE_ID)), # entitlementSource
|
|
("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN
|
|
("MAID", (INT, 0)), # machineId
|
|
("MINR", (INT, 0)), # underageSupported = false
|
|
("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace
|
|
("PILD", (STRING, "")), # legalDocGameIdentifier
|
|
("PLAT", (STRING, PLATFORM)), # platform
|
|
("QOSS", (STRUCT, qos_config())), # qosSettings
|
|
("RSRC", (STRING, TITLE_ID)), # registrationSource
|
|
("SVER", (STRING, SERVER_VERSION)), # serverVersion
|
|
])
|
|
|
|
|
|
def ping_response_fields() -> "OrderedDict":
|
|
"""Blaze::Util::PingResponse @0x144875560 has EXACTLY ONE member: STIM
|
|
(serverTime, uint32). v2 also sent TIME -- that is MEC's field, not
|
|
FIFA 17's. Dropped."""
|
|
return OrderedDict([("STIM", (INT, int(time.time())))])
|
|
|
|
|
|
# ==================================== Authentication::login (1/0x0A) -- FORGED
|
|
#
|
|
# Blaze::Authentication::LoginResponse @0x14487d170 -- EXACTLY 5 members.
|
|
# NOTE the divergence from both MEC emulators: they emit CNTX, ERRC and a
|
|
# top-level SKEY. FIFA 17's LoginResponse has NONE of those -- CNTX/ERRC are
|
|
# the Blaze *error metadata* block, and the session key lives at SESS.KEY.
|
|
|
|
def persona_details_fields(now: int) -> "OrderedDict":
|
|
"""Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members."""
|
|
return OrderedDict([
|
|
("DSNM", (STRING, PERSONA_NAME)), # displayName MUST be "CAGE"
|
|
("LAST", (INT, now)), # lastAuthenticated uint32
|
|
("PID", (INT, PERSONA_ID)), # personaId int64 MUST be 33068179
|
|
("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform enum -> pc
|
|
("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE
|
|
("XREF", (INT, EXT_ID)), # extId uint64
|
|
])
|
|
|
|
|
|
def user_login_info_fields(sess: Session, now: int) -> "OrderedDict":
|
|
"""Blaze::Authentication::UserLoginInfo @0x14487cb00 -- 8 members.
|
|
('1CON' packs to 0x11 which sorts BELOW 'A'=0x21, so it is first.)"""
|
|
return OrderedDict([
|
|
("1CON", (INT, 0)), # isFirstConsoleLogin = false
|
|
("BUID", (INT, USER_ID)), # blazeUserId -- MUST be != 0
|
|
("FRST", (INT, 0)), # isFirstLogin = false
|
|
("KEY", (STRING, sess.session_key)), # sessionKey -- MUST be non-empty
|
|
("LLOG", (INT, now)), # lastLoginDateTime
|
|
("MAIL", (STRING, EMAIL)), # email
|
|
("PDTL", (STRUCT, persona_details_fields(now))),
|
|
("UID", (INT, USER_ID)), # userId -- MUST be != 0
|
|
])
|
|
|
|
|
|
def login_response_fields(sess: Session) -> "OrderedDict":
|
|
"""Blaze::Authentication::LoginResponse @0x14487d170 -- 5 members only."""
|
|
now = int(time.time())
|
|
return OrderedDict([
|
|
("ANON", (INT, 0)), # isAnonymous -- 1 would give a guest session
|
|
("NTOS", (INT, 0)), # needsLegalDoc -- 1 diverts to the legal-doc flow
|
|
("SESS", (STRUCT, user_login_info_fields(sess, now))),
|
|
("SPAM", (INT, 1)), # isOfLegalContactAge
|
|
("UNDR", (INT, 0)), # isUnderage -- 1 strips online features
|
|
])
|
|
|
|
|
|
# ============================ UserSessions notifications (component 0x7802)
|
|
#
|
|
# Authentication (0x0001) publishes NO notifications at all -- its +0x28 slot is
|
|
# getRestResourceInfo, not getNotificationName. The login-success notification
|
|
# lives on UserSessions, whose getNotificationName (0x146de19a0) is clean,
|
|
# unmutated code with a 12-entry jump table at 0x141b03f70:
|
|
# 1 UserSessionExtendedDataUpdate 2 UserAdded 3 UserRemoved
|
|
# 5 UserUpdated 8 UserAuthenticated 9 UserUnauthenticated
|
|
# 12 ServerDraining
|
|
|
|
# CGID (connectionGroupObjectId) is an ObjectId triple. heat2's OBJID encoding
|
|
# is UNVERIFIED on the wire and a wrong encoding desynchronises the whole TDF
|
|
# parse, whereas an ABSENT member simply keeps its client-side default. So we
|
|
# omit it. Flip this once OBJID is confirmed against a real capture.
|
|
EMIT_OBJID_FIELDS = False
|
|
|
|
|
|
def user_session_login_info_fields(sess: Session, now: int) -> "OrderedDict":
|
|
"""Blaze::UserSessionLoginInfo @0x14486f920 -- 16 members. This is a
|
|
SUPERSET of UserLoginInfo with the persona fields flattened in rather than
|
|
nested. KEY must be byte-identical to LoginResponse.SESS.KEY."""
|
|
f = OrderedDict([
|
|
("1CON", (INT, 0)), # isFirstConsoleLogin
|
|
("ALOC", (INT, sess.account_locale)), # accountLocale (echo client's)
|
|
("BUID", (INT, USER_ID)), # blazeUserId
|
|
("DSNM", (STRING, PERSONA_NAME)), # displayName
|
|
("FRST", (INT, 0)), # isFirstLogin
|
|
("KEY", (STRING, sess.session_key)), # sessionKey <- SAME string
|
|
("LAST", (INT, now)), # lastAuthenticated
|
|
("LLOG", (INT, now)), # lastLoginDateTime
|
|
("MAIL", (STRING, EMAIL)), # email
|
|
("NASP", (STRING, PERSONA_NAMESPACE)), # must match PreAuthResponse
|
|
("PID", (INT, PERSONA_ID)), # personaId
|
|
("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform
|
|
("UID", (INT, USER_ID)), # userId
|
|
("USTP", (INT, USER_SESSION_TYPE)), # userSessionType
|
|
("XREF", (INT, EXT_ID)), # extId
|
|
])
|
|
if EMIT_OBJID_FIELDS:
|
|
from heat2 import OBJID
|
|
f["CGID"] = (OBJID, (COMP_USERSESSIONS, 1, USER_ID))
|
|
return f
|
|
|
|
|
|
def network_qos_data_fields() -> "OrderedDict":
|
|
"""Blaze::Util::NetworkQosData @0x14486e680 -- 5 members.
|
|
NATT = NatType; 0 = OPEN, which is what we want offline."""
|
|
return OrderedDict([
|
|
("BWHR", (INT, 0)), # bandwidthHostedRate
|
|
("DBPS", (INT, 100000)), # downstream bits/s
|
|
("NAHR", (INT, 0)), # natHostedRate
|
|
("NATT", (INT, 0)), # NatType -> OPEN
|
|
("UBPS", (INT, 100000)), # upstream bits/s
|
|
])
|
|
|
|
|
|
def user_session_extended_data_fields() -> "OrderedDict":
|
|
"""Blaze::UserSessionExtendedData @0x144870390 -- 12 members.
|
|
|
|
TWO FIFA-17-SPECIFIC DELTAS vs the MEC emulators: FIFA HAS `PSLM`
|
|
(latencyList) which they lack, and FIFA has `BPS` as a TOP-LEVEL string
|
|
member whereas they bury it inside the ADDR union. Follow FIFA's layout.
|
|
|
|
ADDR (NetworkAddress union), CVAR (variable) and ULST (list<ObjectId>) are
|
|
omitted: heat2's UNION/OBJID encodings are unverified and a bad one breaks
|
|
the whole parse, while an absent member just keeps its default."""
|
|
return OrderedDict([
|
|
("BPS", (STRING, "openfut")), # bestPingSiteAlias
|
|
("CTY", (STRING, "US")), # country
|
|
("DMAP", (MAP, (INT, INT, []))), # dataMap map<int64,uint32>
|
|
("HWFG", (INT, 0)), # hardwareFlags bitfield
|
|
("ISP", (STRING, "OpenFUT")), # iSP
|
|
("PSLM", (LIST, (INT, [0]))), # latencyList <- FIFA-only
|
|
("QDAT", (STRUCT, network_qos_data_fields())),
|
|
("TZ", (STRING, "")), # timeZone
|
|
("UATT", (INT, 0)), # userInfoAttribute
|
|
])
|
|
|
|
|
|
def user_session_extended_data_update_fields() -> "OrderedDict":
|
|
"""Blaze::UserSessionExtendedDataUpdate @0x1448703e0 -- 3 members."""
|
|
return OrderedDict([
|
|
("DATA", (STRUCT, user_session_extended_data_fields())),
|
|
("SUBS", (INT, 1)), # subscribed
|
|
("USID", (INT, USER_ID)), # userId
|
|
])
|
|
|
|
|
|
def user_identification_fields() -> "OrderedDict":
|
|
"""Blaze::UserIdentification @0x14486ebc0 -- 9 members."""
|
|
return OrderedDict([
|
|
("AID", (INT, USER_ID)), # accountId
|
|
("ALOC", (INT, ACCOUNT_LOCALE_FALLBACK)), # accountLocale
|
|
("EXBB", (BLOB, b"")), # externalBlob
|
|
("EXID", (INT, EXT_ID)), # externalId
|
|
("ID", (INT, USER_ID)), # blazeId
|
|
("NAME", (STRING, PERSONA_NAME)), # name
|
|
("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace
|
|
("ORIG", (INT, PERSONA_ID)), # originPersonaId
|
|
("PIDI", (INT, PERSONA_ID)), # pidId
|
|
])
|
|
|
|
|
|
def user_data_fields() -> "OrderedDict":
|
|
"""Blaze::UserData @0x1448706b0 -- 3 members. Payload of UserAdded (2).
|
|
FLGS is a UserDataFlags bitfield; bit0 = online/authenticated."""
|
|
return OrderedDict([
|
|
("EDAT", (STRUCT, user_session_extended_data_fields())),
|
|
("FLGS", (INT, 3)),
|
|
("USER", (STRUCT, user_identification_fields())),
|
|
])
|
|
|
|
|
|
def build_login_notifications(sess: Session, now: int) -> list:
|
|
"""The push sequence the client waits on after a successful login."""
|
|
return [
|
|
("UserAuthenticated", notification(
|
|
COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED,
|
|
encode_tdf(user_session_login_info_fields(sess, now)))),
|
|
("UserSessionExtendedDataUpdate", notification(
|
|
COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE,
|
|
encode_tdf(user_session_extended_data_update_fields()))),
|
|
("UserAdded", notification(
|
|
COMP_USERSESSIONS, NOTIFY_USER_ADDED,
|
|
encode_tdf(user_data_fields()))),
|
|
]
|
|
|
|
|
|
# ================================================ post-login RPC bodies
|
|
|
|
def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
|
"""Blaze::Util::PostAuthResponse @0x144875810 -- TELE, TICK, UROP.
|
|
Telemetry/ticker are pointed at a dead local port on purpose: we want the
|
|
client to have a well-formed config and then fail to connect quietly rather
|
|
than resolve a real EA hostname."""
|
|
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
|
("ADRS", (STRING, "127.0.0.1")),
|
|
("ANON", (INT, 0)),
|
|
("DISA", (STRING, "")),
|
|
("EDCT", (INT, 0)),
|
|
("FILT", (STRING, "")),
|
|
("LOC", (INT, sess.account_locale)),
|
|
("MINR", (INT, 0)),
|
|
("NOOK", (STRING, "")),
|
|
("PORT", (INT, 9988)),
|
|
("SDLY", (INT, 15000)),
|
|
("SESS", (STRING, sess.session_key)),
|
|
("SKEY", (STRING, "")),
|
|
("SPCT", (INT, 75)),
|
|
("STIM", (STRING, "")),
|
|
("SVNM", (STRING, "telemetry-openfut")),
|
|
])
|
|
tick = OrderedDict([ # GetTickerServerResponse (3)
|
|
("ADRS", (STRING, "127.0.0.1")),
|
|
("PORT", (INT, 8999)),
|
|
("SKEY", (STRING, "")),
|
|
])
|
|
urop = OrderedDict([ # UserOptions (2)
|
|
("TMOP", (INT, 0)), # TelemetryOpt -> out/disabled
|
|
("UID", (INT, USER_ID)),
|
|
])
|
|
return OrderedDict([
|
|
("TELE", (STRUCT, tele)),
|
|
("TICK", (STRUCT, tick)),
|
|
("UROP", (STRUCT, urop)),
|
|
])
|
|
|
|
|
|
def entitlement_fields(now: int) -> "OrderedDict":
|
|
"""Blaze::Authentication::Entitlement @0x14487d490 -- 16 members.
|
|
The retail exe requires TAG='ONLINE_ACCESS' tied to offer 1027460, STAT
|
|
active, PID 33068179. Failure modes: AUTH_ERR_NO_SUCH_ENTITLEMENT (63),
|
|
AUTH_ERR_ENTITLEMENT_TAG_REQUIRED (74)."""
|
|
day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
|
|
return OrderedDict([
|
|
("DEVI", (STRING, "")), # deviceUri
|
|
("GDAY", (STRING, "2016-09-01T00:00:00Z")), # grantDate
|
|
("GNAM", (STRING, ENTITLEMENT_GROUP)), # groupName
|
|
("ID", (INT, 1)), # id
|
|
("ISCO", (INT, 0)), # isConsumable
|
|
("PID", (INT, PERSONA_ID)), # personaId
|
|
("PJID", (STRING, CONTENT_ID)), # projectId (EA offer id)
|
|
("PRCA", (INT, 2)), # productCatalog
|
|
("PRID", (STRING, CONTENT_ID)), # productId
|
|
("STAT", (INT, 1)), # EntitlementStatus -> ACTIVE (1, verified)
|
|
("STRC", (INT, 0)), # statusReasonCode
|
|
("TAG", (STRING, ENTITLEMENT_TAG)), # entitlementTag
|
|
("TDAY", (STRING, "")), # terminationDate (never)
|
|
("TYPE", (INT, 1)), # EntitlementType -> ONLINE_ACCESS (1, verified)
|
|
("UCNT", (INT, 0)), # useCount
|
|
("VER", (INT, 1)), # version
|
|
])
|
|
del day # (kept for readability of the date format above)
|
|
|
|
|
|
def entitlements_response_fields() -> "OrderedDict":
|
|
"""Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST."""
|
|
now = int(time.time())
|
|
return OrderedDict([
|
|
("NLST", (LIST, (STRUCT, [entitlement_fields(now)]))),
|
|
])
|
|
|
|
|
|
def get_auth_token_response_fields(sess: Session) -> "OrderedDict":
|
|
"""Blaze::Authentication::GetAuthTokenResponse @0x14487d080 -- 1 member."""
|
|
tok = sess.auth_code or ("OPENFUT-" + sess.session_key[:16])
|
|
return OrderedDict([("AUTH", (STRING, tok))])
|
|
|
|
|
|
def user_settings_response_fields() -> "OrderedDict":
|
|
"""TODO(verify): Util::userSettingsLoad's response descriptor was not
|
|
reflected. Both independent clean-room emulators use a single `DATA`
|
|
string, and an unknown-tag payload is ignored rather than fatal, so an empty
|
|
DATA is the safe minimum -- the client falls back to defaults."""
|
|
return OrderedDict([("DATA", (STRING, ""))])
|
|
|
|
|
|
def get_lists_response_fields() -> "OrderedDict":
|
|
"""TODO(verify): AssociationLists::getLists (25/6) response is 3P-only
|
|
(GetListsResponse{LMAP: list<AssociationList>}). FIFA's list names are NOT
|
|
verified -- do not invent them. An EMPTY list is well-formed and means
|
|
'this user has no association lists', which is true offline."""
|
|
return OrderedDict([("LMAP", (LIST, (STRUCT, [])))])
|
|
|
|
|
|
# ================================================================== dispatch
|
|
|
|
def extract_service_name(fields) -> str:
|
|
"""PreAuthRequest.CDAT.SVCN -- echo it back as INST."""
|
|
try:
|
|
cdat = fields.get("CDAT")
|
|
if cdat and cdat[0] == STRUCT:
|
|
svcn = cdat[1].get("SVCN")
|
|
if svcn and svcn[0] == STRING and svcn[1]:
|
|
return svcn[1]
|
|
except Exception:
|
|
pass
|
|
return "fifa-2017-pc"
|
|
|
|
|
|
def find_nested_int(fields, tag: str):
|
|
"""Depth-first search for an INT member `tag` anywhere in a decoded TDF."""
|
|
if not isinstance(fields, dict):
|
|
return None
|
|
for k, (t, v) in fields.items():
|
|
if k == tag and t == INT:
|
|
return v
|
|
if t == STRUCT:
|
|
r = find_nested_int(v, tag)
|
|
if r is not None:
|
|
return r
|
|
return None
|
|
|
|
|
|
def get_str(fields, tag: str, default: str = "") -> str:
|
|
try:
|
|
tv = fields.get(tag)
|
|
if tv and tv[0] == STRING:
|
|
return tv[1]
|
|
except Exception:
|
|
pass
|
|
return default
|
|
|
|
|
|
def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
|
"""-> list of frames to send back, in order (may be empty)."""
|
|
comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"]
|
|
|
|
# Transport-level PING frame (msgType 4) -> PING_REPLY (5), empty body.
|
|
if mtype == PING:
|
|
log(" -> transport PING, answering PING_REPLY (empty)")
|
|
return [reply_to(hdr, b"", msg_type=PING_REPLY)]
|
|
|
|
if mtype not in (MESSAGE,):
|
|
log(" -> msgType %s is not a request; not answering"
|
|
% MSGTYPE_NAME.get(mtype, mtype))
|
|
return []
|
|
|
|
# ---------------------------------------------------------------- Util
|
|
if comp == COMP_UTIL:
|
|
if cmd == CMD_PREAUTH:
|
|
sess.service_name = (extract_service_name(fields)
|
|
if fields is not None else "fifa-2017-pc")
|
|
loc = None
|
|
if fields is not None:
|
|
loc = find_nested_int(fields, "LANG")
|
|
if loc is None:
|
|
loc = find_nested_int(fields, "LOC")
|
|
if loc:
|
|
sess.account_locale = loc
|
|
log(" -- client locale 0x%08x captured for ALOC" % loc)
|
|
resp = preauth_response_fields(service_name=sess.service_name)
|
|
payload = encode_tdf(resp)
|
|
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
|
|
% (sess.service_name, len(payload), heat2.dump(resp)))
|
|
return [reply_to(hdr, payload)]
|
|
|
|
if cmd == CMD_PING:
|
|
resp = ping_response_fields()
|
|
log(" -> PingResponse STIM=%d" % resp["STIM"][1])
|
|
return [reply_to(hdr, encode_tdf(resp))]
|
|
|
|
if cmd == CMD_FETCHCLIENTCONFIG:
|
|
cfid = get_str(fields or {}, "CFID", "")
|
|
resp = fetch_config_response_fields(cfid)
|
|
n = len(resp["CONF"][1][2])
|
|
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
|
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
|
for k, v in resp["CONF"][1][2]:
|
|
log(" %-32s = %s" % (k, v))
|
|
return [reply_to(hdr, encode_tdf(resp))]
|
|
|
|
if cmd == CMD_POSTAUTH:
|
|
resp = post_auth_response_fields(sess)
|
|
log(" -> PostAuthResponse (TELE/TICK/UROP)")
|
|
return [reply_to(hdr, encode_tdf(resp))]
|
|
|
|
if cmd == CMD_SETCLIENTSTATE:
|
|
log(" -> setClientState: empty REPLY (no response TDF)")
|
|
return [reply_to(hdr, b"")]
|
|
|
|
if cmd == CMD_SETCLIENTMETRICS:
|
|
log(" -> setClientMetrics: empty REPLY (no response TDF)")
|
|
return [reply_to(hdr, b"")]
|
|
|
|
if cmd == CMD_USERSETTINGSLOAD:
|
|
log(" -> UserSettingsResponse (empty DATA; TODO verify descriptor)")
|
|
return [reply_to(hdr, encode_tdf(user_settings_response_fields()))]
|
|
|
|
if cmd == CMD_USERSETTINGSSAVE:
|
|
log(" -> userSettingsSave: empty REPLY (accepted, discarded)")
|
|
return [reply_to(hdr, b"")]
|
|
|
|
if cmd == 0x15: # fetchQosConfig -> proven-good QosConfigInfo body
|
|
log(" -> QosConfigInfo (fetchQosConfig)")
|
|
return [reply_to(hdr, encode_tdf(qos_config()))]
|
|
|
|
# ------------------------------------------------------ Authentication
|
|
if comp == COMP_AUTH:
|
|
if cmd == CMD_LOGIN:
|
|
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
|
sess.logged_in = True
|
|
sess.login_time = int(time.time())
|
|
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
|
|
"validation -- forged offline session)" % sess.auth_code)
|
|
resp = login_response_fields(sess)
|
|
payload = encode_tdf(resp)
|
|
log(" -> LoginResponse (%d bytes):\n%s"
|
|
% (len(payload), heat2.dump(resp)))
|
|
notifs = build_login_notifications(sess, sess.login_time)
|
|
out = []
|
|
if NOTIFY_BEFORE_LOGIN_REPLY:
|
|
for name, fr in notifs:
|
|
log(" ~> NOTIFY 0x7802/0x%04x %s" %
|
|
(parse_fire2_header(fr)["command"], name))
|
|
out.append(fr)
|
|
out.append(reply_to(hdr, payload))
|
|
else:
|
|
out.append(reply_to(hdr, payload))
|
|
for name, fr in notifs:
|
|
log(" ~> NOTIFY 0x7802/0x%04x %s" %
|
|
(parse_fire2_header(fr)["command"], name))
|
|
out.append(fr)
|
|
return out
|
|
|
|
if cmd in (CMD_TRUSTEDLOGIN, CMD_EXPRESSLOGIN):
|
|
# Same forged session; the request fields differ but we ignore them.
|
|
sess.logged_in = True
|
|
sess.login_time = int(time.time())
|
|
log(" == Authentication::%s -> same forged LoginResponse"
|
|
% AUTH_CMDS.get(cmd, cmd))
|
|
out = [reply_to(hdr, encode_tdf(login_response_fields(sess)))]
|
|
out += [fr for _, fr in build_login_notifications(sess,
|
|
sess.login_time)]
|
|
return out
|
|
|
|
if cmd == CMD_LOGOUT:
|
|
# An empty REPLY was already correct on the wire (logout has NO
|
|
# request and NO response TDF -- no LogoutRequest/LogoutResponse
|
|
# exists in the client's type index). But receiving this at all
|
|
# means the client decided it had no credential BEFORE Blaze auth.
|
|
log(" !! FAILURE SIGNAL: Authentication::logout (1/0x46) -- the "
|
|
"client never sent login. LAYER 1 (Origin/LSX on "
|
|
"127.0.0.1:4216) is still returning offline / no auth code. "
|
|
"Fix lsx_responder.py or lsx_force_online.py FIRST.")
|
|
return [reply_to(hdr, b"")]
|
|
|
|
if cmd in (CMD_LISTUSERENTITLEMENTS2, 0x20, 0x30, 0x27):
|
|
# 0x1D listUserEntitlements2 / 0x20 listEntitlements /
|
|
# 0x30 listPersonaEntitlements2 / 0x27 grantEntitlement2 -- all return
|
|
# the ONLINE_ACCESS entitlement so the client sees it however it asks.
|
|
log(" -> Entitlements{NLST:[%s / offer %s / ACTIVE]} (cmd 0x%02x)"
|
|
% (ENTITLEMENT_TAG, CONTENT_ID, cmd))
|
|
return [reply_to(hdr, encode_tdf(entitlements_response_fields()))]
|
|
|
|
if cmd == CMD_GETAUTHTOKEN:
|
|
resp = get_auth_token_response_fields(sess)
|
|
log(" -> GetAuthTokenResponse AUTH=%r" % resp["AUTH"][1])
|
|
return [reply_to(hdr, encode_tdf(resp))]
|
|
|
|
# ------------------------------------------------------- UserSessions
|
|
if comp == COMP_USERSESSIONS and cmd == CMD_UPDATENETWORKINFO:
|
|
log(" -> updateNetworkInfo: empty REPLY, then re-push "
|
|
"UserSessionExtendedDataUpdate")
|
|
return [
|
|
reply_to(hdr, b""),
|
|
notification(COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE,
|
|
encode_tdf(user_session_extended_data_update_fields())),
|
|
]
|
|
|
|
# --------------------------------------------------- AssociationLists
|
|
if comp == COMP_ASSOCLISTS and cmd == CMD_GETLISTS:
|
|
log(" -> GetListsResponse{LMAP: []} (TODO verify FIFA's list names)")
|
|
return [reply_to(hdr, encode_tdf(get_lists_response_fields()))]
|
|
|
|
# ---------------------------------------------------------------- TODO
|
|
# Still unimplemented, in the order they are expected to show up:
|
|
# Util::fetchQosConfig (9/0x15) -> QosConfigInfo (see qos_config)
|
|
# Util::localizeStrings (9/4) -> echo the requested ids
|
|
# Messaging::fetchMessages (15/2) -> empty list
|
|
# Stats / GameReporting / GameManager -> FUT-mode specific, later
|
|
# Authentication::getTermsOfServiceContent (1/0xF6) and
|
|
# getPrivacyPolicyContent (1/0x2F) -> only reached if NTOS != 0
|
|
# Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED
|
|
# (three clean-room sources disagree: header[14:16] vs metadata ERRC vs
|
|
# payload CNTX/ERRC) -- do not emit one until it is verified on the wire.
|
|
# ----------------------------------------------------------------------
|
|
|
|
if REPLY_EMPTY_TO_UNKNOWN:
|
|
log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not "
|
|
"hang (all fields fall back to client-side defaults)"
|
|
% rpc_name(comp, cmd, mtype))
|
|
return [reply_to(hdr, b"")]
|
|
|
|
log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd, mtype))
|
|
return []
|
|
|
|
|
|
# ============================================================= blaze server
|
|
|
|
def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool:
|
|
"""Fill `buf` to at least n bytes. False on clean EOF / short close."""
|
|
while len(buf) < n:
|
|
try:
|
|
chunk = sock.recv(65536)
|
|
except socket.timeout:
|
|
return False
|
|
if not chunk:
|
|
return False
|
|
buf += chunk
|
|
return True
|
|
|
|
|
|
_frame_counter = [0]
|
|
|
|
|
|
def blaze_handle(raw: socket.socket, addr) -> None:
|
|
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
|
sess = Session()
|
|
log(" session key minted: %s" % sess.session_key)
|
|
buf = bytearray()
|
|
raw.settimeout(300)
|
|
try:
|
|
while True:
|
|
if not recv_exactly(raw, FIRE2_HDR, buf):
|
|
break
|
|
hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR]))
|
|
total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"]
|
|
if hdr["payload_len"] > 4 * 1024 * 1024:
|
|
log("BLAZE %s: absurd payload_len %d, dropping connection\n%s"
|
|
% (addr, hdr["payload_len"], hexdump(bytes(buf[:64]))))
|
|
break
|
|
if not recv_exactly(raw, total, buf):
|
|
log("BLAZE %s: EOF mid-frame (want %d, have %d)"
|
|
% (addr, total, len(buf)))
|
|
break
|
|
|
|
frame = bytes(buf[:total])
|
|
del buf[:total]
|
|
metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]]
|
|
payload = frame[FIRE2_HDR + hdr["metadata_len"]:]
|
|
|
|
_frame_counter[0] += 1
|
|
n = _frame_counter[0]
|
|
log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x "
|
|
"meta=%dB payload=%dB"
|
|
% (n, rpc_name(hdr["component"], hdr["command"], hdr["msg_type"]),
|
|
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
|
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
|
hdr["metadata_len"], hdr["payload_len"]))
|
|
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
|
if metadata:
|
|
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
|
if DUMP_FRAMES:
|
|
try:
|
|
os.makedirs(RXDIR, exist_ok=True)
|
|
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
|
% (n, hdr["component"], hdr["command"]))
|
|
with open(fn, "wb") as fh:
|
|
fh.write(frame)
|
|
log("RX #%d saved -> %s" % (n, fn))
|
|
except Exception as e:
|
|
log("RX #%d save failed: %s" % (n, e))
|
|
|
|
fields = None
|
|
if payload:
|
|
try:
|
|
fields = decode_tdf(payload)
|
|
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
|
except Exception as e:
|
|
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
|
else:
|
|
log("RX #%d TDF: (empty payload)" % n)
|
|
|
|
try:
|
|
outs = dispatch(hdr, fields, payload, sess)
|
|
except Exception as e:
|
|
log("RX #%d DISPATCH ERROR: %r" % (n, e))
|
|
outs = []
|
|
|
|
for k, out in enumerate(outs):
|
|
raw.sendall(out)
|
|
ohdr = parse_fire2_header(out)
|
|
log("TX #%d.%d %s msgType=%s msgNum=%d %dB total (%d payload)"
|
|
% (n, k,
|
|
rpc_name(ohdr["component"], ohdr["command"],
|
|
ohdr["msg_type"]),
|
|
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
|
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
|
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
|
except ConnectionResetError:
|
|
log("BLAZE %s: connection reset by client" % (addr,))
|
|
except Exception as e:
|
|
log("BLAZE %s ERR: %r" % (addr, e))
|
|
finally:
|
|
try:
|
|
raw.close()
|
|
except Exception:
|
|
pass
|
|
log("BLAZE %s: closed" % (addr,))
|
|
|
|
|
|
# ======================================================= redirector (TLS)
|
|
|
|
def build_redirect_response() -> bytes:
|
|
# ServerInstanceInfo.address is a ServerAddress union -> Heat2 XML union is
|
|
# <address member="N"><valu>... member="0" = ipAddress variant
|
|
# {hostname, ip(uint32 decimal), port(uint16)}.
|
|
body = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<serverinstanceinfo>\n'
|
|
'\t<address member="0">\n'
|
|
'\t\t<valu>\n'
|
|
f'\t\t\t<hostname>{BLAZE_IP_STR}</hostname>\n'
|
|
f'\t\t\t<ip>{BLAZE_IP_U32}</ip>\n'
|
|
f'\t\t\t<port>{BLAZE_PORT}</port>\n'
|
|
'\t\t</valu>\n'
|
|
'\t</address>\n'
|
|
'\t<secure>0</secure>\n'
|
|
'\t<trialservicename></trialservicename>\n'
|
|
'\t<defaultdnsaddress>0</defaultdnsaddress>\n'
|
|
'</serverinstanceinfo>\n'
|
|
)
|
|
b = body.encode()
|
|
hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n"
|
|
f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode()
|
|
return hdr + b
|
|
|
|
|
|
def make_tls_context():
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
ctx.load_cert_chain(CERT, KEY)
|
|
ctx.minimum_version = ssl.TLSVersion.TLSv1
|
|
ctx.set_ciphers("ALL:@SECLEVEL=0")
|
|
return ctx
|
|
|
|
|
|
_ctx = [None]
|
|
|
|
|
|
def redir_handle(raw: socket.socket, addr) -> None:
|
|
try:
|
|
tls = _ctx[0].wrap_socket(raw, server_side=True)
|
|
except ssl.SSLError as e:
|
|
log("REDIR REJECTED %s: %s" % (addr, e))
|
|
raw.close()
|
|
return
|
|
log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0]))
|
|
try:
|
|
tls.settimeout(8)
|
|
req = b""
|
|
while b"\r\n\r\n" not in req:
|
|
c = tls.recv(4096)
|
|
if not c:
|
|
break
|
|
req += c
|
|
if b"content-length:" in req.lower():
|
|
head, _, rest = req.partition(b"\r\n\r\n")
|
|
cl = int([l.split(b":")[1] for l in head.split(b"\r\n")
|
|
if l.lower().startswith(b"content-length")][0])
|
|
while len(rest) < cl:
|
|
c = tls.recv(4096)
|
|
if not c:
|
|
break
|
|
rest += c
|
|
req = head + b"\r\n\r\n" + rest
|
|
line0 = req.split(b"\r\n", 1)[0].decode(errors="replace")
|
|
log("REDIR REQ %s: %s" % (addr, line0))
|
|
resp = build_redirect_response()
|
|
tls.sendall(resp)
|
|
log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d"
|
|
% (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT))
|
|
time.sleep(0.3)
|
|
tls.close()
|
|
except Exception as e:
|
|
log("REDIR ERR %s: %s" % (addr, e))
|
|
|
|
|
|
# ================================================ Nucleus OAuth stub (plain)
|
|
#
|
|
# LoginStateMachineImpl (string cluster 0x14389fd50-0x14389fef8) builds
|
|
# "<nucleusConnect>/connect/token", POSTs "grant_type=client_credentials"
|
|
# with a "NEXUS_S2S " authorization header, and scrapes '"access_token" : "'
|
|
# out of the reply body. We advertise nucleusConnect = this listener, so the
|
|
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
|
|
# the client searches for the literal '"access_token" : "'.
|
|
|
|
def nucleus_handle(raw: socket.socket, addr) -> None:
|
|
try:
|
|
raw.settimeout(10)
|
|
req = b""
|
|
while b"\r\n\r\n" not in req and len(req) < 65536:
|
|
c = raw.recv(4096)
|
|
if not c:
|
|
break
|
|
req += c
|
|
head, _, rest = req.partition(b"\r\n\r\n")
|
|
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
|
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
|
if head:
|
|
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
|
if rest:
|
|
log("NUCLEUS BODY: %r" % rest[:512])
|
|
|
|
token = "OPENFUT_" + "".join(
|
|
random.choice(string.ascii_letters + string.digits) for _ in range(40))
|
|
# Spacing matters: the client greps for the literal '"access_token" : "'.
|
|
body = ('{\n "access_token" : "%s",\n "token_type" : "Bearer",\n'
|
|
' "expires_in" : 14400,\n "id_token" : "%s",\n'
|
|
' "refresh_token" : "%s"\n}\n'
|
|
% (token, token, token)).encode()
|
|
out = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
|
|
b"Cache-Control: no-store\r\nContent-Length: "
|
|
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
|
raw.sendall(out)
|
|
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
|
|
except Exception as e:
|
|
log("NUCLEUS ERR %s: %s" % (addr, e))
|
|
finally:
|
|
try:
|
|
raw.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ================================================================== serve
|
|
|
|
def serve(port: int, handler, name: str) -> None:
|
|
s = socket.socket()
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind((HOST, port))
|
|
s.listen(16)
|
|
log("%s listening on %s:%d" % (name, HOST, port))
|
|
while True:
|
|
c, a = s.accept()
|
|
threading.Thread(target=handler, args=(c, a), daemon=True).start()
|
|
|
|
|
|
# ================================================================== selftest
|
|
|
|
def _check_roundtrip(label: str, fields) -> bytes:
|
|
payload = encode_tdf(fields)
|
|
back = decode_tdf(payload)
|
|
again = encode_tdf(back)
|
|
assert again == payload, "%s: re-encode differs" % label
|
|
assert list(back.keys()) == sorted(fields.keys(), key=heat2.tag_key), \
|
|
"%s: tag order %r" % (label, list(back.keys()))
|
|
return payload
|
|
|
|
|
|
def _selftest() -> None:
|
|
print("=" * 72)
|
|
print("blaze_responder_v3 selftest")
|
|
print("=" * 72)
|
|
|
|
sess = Session()
|
|
sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0"
|
|
sess.account_locale = 0x656E5553
|
|
now = 1469000000
|
|
|
|
# ---- 1. preAuth still round-trips (regression guard vs v2)
|
|
pre = preauth_response_fields()
|
|
p = _check_roundtrip("PreAuthResponse", pre)
|
|
fr = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, p)
|
|
assert fr[13] == 0x20, fr[13]
|
|
assert list(decode_tdf(p).keys()) == [
|
|
"ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST", "MAID", "MINR",
|
|
"NASP", "PILD", "PLAT", "QOSS", "RSRC", "SVER"]
|
|
print("[ok] PreAuthResponse %4d payload bytes" % len(p))
|
|
|
|
# ---- 2. fetchClientConfig per CFID
|
|
for cfid in ["BlazeSDK", "OSDK_CORE", "OSDK_CLIENT", "OSDK_NUCLEUS",
|
|
"OSDK_WEBOFFER", "OSDK_ABUSE_REPORTING",
|
|
"OSDK_XMS_ABUSE_REPORTING", "IdentityParams", "TOTALLY_UNKNOWN"]:
|
|
f = fetch_config_response_fields(cfid)
|
|
pb = _check_roundtrip("FetchConfigResponse/" + cfid, f)
|
|
back = decode_tdf(pb)
|
|
assert list(back.keys()) == ["CONF"], back.keys()
|
|
kt, vt, items = back["CONF"][1]
|
|
assert (kt, vt) == (STRING, STRING)
|
|
assert items == client_config_for(cfid), cfid
|
|
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
|
% (cfid, len(items), len(pb)))
|
|
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
|
|
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
|
|
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
|
|
|
|
# ---- 3. LoginResponse
|
|
lr = login_response_fields(sess)
|
|
lp = _check_roundtrip("LoginResponse", lr)
|
|
back = decode_tdf(lp)
|
|
assert list(back.keys()) == ["ANON", "NTOS", "SESS", "SPAM", "UNDR"], back.keys()
|
|
assert "CNTX" not in back and "ERRC" not in back and "SKEY" not in back
|
|
s = back["SESS"][1]
|
|
assert list(s.keys()) == ["1CON", "BUID", "FRST", "KEY", "LLOG", "MAIL",
|
|
"PDTL", "UID"], list(s.keys())
|
|
assert s["KEY"][1] == sess.session_key
|
|
assert s["BUID"][1] == USER_ID != 0 and s["UID"][1] == USER_ID
|
|
d = s["PDTL"][1]
|
|
assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \
|
|
list(d.keys())
|
|
assert d["PID"][1] == PERSONA_ID == 33068179
|
|
assert d["DSNM"][1] == PERSONA_NAME == "CAGE"
|
|
assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0
|
|
lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)
|
|
lh = parse_fire2_header(lfr)
|
|
assert (lh["component"], lh["command"], lh["msg_type"]) == (1, 0x0A, REPLY)
|
|
assert lfr[13] == 0x20
|
|
print("[ok] LoginResponse %4d payload bytes, hdr %s"
|
|
% (len(lp), lfr[:16].hex(" ")))
|
|
|
|
# ---- 4. UserAuthenticated notification
|
|
ua = user_session_login_info_fields(sess, now)
|
|
up = _check_roundtrip("UserSessionLoginInfo", ua)
|
|
nb = decode_tdf(up)
|
|
assert list(nb.keys()) == ["1CON", "ALOC", "BUID", "DSNM", "FRST", "KEY",
|
|
"LAST", "LLOG", "MAIL", "NASP", "PID", "PLAT",
|
|
"UID", "USTP", "XREF"], list(nb.keys())
|
|
assert nb["KEY"][1] == sess.session_key, "notif KEY must match SESS.KEY"
|
|
assert nb["PID"][1] == PERSONA_ID and nb["DSNM"][1] == PERSONA_NAME
|
|
assert nb["NASP"][1] == PERSONA_NAMESPACE
|
|
nfr = notification(COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, up)
|
|
nh = parse_fire2_header(nfr)
|
|
assert (nh["component"], nh["command"]) == (0x7802, 0x0008), nh
|
|
assert nh["msg_type"] == NOTIFICATION and nh["msg_num"] == 0
|
|
assert nfr[13] == 0x40, nfr[13]
|
|
print("[ok] UserAuthenticated notif %4d payload bytes, hdr %s"
|
|
% (len(up), nfr[:16].hex(" ")))
|
|
|
|
# ---- 5. the other two notifications + post-login RPCs
|
|
for label, f in [
|
|
("UserSessionExtendedDataUpdate", user_session_extended_data_update_fields()),
|
|
("UserAdded (UserData)", user_data_fields()),
|
|
("PostAuthResponse", post_auth_response_fields(sess)),
|
|
("Entitlements", entitlements_response_fields()),
|
|
("GetAuthTokenResponse", get_auth_token_response_fields(sess)),
|
|
("UserSettingsResponse", user_settings_response_fields()),
|
|
("GetListsResponse", get_lists_response_fields()),
|
|
("PingResponse", ping_response_fields()),
|
|
]:
|
|
b = _check_roundtrip(label, f)
|
|
print("[ok] %-30s %4d payload bytes" % (label, len(b)))
|
|
assert list(ping_response_fields().keys()) == ["STIM"], \
|
|
"PingResponse must carry ONLY STIM (TIME is MEC's, not FIFA 17's)"
|
|
|
|
# ---- 6. the full login burst, framed
|
|
frames = [fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)] + \
|
|
[fr for _, fr in build_login_notifications(sess, now)]
|
|
blob = b"".join(frames)
|
|
seen, i = [], 0
|
|
while i < len(blob):
|
|
h = parse_fire2_header(blob[i:i + 16])
|
|
tot = 16 + h["metadata_len"] + h["payload_len"]
|
|
seen.append((h["component"], h["command"], h["msg_type"]))
|
|
decode_tdf(blob[i + 16 + h["metadata_len"]:i + tot])
|
|
i += tot
|
|
assert seen == [(0x0001, 0x000A, REPLY),
|
|
(0x7802, 0x0008, NOTIFICATION),
|
|
(0x7802, 0x0001, NOTIFICATION),
|
|
(0x7802, 0x0002, NOTIFICATION)], seen
|
|
print("[ok] login burst re-framed: %d frames / %d bytes"
|
|
% (len(frames), len(blob)))
|
|
|
|
print()
|
|
print("---- LoginResponse -------------------------------------------------")
|
|
print(heat2.dump(lr))
|
|
print()
|
|
print("---- NOTIFY 0x7802/0x0008 UserAuthenticated ------------------------")
|
|
print(heat2.dump(ua))
|
|
print()
|
|
print("ALL SELFTESTS PASSED")
|
|
|
|
|
|
# ================================================================== main
|
|
|
|
if __name__ == "__main__":
|
|
if "--selftest" in sys.argv:
|
|
_selftest()
|
|
raise SystemExit(0)
|
|
|
|
log("=== RESPONDER v3 START (redir %d / blaze %d%s) ==="
|
|
% (REDIR_PORT, BLAZE_PORT,
|
|
(" / nucleus %d" % NUCLEUS_PORT) if NUCLEUS_STUB_ENABLED else ""))
|
|
log(" persona %d / %r namespace %r entitlement %s (offer %s)"
|
|
% (PERSONA_ID, PERSONA_NAME, PERSONA_NAMESPACE, ENTITLEMENT_TAG,
|
|
CONTENT_ID))
|
|
log(" REMINDER: layer 1 first -- start lsx_responder.py BEFORE FIFA 17, "
|
|
"or the client sends logout (1/0x46) instead of login (1/0x0A).")
|
|
_ctx[0] = make_tls_context()
|
|
threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"),
|
|
daemon=True).start()
|
|
if NUCLEUS_STUB_ENABLED:
|
|
threading.Thread(target=serve,
|
|
args=(NUCLEUS_PORT, nucleus_handle, "NUCLEUS"),
|
|
daemon=True).start()
|
|
serve(REDIR_PORT, redir_handle, "REDIR")
|