#!/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.
"""
OpenFUT clean-room LSX responder for FIFA 17 (replaces the Steampunks stp-origin_emu
in-process stub on 127.0.0.1:4216).

PROVENANCE / CLEAN-ROOM: every constant and algorithm here was recovered by static +
dynamic analysis of binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in
our own running process). Nothing is derived from the 2021 EA/FIFA leak.

WIRE PROTOCOL (reversed from stp-origin_emu.dll @ base 0x6ffffc930000):
  transport : TCP 127.0.0.1:4216, each message is a NUL-terminated byte string
              (send length == strlen(msg)+1).
  handshake : server sends <Challenge key="..."> IN PLAINTEXT.
              client replies (plaintext) with response="..." and key="..." attrs.
              server replies <ChallengeAccepted response="H"> where
                  H = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED))
                  K_FIXED = 000102030405060708090a0b0c0d0e0f   (emu .rdata 0x935038)
  session   : every later message is
                  hex_lower( AES128_ECB_encrypt( pkcs7_pad16( xml ) ) )
              under SESSION_KEY, which both sides derive from H (see derive_session_key).
              Incoming messages are hex-decoded, decrypted, pad-stripped.

USAGE: bind this BEFORE launching FIFA 17. The stub's bind() then fails and its
       server thread returns cleanly (it has no SO_REUSEADDR and no retry), so the
       game's OriginSDK connects to us instead.
"""
import socket, sys, re, os, threading
from Crypto.Cipher import AES

# ---------------------------------------------------------------- identity
# SHARED CONSTANTS -- must stay byte-identical to stp-origin_emu.ini [Globals]
# AND to the same block at the top of blaze_responder_v3.py.  A mismatch between
# what LSX reports here and what Blaze returns in LoginResponse.SESS.PDTL is
# exactly what raises AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_
# PERSONA / AUTH_ERR_PERSONA_NOT_FOUND.
PERSONA_ID = 33068179
PERSONA_NAME = "CAGE"
USER_ID = 33068179
CONTENT_ID = "1027460"          # FIFA 17 EA offer id
ENTITLEMENT_TAG = "ONLINE_ACCESS"

# TWO-LAYER ORDERING: this file is LAYER 1.  It must be listening on
# 127.0.0.1:4216 BEFORE FIFA 17 starts.  Only once GetInternetConnectedState
# answers connected="1" does origin.nav take the OriginIsOnlineTrue exit into
# futBlazeLogin; only then does the client call GetAuthCode and put the result
# in Blaze LoginRequest.AUTH (1/0x0A).  Until then it sends
# Authentication::logout (1/0x46) and blaze_responder_v3.py can do nothing.
# The auth code we hand out is echoed to AUTHCODE_FILE purely so the Blaze log
# can be correlated; blaze_responder_v3 accepts whatever AUTH arrives and never
# validates it against Nucleus.
AUTHCODE_FILE = "/tmp/openfut_authcode.txt"

# Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038
K_FIXED = bytes(range(16))      # 000102030405060708090a0b0c0d0e0f

# Emu's own advertised challenge (any 32 hex chars work; the client echoes it back)
CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32"
BUILD = "release"
VERSION = "10,4,13,6637"


# ---------------------------------------------------------------- crypto
def msvcr_rand(seed):
    """MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61)."""
    s = seed & 0xFFFFFFFF
    while True:
        s = (s * 214013 + 2531011) & 0xFFFFFFFF
        yield (s >> 16) & 0x7FFF


def derive_session_key(resp_hex: str) -> bytes:
    """Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101).

        srand(7); r0 = rand()                       -> r0 == 61
        bx  = (u16)((resp[0] << 8) + resp[1])       (16-bit wrap)
        srand(bx + r0)
        key[i] = (uint8_t)rand()   for i in 0..15
    """
    r0 = next(msvcr_rand(7))                                    # == 61
    bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF
    g = msvcr_rand((bx + r0) & 0xFFFFFFFF)
    return bytes(next(g) & 0xFF for _ in range(16))


def challenge_response(client_key_ascii: str) -> str:
    """H = hex(AES128-ECB(K_FIXED, PKCS7pad16(clientKeyAscii))).

    VERIFIED against a captured client ChallengeResponse (2026-07-30): the 32-char
    ASCII key is PKCS7-padded to 48 bytes (3 AES blocks, 96 hex), NOT zero-padded
    to 32.  With server challenge key '2b8ee7fa...' this reproduces the client's
    response '00b9c8af...216684899' exactly."""
    b = client_key_ascii.encode()
    pad = 16 - (len(b) % 16)          # 32 -> +16 full block -> 48 bytes
    b += bytes([pad]) * pad
    return AES.new(K_FIXED, AES.MODE_ECB).encrypt(b).hex()


def lsx_encrypt(xml: str, key: bytes) -> bytes:
    """pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated."""
    b = xml.encode()
    pad = 16 - (len(b) % 16)          # emu always pads (pad==16 when aligned)
    b += bytes([pad]) * pad
    return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0"


def lsx_decrypt(data: bytes, key: bytes) -> str:
    h = data.split(b"\0")[0].strip()
    raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode()))
    pad = raw[-1]
    if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]):
        raw = raw[:-pad]
    return raw.split(b"\0")[0].decode(errors="replace")


# ---------------------------------------------------------------- responses
def resp(mid, body, sender=""):
    return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'


def build_reply(mid, req_name, attrs):
    """Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script)."""
    if req_name == "GetInternetConnectedState":
        # THE ONLINE GATE. Stub hardcoded connected="0" -> "log in to Origin".
        return resp(mid, 'InternetConnectedState connected="1"')

    if req_name == "GetAuthCode":
        # Stub never implemented this at all. Element name is <AuthCode> (confirmed
        # in FIFA17.exe element table @0x143937ae0). Emit both plausible value attrs;
        # the client reads the one it knows and ignores the other.
        code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
        try:
            with open(AUTHCODE_FILE, "w") as fh:
                fh.write(code)
        except Exception:
            pass
        print(f"[lsx] *** GetAuthCode issued: {code} -- this is what should "
              f"arrive as Blaze LoginRequest.AUTH (1/0x0A) ***")
        return resp(mid, f'AuthCode Code="{code}" Return="{code}"', sender="EbisuSDK")

    if req_name == "QueryEntitlements":
        item = (f'<OriginItem ItemId="{ENTITLEMENT_TAG}" EntitlementId="1" '
                f'ResourceId="{CONTENT_ID}" OfferId="{CONTENT_ID}" '
                f'GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>')
        return (f'<LSX><Response id="{mid}" sender="EbisuSDK">'
                f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
                f'</Response></LSX>')

    if req_name == "GetProfile":
        return resp(mid,
                    f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
                    f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
                    f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" '
                    f'CommerceCurrency="USD"', sender="EbisuSDK")

    if req_name == "GetGameInfo":
        gi = attrs.get("GameInfoId")
        if gi == "LANGUAGES":
            return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,'
                             'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,'
                             'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"')
        if gi == "UPTODATE":
            # "is the title up to date?" -- MUST be true or the client shows
            # "Your title version is outdated" and blocks all online features.
            return resp(mid, 'GetGameInfoResponse GameInfo="true"')
        # FREETRIAL etc. -> false (retail, not a trial)
        return resp(mid, 'GetGameInfoResponse GameInfo="false"')

    if req_name == "GetSetting":
        sid = attrs.get("SettingId", "").upper()   # client asks UPPERCASE (ENVIRONMENT/LANGUAGE)
        if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
            return resp(mid, 'GetSettingResponse Setting="production"')
        if sid == "LANGUAGE":
            return resp(mid, 'GetSettingResponse Setting="en_US"')
        return resp(mid, 'GetSettingResponse Setting="false"')

    if req_name == "GetConfig":
        return resp(mid, 'GetConfigResponse Config="false"', sender="EbisuSDK")

    if req_name == "IsProgressiveInstallationAvailable":
        return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
                         'Available="false"')

    return resp(mid, 'ErrorSuccess Code="0" Description=""')


REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')


def serve(conn):
    # 1. plaintext Challenge
    chal = (f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
            f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
    conn.sendall(chal.encode() + b"\0")

    # 2. plaintext ChallengeResponse from client
    data = conn.recv(4096)
    m = re.search(r'key="([^"]*)"', data.decode(errors="replace"))
    client_key = m.group(1) if m else CHALLENGE_KEY
    h = challenge_response(client_key)
    key = derive_session_key(h)
    print(f"[lsx] client key={client_key} response={h[:16]}... session_key={key.hex()}")

    # 3. plaintext ChallengeAccepted
    conn.sendall(resp(1, f'ChallengeAccepted response="{h}"', "EALS").encode() + b"\0")

    # 4. encrypted request/response loop
    while True:
        data = conn.recv(65536)
        if not data:
            break
        for chunk in filter(None, data.split(b"\0")):
            try:
                xml = lsx_decrypt(chunk + b"\0", key)
            except Exception as e:
                print("[lsx] decrypt fail:", e)
                continue
            mm = REQ_RE.search(xml)
            if not mm:
                print("[lsx] <<", xml)
                continue
            mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
            attrs = dict(ATTR_RE.findall(rest))
            reply = build_reply(mid, name, attrs)
            print(f"[lsx] << id={mid} {name} {attrs}\n[lsx] >> {reply}")
            conn.sendall(lsx_encrypt(reply, key))


def main():
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", 4216))
    s.listen(8)
    print("[lsx] listening on 127.0.0.1:4216 (start FIFA 17 now)")
    while True:
        c, a = s.accept()
        print("[lsx] connection from", a)
        threading.Thread(target=serve, args=(c,), daemon=True).start()


if __name__ == "__main__":
    main()
