diff --git a/Cargo.lock b/Cargo.lock index 2286c45..9ef2812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -810,6 +821,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -2307,6 +2328,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -3118,6 +3148,10 @@ dependencies = [ "serde_json", ] +[[package]] +name = "openfut-autopatch" +version = "0.1.0" + [[package]] name = "openfut-blaze-host" version = "0.1.0" @@ -3238,6 +3272,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "openfut-lsx" +version = "0.1.0" +dependencies = [ + "aes", + "parking_lot", +] + [[package]] name = "openfut-protocol-blaze" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 77a51f8..cfdd311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ members = [ "openfut-import-fifa17", "openfut-bridge", "openfut-launcher", + # The two companion services the launcher used to shell out to Python for. + "openfut-lsx", + "openfut-autopatch", "fifa-blaze/crates/blaze-proto", "fifa-blaze/crates/server", ] diff --git a/openfut-autopatch/Cargo.toml b/openfut-autopatch/Cargo.toml new file mode 100644 index 0000000..edf4c83 --- /dev/null +++ b/openfut-autopatch/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "openfut-autopatch" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Applies the FIFA 17 ProtoSSL cert and CardsDLL store patches to a running client" +publish = false + +# Deliberately dependency-free. Everything this needs is in std: /proc scanning for +# the client pid, and positioned reads/writes against /proc//mem via +# std::os::unix::fs::FileExt. A patcher that edits another process's memory should be +# auditable end to end without pulling in a dependency tree. +[dependencies] diff --git a/openfut-autopatch/src/lib.rs b/openfut-autopatch/src/lib.rs new file mode 100644 index 0000000..debaa6b --- /dev/null +++ b/openfut-autopatch/src/lib.rs @@ -0,0 +1,477 @@ +//! FIFA 17 client patcher — the ProtoSSL cert gates and the CardsDLL FUT store +//! patches, applied to a running `FIFA17.exe` through `/proc//mem`. +//! +//! Port of `fifa17-recon/tools/autopatch.py`; that file is the specification and +//! every address, byte pattern and log line here is reproduced from it verbatim. +//! The store patches are re-applied on every tick because the game rewrites +//! those sites; the cert gates are applied once per pid. +//! +//! This module holds the constants, the two pure decision functions and the +//! parsers. The enforcement passes live in [`patch`], process access in +//! [`procmem`], timestamping in [`localtime`], and the watch loop in `main.rs`. + +pub mod localtime; +pub mod logging; +pub mod patch; +pub mod procmem; + +use std::fmt; + +pub use logging::Logger; + +/// `/proc//comm` of the client we patch (exact match after trimming). +pub const CLIENT_COMM: &str = "FIFA17.exe"; + +/// Substring identifying the CardsDLL mapping in `/proc//maps`. +pub const CARDSDLL_MARKER: &str = "CardsDLL"; + +// --------------------------------------------------------------------------- +// ProtoSSL certificate gates (absolute VAs in the unpacked FIFA17.exe image; +// present only once the packer has mapped the real code, hence the watch loop). +// --------------------------------------------------------------------------- + +pub const GATE2: u64 = 0x1461361b0; +pub const GATE2_ORIG: [u8; 3] = [0x48, 0x89, 0x5c]; +pub const GATE2_PATCH: [u8; 3] = [0x31, 0xc0, 0xc3]; + +pub const GATE1: u64 = 0x146132548; +pub const GATE1_ORIG: [u8; 6] = [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00]; +pub const GATE1_PATCH: [u8; 6] = [0x90; 6]; + +// --------------------------------------------------------------------------- +// FUT store patches, expressed against the CardsDLL preferred image base and +// relocated to the live mapping base at runtime. +// --------------------------------------------------------------------------- + +/// `mov eax, 1; ret` — force a predicate true. +pub const RET_TRUE: [u8; 6] = [0xb8, 0x01, 0x00, 0x00, 0x00, 0xc3]; +/// Two `nop`s. +pub const NOP2: [u8; 2] = [0x90, 0x90]; +/// `jmp +0x3f`, the one non-uniform store patch (site `0x180017543`). +pub const SHORT_JMP_3F: [u8; 2] = [0xeb, 0x3f]; +/// CardsDLL's preferred image base; live address = `cbase + (va - IMG_BASE)`. +pub const IMG_BASE: u64 = 0x180000000; + +/// Unconditional store patches, in the Python's insertion order — the order +/// decides the order of the `ENFORCED store patch` log lines. +/// +/// The Python carries no per-site rationale for these eight sites, so none is +/// invented here; the addresses and bytes are reproduced verbatim. +pub const STORE_PATCHES: [(u64, &[u8]); 8] = [ + (0x1800f7fb0, &RET_TRUE), + (0x1800fb850, &RET_TRUE), + (0x180100500, &RET_TRUE), + (0x180013cf0, &RET_TRUE), + (0x180017543, &SHORT_JMP_3F), + (0x180017487, &NOP2), + (0x180017490, &NOP2), + (0x1800175aa, &NOP2), +]; + +/// Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the +/// tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and +/// docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md). +/// +/// When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL +/// FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero +/// category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the +/// \[NULL+0x48\] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves +/// positive-category resolution (EDI>0 branch) while routing zero/negative categories through +/// the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs. +/// +/// CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid +/// ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent) +/// DURING one running FIFA process -- that reproduced the same crash in the confounded run F3. +/// The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE +/// invariant in the client-fix plan). +/// +/// Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f); +/// already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly +/// overwritten), so an unrecognised CardsDLL build is not patched. +/// +/// Tuple layout: `(va, orig, patch)`. `JNZ 0x14869` -> `JG 0x14869`. +pub const STORE_PATCHES_GUARDED: [(u64, &[u8], &[u8]); 1] = + [(0x180014858, &[0x75, 0x0f], &[0x7f, 0x0f])]; + +/// Capability advertised to the launcher/backend once the resolver guard is VERIFIED +/// live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4). +pub const EMPTY_MYPACKS_RESOLVER_CAPABILITY: &str = "fifa17.empty_mypacks_resolver"; +pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1; + +/// The guarded site whose verified enforcement backs the capability above. +pub const RESOLVER_GUARD_VA: u64 = 0x180014858; + +/// Longest patch payload, so the watch loop can compare live bytes on the stack. +pub const MAX_PATCH_LEN: usize = RET_TRUE.len(); + +// Compile-time proof that the watch loop's stack buffers are large enough, so no +// slicing panic is reachable from the patch tables. +const _: () = { + let mut i = 0; + while i < STORE_PATCHES.len() { + assert!(STORE_PATCHES[i].1.len() <= MAX_PATCH_LEN); + i += 1; + } + let mut i = 0; + while i < STORE_PATCHES_GUARDED.len() { + assert!(STORE_PATCHES_GUARDED[i].1.len() <= MAX_PATCH_LEN); + assert!(STORE_PATCHES_GUARDED[i].2.len() <= MAX_PATCH_LEN); + assert!(STORE_PATCHES_GUARDED[i].1.len() == STORE_PATCHES_GUARDED[i].2.len()); + i += 1; + } +}; + +/// Fail-closed decision for a guarded byte patch (see [`STORE_PATCHES_GUARDED`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuardedAction { + /// Live bytes are already the patch; nothing to write. + Noop, + /// Live bytes are the known original; safe to apply. + Patch, + /// Unrecognised CardsDLL build — never blindly overwritten. + Skip, +} + +/// Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuardState { + /// CardsDLL not mapped / guard not yet evaluated. + NotAttempted, + /// Live bytes == patch after enforcement (patch or noop). + Verified, + /// Neither original nor patched ([`GuardedAction::Skip`]). + UnsupportedBuild, + /// The `/proc//mem` write failed. + WriteFailed, + /// Post-write re-read != patch. + VerifyFailed, +} + +impl GuardState { + /// Wire spelling used in the `[store-guard] guard status=…` line the launcher reads. + pub fn as_str(self) -> &'static str { + match self { + GuardState::NotAttempted => "NOT_ATTEMPTED", + GuardState::Verified => "VERIFIED", + GuardState::UnsupportedBuild => "UNSUPPORTED_BUILD", + GuardState::WriteFailed => "WRITE_FAILED", + GuardState::VerifyFailed => "VERIFY_FAILED", + } + } +} + +impl fmt::Display for GuardState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Fail-closed decision for a guarded byte patch. +/// +/// [`GuardedAction::Noop`] when the live bytes are already patched, +/// [`GuardedAction::Patch`] when they are the known original (safe to apply), or +/// [`GuardedAction::Skip`] for anything else — an unrecognised CardsDLL build +/// that must never be blindly overwritten. +pub fn guarded_action(cur: &[u8], orig: &[u8], patch: &[u8]) -> GuardedAction { + if cur == patch { + return GuardedAction::Noop; + } + if cur == orig { + return GuardedAction::Patch; + } + GuardedAction::Skip +} + +/// Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure). +/// +/// Mirrors [`guarded_action`]'s decision, extended with post-write verification so the +/// caller advertises the capability only on VERIFIED. No `/proc` access — unit-testable. +/// +/// - `cur_before == patch` -> VERIFIED (already patched; [`GuardedAction::Noop`]) +/// - `cur_before == orig` -> WRITE_FAILED if the write raised, else VERIFIED when the +/// re-read is patch, else VERIFY_FAILED ([`GuardedAction::Patch`]) +/// - otherwise -> UNSUPPORTED_BUILD ([`GuardedAction::Skip`]) +/// +/// [`GuardState::NotAttempted`] is never returned: it is the state a pid carries before +/// the guard is evaluated at all (CardsDLL not mapped yet), and this function is only +/// reached once live bytes have been read. +pub fn guard_state_after( + cur_before: &[u8], + orig: &[u8], + patch: &[u8], + wrote_ok: bool, + cur_after: &[u8], +) -> GuardState { + if cur_before == patch { + return GuardState::Verified; + } + if cur_before == orig { + if !wrote_ok { + return GuardState::WriteFailed; + } + if cur_after == patch { + return GuardState::Verified; + } + return GuardState::VerifyFailed; + } + GuardState::UnsupportedBuild +} + +/// Live address of an image-relative patch site inside the mapped CardsDLL. +pub fn live_addr(cbase: u64, va: u64) -> u64 { + cbase + (va - IMG_BASE) +} + +/// Lowercase, unseparated hex — the spelling of `bytes.hex()` in the SKIP log line. +pub fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + // Two nibbles, no separator, no allocation per byte. + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + out.push(DIGITS[(b >> 4) as usize] as char); + out.push(DIGITS[(b & 0x0f) as usize] as char); + } + out +} + +/// True when a `/proc//comm` body names the FIFA 17 client. +pub fn is_client_comm(comm: &str) -> bool { + comm.trim() == CLIENT_COMM +} + +/// Pid from a `/proc` directory entry name. +/// +/// The Python globs `/proc/[0-9]*` and then `int()`s the name inside a bare +/// `except`, so a name has to start with a digit *and* be entirely numeric. +pub fn pid_from_proc_entry(name: &str) -> Option { + if !name.as_bytes().first().is_some_and(u8::is_ascii_digit) { + return None; + } + name.parse::().ok() +} + +/// Base address of the first `CardsDLL` mapping in a `/proc//maps` body. +/// +/// Only the first matching line is considered, and a line whose base does not +/// parse yields `None` rather than falling through to the next mapping — the +/// Python's `int(...)` raises inside the `try` that returns `None`. +pub fn parse_cardsdll_base(maps: &str) -> Option { + let line = maps.lines().find(|line| line.contains(CARDSDLL_MARKER))?; + u64::from_str_radix(line.split('-').next()?, 16).ok() +} + +/// `--launcher-pid ` out of the argument list. +/// +/// `Ok(None)` when the flag is absent, `Err(())` when it is present with a +/// missing or non-numeric value (the Python raises `SystemExit`). The value is +/// kept signed and un-clamped so the liveness check behaves exactly like the +/// Python's `os.path.exists(f"/proc/{launcher_pid}")` for odd inputs. +#[allow(clippy::result_unit_err)] +pub fn parse_launcher_pid(args: I) -> Result, ()> +where + I: IntoIterator, + S: AsRef, +{ + let args: Vec = args.into_iter().collect(); + let Some(idx) = args.iter().position(|a| a.as_ref() == "--launcher-pid") else { + return Ok(None); + }; + let value = args.get(idx + 1).ok_or(())?; + value.as_ref().trim().parse::().map(Some).map_err(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ORIG: &[u8] = &[0x75, 0x0f]; + const PATCH: &[u8] = &[0x7f, 0x0f]; + + #[test] + fn constants_match_the_python_spec() { + assert_eq!(GATE2, 0x1461361b0); + assert_eq!(GATE2_ORIG, [0x48, 0x89, 0x5c]); + assert_eq!(GATE2_PATCH, [0x31, 0xc0, 0xc3]); + assert_eq!(GATE1, 0x146132548); + assert_eq!(GATE1_ORIG, [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00]); + assert_eq!(GATE1_PATCH, [0x90, 0x90, 0x90, 0x90, 0x90, 0x90]); + assert_eq!(IMG_BASE, 0x180000000); + assert_eq!(hex(&RET_TRUE), "b801000000c3"); + assert_eq!(hex(&NOP2), "9090"); + + // Site order is part of the log contract, so assert the whole table. + let sites: Vec<(u64, String)> = STORE_PATCHES + .iter() + .map(|(va, data)| (*va, hex(data))) + .collect(); + assert_eq!( + sites, + vec![ + (0x1800f7fb0, "b801000000c3".to_string()), + (0x1800fb850, "b801000000c3".to_string()), + (0x180100500, "b801000000c3".to_string()), + (0x180013cf0, "b801000000c3".to_string()), + (0x180017543, "eb3f".to_string()), + (0x180017487, "9090".to_string()), + (0x180017490, "9090".to_string()), + (0x1800175aa, "9090".to_string()), + ] + ); + + assert_eq!(STORE_PATCHES_GUARDED.len(), 1); + let (va, orig, patch) = STORE_PATCHES_GUARDED[0]; + assert_eq!(va, 0x180014858); + assert_eq!(hex(orig), "750f"); + assert_eq!(hex(patch), "7f0f"); + assert_eq!(RESOLVER_GUARD_VA, va); + assert_eq!(EMPTY_MYPACKS_RESOLVER_CAPABILITY, "fifa17.empty_mypacks_resolver"); + assert_eq!(EMPTY_MYPACKS_RESOLVER_VERSION, 1); + assert_eq!(MAX_PATCH_LEN, 6); + } + + #[test] + fn guarded_action_noops_when_already_patched() { + assert_eq!(guarded_action(PATCH, ORIG, PATCH), GuardedAction::Noop); + } + + #[test] + fn guarded_action_patches_the_known_original() { + assert_eq!(guarded_action(ORIG, ORIG, PATCH), GuardedAction::Patch); + } + + #[test] + fn guarded_action_skips_an_unrecognised_build() { + // Fail-closed: an unknown CardsDLL build is never overwritten. + assert_eq!(guarded_action(&[0x74, 0x0f], ORIG, PATCH), GuardedAction::Skip); + assert_eq!(guarded_action(&[], ORIG, PATCH), GuardedAction::Skip); + assert_eq!(guarded_action(&[0x75], ORIG, PATCH), GuardedAction::Skip); + } + + #[test] + fn guard_state_verified_when_already_patched() { + // wrote_ok / cur_after are irrelevant on this branch. + assert_eq!( + guard_state_after(PATCH, ORIG, PATCH, false, &[]), + GuardState::Verified + ); + } + + #[test] + fn guard_state_verified_after_a_successful_write() { + assert_eq!( + guard_state_after(ORIG, ORIG, PATCH, true, PATCH), + GuardState::Verified + ); + } + + #[test] + fn guard_state_write_failed() { + assert_eq!( + guard_state_after(ORIG, ORIG, PATCH, false, ORIG), + GuardState::WriteFailed + ); + } + + #[test] + fn guard_state_verify_failed() { + // Write reported success but the re-read still shows the original … + assert_eq!( + guard_state_after(ORIG, ORIG, PATCH, true, ORIG), + GuardState::VerifyFailed + ); + // … or could not be re-read at all (the Python's `cur_after = b""`). + assert_eq!( + guard_state_after(ORIG, ORIG, PATCH, true, &[]), + GuardState::VerifyFailed + ); + } + + #[test] + fn guard_state_unsupported_build() { + assert_eq!( + guard_state_after(&[0x74, 0x0f], ORIG, PATCH, true, PATCH), + GuardState::UnsupportedBuild + ); + } + + #[test] + fn guard_state_spellings_are_the_launcher_contract() { + assert_eq!(GuardState::NotAttempted.to_string(), "NOT_ATTEMPTED"); + assert_eq!(GuardState::Verified.to_string(), "VERIFIED"); + assert_eq!(GuardState::UnsupportedBuild.to_string(), "UNSUPPORTED_BUILD"); + assert_eq!(GuardState::WriteFailed.to_string(), "WRITE_FAILED"); + assert_eq!(GuardState::VerifyFailed.to_string(), "VERIFY_FAILED"); + } + + #[test] + fn live_addr_relocates_against_the_image_base() { + assert_eq!(live_addr(0x7f0000000000, 0x180014858), 0x7f0000014858); + assert_eq!(live_addr(IMG_BASE, RESOLVER_GUARD_VA), RESOLVER_GUARD_VA); + } + + #[test] + fn hex_is_lowercase_and_unseparated() { + assert_eq!(hex(&[0x00, 0x0f, 0xa5, 0xff]), "000fa5ff"); + assert_eq!(hex(&[]), ""); + } + + #[test] + fn comm_matches_only_the_client() { + assert!(is_client_comm("FIFA17.exe\n")); + assert!(is_client_comm("FIFA17.exe")); + assert!(!is_client_comm("fifa17.exe\n")); + assert!(!is_client_comm("FIFA17.exe.bak\n")); + assert!(!is_client_comm("wineserver\n")); + assert!(!is_client_comm("")); + } + + #[test] + fn proc_entry_names_yield_only_numeric_pids() { + assert_eq!(pid_from_proc_entry("1"), Some(1)); + assert_eq!(pid_from_proc_entry("41234"), Some(41234)); + assert_eq!(pid_from_proc_entry("self"), None); + assert_eq!(pid_from_proc_entry("1abc"), None); + assert_eq!(pid_from_proc_entry("+7"), None); + assert_eq!(pid_from_proc_entry(""), None); + } + + #[test] + fn cardsdll_base_is_the_first_matching_mapping() { + let maps = concat!( + "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n", + "7f2a11c00000-7f2a11c9c000 r-xp 00000000 08:02 44 /home/u/CardsDLL_Win64_retail.dll\n", + "7f2a12000000-7f2a12001000 r--p 00000000 08:02 45 /home/u/CardsDLL_second.dll\n", + ); + assert_eq!(parse_cardsdll_base(maps), Some(0x7f2a11c00000)); + } + + #[test] + fn cardsdll_base_absent_or_unparsable() { + assert_eq!(parse_cardsdll_base(""), None); + let no_dll = "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n"; + assert_eq!(parse_cardsdll_base(no_dll), None); + // A CardsDLL line whose base is not hex: fail, do not fall through. + let broken = concat!( + "zzzz-140001000 r--p 00000000 08:02 12 /home/u/CardsDLL.dll\n", + "7f2a11c00000-7f2a11c9c000 r-xp 0 08:02 44 /home/u/CardsDLL.dll\n", + ); + assert_eq!(parse_cardsdll_base(broken), None); + } + + #[test] + fn launcher_pid_parsing() { + assert_eq!(parse_launcher_pid(Vec::::new()), Ok(None)); + assert_eq!(parse_launcher_pid(["--other", "3"]), Ok(None)); + assert_eq!(parse_launcher_pid(["--launcher-pid", "4242"]), Ok(Some(4242))); + assert_eq!( + parse_launcher_pid(["-x", "--launcher-pid", "7", "--launcher-pid", "9"]), + Ok(Some(7)) + ); + // Falsy in the Python (`if launcher_pid and ...`): parsed, never watched. + assert_eq!(parse_launcher_pid(["--launcher-pid", "0"]), Ok(Some(0))); + assert_eq!(parse_launcher_pid(["--launcher-pid", "-5"]), Ok(Some(-5))); + assert_eq!(parse_launcher_pid(["--launcher-pid"]), Err(())); + assert_eq!(parse_launcher_pid(["--launcher-pid", "abc"]), Err(())); + assert_eq!(parse_launcher_pid(["--launcher-pid", ""]), Err(())); + } +} diff --git a/openfut-autopatch/src/localtime.rs b/openfut-autopatch/src/localtime.rs new file mode 100644 index 0000000..2f9a1be --- /dev/null +++ b/openfut-autopatch/src/localtime.rs @@ -0,0 +1,304 @@ +//! Wall-clock `HH:MM:SS` in local time, from std alone. +//! +//! The Python logs `time.strftime('%H:%M:%S')`, i.e. *local* time, and the +//! launcher interleaves these lines with its own log, so UTC would misreport the +//! timestamps by the machine's offset. std has no local-time support, so the +//! UTC offset is taken from the system's TZif database (`TZ` or `/etc/localtime`), +//! parsed here — a bounded, well-specified format (RFC 8536). +//! +//! LIMITATION, stated rather than hidden: only the TZif transition table is +//! evaluated, not the trailing POSIX-TZ footer string. With the "fat" tzdata +//! that Debian-family systems ship, transitions run to 2037, so the offset — +//! including DST — is exact. Two cases fall back to the last known transition's +//! offset (so a DST-observing zone could read one hour off) and one falls back +//! to UTC: +//! * "slim" tzdata, or dates past the last transition -> last transition; +//! * `TZ` holding a bare POSIX rule (`EST5EDT`) with no such zone file, or an +//! unreadable/corrupt zone file -> UTC. +//! The timestamp is diagnostic; no line the launcher parses carries a time. + +use std::fs; +use std::sync::LazyLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Resolved UTC offsets over time: an offset before the first transition, then +/// `(transition instant, offset from that instant on)` in ascending order. +#[derive(Debug, PartialEq, Eq)] +pub struct TzData { + initial: i32, + transitions: Vec<(i64, i32)>, +} + +impl TzData { + /// UTC offset in seconds applying at `unix_secs`. + pub fn offset_at(&self, unix_secs: i64) -> i32 { + let idx = self + .transitions + .partition_point(|(start, _)| *start <= unix_secs); + if idx == 0 { + self.initial + } else { + self.transitions[idx - 1].1 + } + } +} + +/// `[HH:MM:SS]`-worthy time-of-day for a Unix timestamp at a given UTC offset. +pub fn hms(unix_secs: i64, utc_offset_secs: i32) -> (u32, u32, u32) { + let local = unix_secs + i64::from(utc_offset_secs); + // rem_euclid keeps pre-epoch and negative-offset instants on a sane clock. + let day = local.rem_euclid(86_400); + ((day / 3600) as u32, (day % 3600 / 60) as u32, (day % 60) as u32) +} + +/// Current local time of day, `(hour, minute, second)`. +pub fn now_hms() -> (u32, u32, u32) { + let unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + // Before 1970 the clock is broken anyway; keep logging rather than panic. + .unwrap_or(0); + hms(unix, local_offset_at(unix)) +} + +/// UTC offset in seconds for `unix_secs`, or 0 when no zone data is usable. +/// +/// The zone file is read and parsed once; the offset is then recomputed per call +/// so a DST transition during a long-running session is picked up. +pub fn local_offset_at(unix_secs: i64) -> i32 { + static TZ: LazyLock> = LazyLock::new(load_system_tz); + TZ.as_ref().map_or(0, |tz| tz.offset_at(unix_secs)) +} + +/// Read and parse the zone file named by `TZ`, else `/etc/localtime`. +fn load_system_tz() -> Option { + let path = match std::env::var("TZ") { + Ok(tz) if !tz.is_empty() => { + // glibc accepts a leading ':' and either an absolute path or a name + // relative to the zoneinfo directory. + let name = tz.strip_prefix(':').unwrap_or(&tz); + if name.starts_with('/') { + name.to_string() + } else { + format!("/usr/share/zoneinfo/{name}") + } + } + _ => "/etc/localtime".to_string(), + }; + parse_tzif(&fs::read(path).ok()?) +} + +/// Parse a TZif (RFC 8536) file into resolved offsets. +/// +/// For version 2+ files the 64-bit data block is used; the legacy 32-bit block +/// is skipped, because modern tzdata leaves it minimal. +pub fn parse_tzif(bytes: &[u8]) -> Option { + let (version, counts) = parse_header(bytes, 0)?; + if version >= b'2' { + // Skip the v1 header + v1 data block, then re-read the 64-bit header. + let v1_end = 44 + data_block_len(&counts, 4)?; + let (_, counts64) = parse_header(bytes, v1_end)?; + parse_data(bytes, v1_end + 44, &counts64, 8) + } else { + parse_data(bytes, 44, &counts, 4) + } +} + +/// `(isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt)`. +type Counts = [u32; 6]; + +fn parse_header(bytes: &[u8], off: usize) -> Option<(u8, Counts)> { + let head = bytes.get(off..off + 44)?; + if &head[0..4] != b"TZif" { + return None; + } + let version = head[4]; + let mut counts = [0u32; 6]; + for (i, slot) in counts.iter_mut().enumerate() { + let at = 20 + i * 4; + *slot = u32::from_be_bytes(head[at..at + 4].try_into().ok()?); + } + Some((version, counts)) +} + +/// Byte length of a data block with `time_len`-wide transition times. +fn data_block_len(counts: &Counts, time_len: usize) -> Option { + let [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt] = counts.map(|c| c as usize); + Some( + timecnt * time_len + + timecnt + + typecnt * 6 + + charcnt + + leapcnt * (time_len + 4) + + isstdcnt + + isutcnt, + ) +} + +fn parse_data(bytes: &[u8], off: usize, counts: &Counts, time_len: usize) -> Option { + let [_, _, _, timecnt, typecnt, _] = counts.map(|c| c as usize); + if typecnt == 0 { + return None; + } + let block = bytes.get(off..off + data_block_len(counts, time_len)?)?; + + let times = block.get(..timecnt * time_len)?; + let type_idx = block.get(timecnt * time_len..timecnt * time_len + timecnt)?; + let ttinfo_at = timecnt * time_len + timecnt; + let ttinfo = block.get(ttinfo_at..ttinfo_at + typecnt * 6)?; + + // utoff + isdst per local-time type. + let mut offsets = Vec::with_capacity(typecnt); + for i in 0..typecnt { + let rec = &ttinfo[i * 6..i * 6 + 6]; + let utoff = i32::from_be_bytes(rec[0..4].try_into().ok()?); + offsets.push((utoff, rec[4] != 0)); + } + + // Before the first transition, RFC 8536 says to use the first non-DST type, + // falling back to the first type. This is also the whole answer for a + // fixed-offset zone (typecnt 1, timecnt 0), e.g. Etc/UTC. + let initial = offsets + .iter() + .find(|(_, isdst)| !*isdst) + .unwrap_or(&offsets[0]) + .0; + + let mut transitions = Vec::with_capacity(timecnt); + for i in 0..timecnt { + let raw = ×[i * time_len..(i + 1) * time_len]; + let at = if time_len == 8 { + i64::from_be_bytes(raw.try_into().ok()?) + } else { + i64::from(i32::from_be_bytes(raw.try_into().ok()?)) + }; + let (utoff, _) = *offsets.get(*type_idx.get(i)? as usize)?; + transitions.push((at, utoff)); + } + + Some(TzData { + initial, + transitions, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal TZif builder: `transitions` are `(instant, type index)`. + fn tzif(version: u8, types: &[(i32, bool)], transitions: &[(i64, u8)]) -> Vec { + fn block(types: &[(i32, bool)], transitions: &[(i64, u8)], time_len: usize) -> Vec { + let mut out = Vec::new(); + for (at, _) in transitions { + if time_len == 8 { + out.extend_from_slice(&at.to_be_bytes()); + } else { + out.extend_from_slice(&(*at as i32).to_be_bytes()); + } + } + for (_, idx) in transitions { + out.push(*idx); + } + for (utoff, isdst) in types { + out.extend_from_slice(&utoff.to_be_bytes()); + out.push(u8::from(*isdst)); + out.push(0); // abbreviation index + } + out.push(0); // one NUL abbreviation byte + out + } + fn header(version: u8, types: usize, transitions: usize) -> Vec { + let mut out = Vec::from(*b"TZif"); + out.push(version); + out.extend_from_slice(&[0u8; 15]); + for count in [0u32, 0, 0, transitions as u32, types as u32, 1] { + out.extend_from_slice(&count.to_be_bytes()); + } + out + } + let mut out = header(version, types.len(), transitions.len()); + if version >= b'2' { + // Modern "slim-ish" shape: an empty v1 block, then the 64-bit block. + out.truncate(0); + out.extend(header(version, types.len(), 0)); + out.extend(block(types, &[], 4)); + out.extend(header(version, types.len(), transitions.len())); + out.extend(block(types, transitions, 8)); + } else { + out.extend(block(types, transitions, 4)); + } + out + } + + #[test] + fn fixed_offset_zone_has_no_transitions() { + let tz = parse_tzif(&tzif(b'2', &[(0, false)], &[])).unwrap(); + assert_eq!(tz.offset_at(0), 0); + assert_eq!(tz.offset_at(1_800_000_000), 0); + + let kolkata = parse_tzif(&tzif(b'2', &[(19_800, false)], &[])).unwrap(); + assert_eq!(kolkata.offset_at(1_800_000_000), 19_800); + } + + #[test] + fn dst_transitions_select_the_right_offset() { + // CET/CEST with two transitions. + let tz = parse_tzif(&tzif( + b'2', + &[(3600, false), (7200, true)], + &[(1_000_000_000, 1), (1_100_000_000, 0)], + )) + .unwrap(); + assert_eq!(tz.offset_at(999_999_999), 3600); // before the first transition + assert_eq!(tz.offset_at(1_000_000_000), 7200); // exactly at it + assert_eq!(tz.offset_at(1_050_000_000), 7200); + assert_eq!(tz.offset_at(1_100_000_000), 3600); + assert_eq!(tz.offset_at(i64::MAX), 3600); // past the table: last known + } + + #[test] + fn version_1_files_parse_from_the_32_bit_block() { + let tz = parse_tzif(&tzif(b'\0', &[(-18_000, false)], &[(100, 0)])).unwrap(); + assert_eq!(tz.offset_at(0), -18_000); + assert_eq!(tz.offset_at(1_000), -18_000); + } + + #[test] + fn initial_offset_skips_a_leading_dst_type() { + let tz = parse_tzif(&tzif(b'2', &[(7200, true), (3600, false)], &[])).unwrap(); + assert_eq!(tz.offset_at(0), 3600); + } + + #[test] + fn garbage_is_rejected_rather_than_guessed() { + assert_eq!(parse_tzif(b""), None); + assert_eq!(parse_tzif(b"not a tzif file at all, truncated"), None); + let mut truncated = tzif(b'2', &[(3600, false)], &[(1, 0)]); + truncated.truncate(truncated.len() - 5); + assert_eq!(parse_tzif(&truncated), None); + // Well-formed header claiming zero local-time types is unusable. + assert_eq!(parse_tzif(&tzif(b'2', &[], &[])), None); + } + + #[test] + fn hms_matches_known_instants() { + assert_eq!(hms(0, 0), (0, 0, 0)); + // 2026-08-18T04:52:08Z + assert_eq!(hms(1_787_028_728, 0), (4, 52, 8)); + // …the same instant at +02:00 and at -05:00 (the latter is the day before). + assert_eq!(hms(1_787_028_728, 7200), (6, 52, 8)); + assert_eq!(hms(1_787_028_728, -18_000), (23, 52, 8)); + // Offsets that cross midnight in either direction stay on the clock. + assert_eq!(hms(86_399, 1), (0, 0, 0)); + assert_eq!(hms(0, -1), (23, 59, 59)); + } + + #[test] + fn the_system_zone_resolves_to_a_plausible_offset() { + // Whatever this machine's zone is, the offset must be a real one. + let offset = local_offset_at(1_786_697_528); + assert!((-50_400..=50_400).contains(&offset), "implausible {offset}"); + assert_eq!(offset % 60, 0); + } +} diff --git a/openfut-autopatch/src/logging.rs b/openfut-autopatch/src/logging.rs new file mode 100644 index 0000000..cd85f4d --- /dev/null +++ b/openfut-autopatch/src/logging.rs @@ -0,0 +1,103 @@ +//! `[HH:MM:SS] ` to stdout (flushed per line) and appended to the log file. +//! +//! The launcher pipes this process's stdout into its own log buffer and *parses* +//! some of these lines, so the per-line flush and the line shapes are a contract, +//! not cosmetics. + +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::PathBuf; + +use crate::localtime; +use crate::procmem; + +/// Environment override for the log file path. +pub const LOG_PATH_ENV: &str = "OPENFUT_AUTOPATCH_LOG"; + +pub struct Logger { + path: PathBuf, +} + +impl Logger { + /// `$OPENFUT_AUTOPATCH_LOG`, defaulting to `/tmp/openfut-autopatch-.log`. + /// + /// Resolved once at startup, exactly like the Python's module-level `LOG`, so + /// a later environment change cannot move the file mid-run. + pub fn from_env() -> io::Result { + let path = match std::env::var_os(LOG_PATH_ENV) { + Some(path) if !path.is_empty() => PathBuf::from(path), + _ => PathBuf::from(format!( + "/tmp/openfut-autopatch-{}.log", + procmem::current_uid()? + )), + }; + Ok(Self { path }) + } + + pub fn path(&self) -> &std::path::Path { + &self.path + } + + /// Emit one line. Timestamped in local time. + pub fn log(&self, msg: &str) { + let (h, m, s) = localtime::now_hms(); + let line = format!("[{h:02}:{m:02}:{s:02}] {msg}"); + + let mut stdout = io::stdout().lock(); + // Ignore a broken pipe: the launcher may have stopped reading, and dying + // here would leave FIFA's store patches unenforced. + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + drop(stdout); + + if let Err(e) = self.append(&line) { + // The Python lets a failing log write kill the process. Patching the + // running client matters more than the transcript, so report once to + // stderr (the launcher captures it too) and carry on. + let _ = writeln!(io::stderr(), "autopatch: cannot append to {}: {e}", self.path.display()); + } + } + + fn append(&self, line: &str) -> io::Result<()> { + let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_a_timestamped_line_to_the_configured_path() { + let path = std::env::temp_dir().join(format!( + "openfut-autopatch-test-{}.log", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let logger = Logger { + path: path.clone(), + }; + logger.log("pid 4242: PATCHED cert gates"); + logger.log("second line"); + + let body = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!(lines.len(), 2); + assert_eq!(&lines[0][..1], "["); + assert_eq!(&lines[0][3..4], ":"); + assert_eq!(&lines[0][6..7], ":"); + assert_eq!(&lines[0][9..], "] pid 4242: PATCHED cert gates"); + assert!(lines[1].ends_with("] second line")); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn a_bad_log_path_does_not_kill_the_patcher() { + let logger = Logger { + path: PathBuf::from("/proc/definitely/not/writable.log"), + }; + logger.log("still running"); + } +} diff --git a/openfut-autopatch/src/main.rs b/openfut-autopatch/src/main.rs new file mode 100644 index 0000000..f9e25bb --- /dev/null +++ b/openfut-autopatch/src/main.rs @@ -0,0 +1,97 @@ +//! Watch for a (re)launched FIFA17.exe and auto-apply the ProtoSSL cert patches +//! the moment its unpacked code is mapped, plus the CardsDLL FUT store patches. +//! Idempotent; keeps watching across relaunches. +//! +//! Port of `fifa17-recon/tools/autopatch.py`, tick for tick: cert gates once per +//! pid, store patches re-enforced every second (the game rewrites those sites), +//! then the resolver-guard capability reported once per pid. The passes +//! themselves live in `openfut_autopatch::patch`; this is the loop and the CLI. + +use std::collections::HashSet; +use std::process::ExitCode; +use std::thread::sleep; +use std::time::Duration; + +use openfut_autopatch::patch::{cert_pass, enforce_store_patches}; +use openfut_autopatch::procmem::{self, ProcMem}; +use openfut_autopatch::{parse_launcher_pid, Logger}; + +/// One tick per second, as in the Python. +const TICK: Duration = Duration::from_secs(1); + +/// Per-pid bookkeeping so each of these lines is logged exactly once per client +/// process (the Python's three module-level sets). +#[derive(Default)] +struct Seen { + patched: HashSet, + store_patched: HashSet, + guard_reported: HashSet, +} + +fn main() -> ExitCode { + let launcher_pid = match parse_launcher_pid(std::env::args().skip(1)) { + Ok(pid) => pid, + Err(()) => { + eprintln!("invalid --launcher-pid"); + return ExitCode::FAILURE; + } + }; + + let logger = match Logger::from_env() { + Ok(logger) => logger, + Err(e) => { + eprintln!("cannot resolve the autopatch log path: {e}"); + return ExitCode::FAILURE; + } + }; + + logger.log("=== AUTOPATCH watching for FIFA17.exe ==="); + + let mut seen = Seen::default(); + loop { + // `if launcher_pid and not os.path.exists(...)`: pid 0 is falsy in the + // Python, so `--launcher-pid 0` parses but is never watched. + if let Some(pid) = launcher_pid { + if pid != 0 && !procmem::pid_alive(pid) { + logger.log(&format!("launcher pid {pid} exited; stopping autopatch")); + break; + } + } + + for pid in procmem::find_pids() { + let mem = ProcMem::new(pid); + + if !seen.patched.contains(&pid) + && !cert_pass(&mem, &mut |line| logger.log(line), &mut seen.patched) + { + // Code not mapped yet: nothing else to do for this pid this tick. + continue; + } + + // Store patches are enforced on EVERY tick, not once: the game + // rewrites these sites, so a single pass at startup does not hold. + let Some(cbase) = procmem::cardsdll_base(pid) else { + continue; + }; + match enforce_store_patches( + &mem, + cbase, + &mut |line| logger.log(line), + &mut seen.guard_reported, + ) { + Ok(()) => { + if seen.store_patched.insert(pid) { + logger.log(&format!( + "pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}" + )); + } + } + Err(e) => logger.log(&format!("pid {pid}: store patch write failed: {e}")), + } + } + + sleep(TICK); + } + + ExitCode::SUCCESS +} diff --git a/openfut-autopatch/src/patch.rs b/openfut-autopatch/src/patch.rs new file mode 100644 index 0000000..f4dbd95 --- /dev/null +++ b/openfut-autopatch/src/patch.rs @@ -0,0 +1,478 @@ +//! The two enforcement passes: ProtoSSL cert gates (once per pid) and the +//! CardsDLL store patches (every tick). +//! +//! Both are written against the [`Memory`] trait rather than `/proc` directly, so +//! the log lines and their order — which the launcher reads — are unit-testable +//! without a live FIFA client. + +use std::collections::HashSet; +use std::io; + +use crate::{ + guard_state_after, guarded_action, hex, live_addr, GuardState, GuardedAction, + EMPTY_MYPACKS_RESOLVER_CAPABILITY, EMPTY_MYPACKS_RESOLVER_VERSION, GATE1, GATE1_ORIG, + GATE1_PATCH, GATE2, GATE2_ORIG, GATE2_PATCH, MAX_PATCH_LEN, RESOLVER_GUARD_VA, STORE_PATCHES, + STORE_PATCHES_GUARDED, +}; + +/// Byte-level access to one client process's address space. +pub trait Memory { + /// The pid being patched; it appears in every log line. + fn pid(&self) -> u32; + /// Fill `buf` from virtual address `va`. An error means "not mapped (yet)". + fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()>; + /// Write `data` at virtual address `va`. + fn write(&self, va: u64, data: &[u8]) -> io::Result<()>; +} + +/// Apply the two ProtoSSL cert gates, once per pid. +/// +/// Returns `false` when the gates could not be read — the packer has not mapped +/// that code yet, which is the Python's `continue`, not an error to report. +pub fn cert_pass( + mem: &M, + log: &mut impl FnMut(&str), + patched: &mut HashSet, +) -> bool { + let pid = mem.pid(); + // Sized from the patterns themselves; the initial contents are overwritten by + // the reads and are never compared unless both reads succeed. + let mut g2 = GATE2_ORIG; + let mut g1 = GATE1_ORIG; + if mem.read(GATE2, &mut g2).is_err() || mem.read(GATE1, &mut g1).is_err() { + return false; + } + + if g2 == GATE2_PATCH && g1 == GATE1_PATCH { + log(&format!("pid {pid}: cert gates already patched")); + patched.insert(pid); + } else if g2 == GATE2_ORIG && g1 == GATE1_ORIG { + match mem + .write(GATE2, &GATE2_PATCH) + .and_then(|()| mem.write(GATE1, &GATE1_PATCH)) + { + Ok(()) => { + log(&format!("pid {pid}: PATCHED cert gates")); + patched.insert(pid); + } + Err(e) => log(&format!("pid {pid}: cert patch write failed: {e}")), + } + } + // Anything else is a build we do not recognise: left alone, as in the Python. + true +} + +/// Re-apply every store patch whose live bytes have drifted, then the guarded +/// patch, then report the resolver-guard capability once per pid. +/// +/// An `Err` is a read or write that failed outside the guarded site's own +/// handling; it aborts the rest of this pid's pass for this tick, exactly like +/// the Python's enclosing `try`. +pub fn enforce_store_patches( + mem: &M, + cbase: u64, + log: &mut impl FnMut(&str), + guard_reported: &mut HashSet, +) -> io::Result<()> { + let pid = mem.pid(); + + for (va, data) in STORE_PATCHES { + let live = live_addr(cbase, va); + let mut buf = [0u8; MAX_PATCH_LEN]; + let cur = &mut buf[..data.len()]; + mem.read(live, cur)?; + if cur != data { + mem.write(live, data)?; + log(&format!("pid {pid}: ENFORCED store patch @ {live:#x}")); + } + } + + for (va, orig, patch) in STORE_PATCHES_GUARDED { + let live = live_addr(cbase, va); + let mut before = [0u8; MAX_PATCH_LEN]; + mem.read(live, &mut before[..patch.len()])?; + let cur = &before[..patch.len()]; + + let mut wrote_ok = true; + // The Python starts with `cur_after = cur`, which only matters on the + // branches that never re-read. + let mut after = [0u8; MAX_PATCH_LEN]; + after[..patch.len()].copy_from_slice(cur); + let mut after_len = patch.len(); + + match guarded_action(cur, orig, patch) { + GuardedAction::Patch => { + match mem.write(live, patch) { + Ok(()) => log(&format!( + "pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)" + )), + Err(e) => { + wrote_ok = false; + log(&format!( + "pid {pid}: guarded patch write failed @ {live:#x}: {e}" + )); + } + } + if wrote_ok && mem.read(live, &mut after[..patch.len()]).is_err() { + // The Python's `cur_after = b""`: unverifiable, so not verified. + after_len = 0; + } + } + GuardedAction::Skip => log(&format!( + "pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {} (build mismatch)", + hex(cur) + )), + // Already patched; nothing to write. + GuardedAction::Noop => {} + } + + if va == RESOLVER_GUARD_VA && !guard_reported.contains(&pid) { + let state = guard_state_after(cur, orig, patch, wrote_ok, &after[..after_len]); + if state == GuardState::Verified { + // PARSED BY THE LAUNCHER (fifa17_capability::parse_capability_line): + // this line must keep both `verified capability` and the + // `fifa17.empty_mypacks_resolver=` token verbatim. + log(&format!( + "[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}" + )); + } else { + log(&format!( + "[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)" + )); + } + guard_reported.insert(pid); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::BTreeMap; + + const CBASE: u64 = 0x7f2a11c00000; + const GUARD_LIVE: u64 = CBASE + 0x14858; + + /// Sparse fake address space: an unmapped byte reads as `NotFound`, mirroring + /// `/proc//mem` refusing an address the packer has not produced yet. + struct FakeMemory { + bytes: RefCell>, + /// Writes to these addresses fail. + fail_writes: Vec, + /// Writes to these addresses report success but change nothing (the + /// VERIFY_FAILED shape). + swallow_writes: Vec, + /// Reads of these addresses fail even when mapped. + fail_reads: Vec, + } + + impl FakeMemory { + fn new() -> Self { + Self { + bytes: RefCell::new(BTreeMap::new()), + fail_writes: Vec::new(), + swallow_writes: Vec::new(), + fail_reads: Vec::new(), + } + } + + fn map(self, va: u64, bytes: &[u8]) -> Self { + { + let mut mem = self.bytes.borrow_mut(); + for (i, b) in bytes.iter().enumerate() { + mem.insert(va + i as u64, *b); + } + } + self + } + + /// Every store-patch site mapped with filler that is neither the patch + /// nor (for the guarded site) the original. + fn with_store_sites(mut self, filler: u8) -> Self { + for (va, data) in STORE_PATCHES { + self = self.map(live_addr(CBASE, va), &vec![filler; data.len()]); + } + for (va, _, patch) in STORE_PATCHES_GUARDED { + self = self.map(live_addr(CBASE, va), &vec![filler; patch.len()]); + } + self + } + + fn at(&self, va: u64, len: usize) -> Vec { + let mem = self.bytes.borrow(); + (0..len as u64).map(|i| mem[&(va + i)]).collect() + } + } + + impl Memory for FakeMemory { + fn pid(&self) -> u32 { + 4242 + } + + fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> { + if self.fail_reads.contains(&va) { + return Err(io::Error::from(io::ErrorKind::PermissionDenied)); + } + let mem = self.bytes.borrow(); + for (i, slot) in buf.iter_mut().enumerate() { + *slot = *mem + .get(&(va + i as u64)) + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; + } + Ok(()) + } + + fn write(&self, va: u64, data: &[u8]) -> io::Result<()> { + if self.fail_writes.contains(&va) { + return Err(io::Error::from(io::ErrorKind::PermissionDenied)); + } + if self.swallow_writes.contains(&va) { + return Ok(()); + } + let mut mem = self.bytes.borrow_mut(); + for (i, b) in data.iter().enumerate() { + mem.insert(va + i as u64, *b); + } + Ok(()) + } + } + + /// Collects log lines so the contract strings can be asserted verbatim. + #[derive(Default)] + struct Lines(Vec); + + impl Lines { + fn sink(&mut self) -> impl FnMut(&str) + '_ { + |line: &str| self.0.push(line.to_string()) + } + } + + #[test] + fn cert_pass_defers_while_the_code_is_not_mapped() { + let mem = FakeMemory::new(); + let mut lines = Lines::default(); + let mut patched = HashSet::new(); + assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched)); + assert!(lines.0.is_empty(), "{:?}", lines.0); + assert!(patched.is_empty()); + } + + #[test] + fn cert_pass_defers_when_only_the_first_gate_is_mapped() { + let mem = FakeMemory::new().map(GATE2, &GATE2_ORIG); + let mut lines = Lines::default(); + let mut patched = HashSet::new(); + assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched)); + assert!(lines.0.is_empty()); + assert!(patched.is_empty()); + } + + #[test] + fn cert_pass_writes_both_gates_once() { + let mem = FakeMemory::new() + .map(GATE2, &GATE2_ORIG) + .map(GATE1, &GATE1_ORIG); + let mut lines = Lines::default(); + let mut patched = HashSet::new(); + assert!(cert_pass(&mem, &mut lines.sink(), &mut patched)); + assert_eq!(lines.0, vec!["pid 4242: PATCHED cert gates"]); + assert_eq!(mem.at(GATE2, 3), GATE2_PATCH); + assert_eq!(mem.at(GATE1, 6), GATE1_PATCH); + assert!(patched.contains(&4242)); + } + + #[test] + fn cert_pass_recognises_an_already_patched_client() { + let mem = FakeMemory::new() + .map(GATE2, &GATE2_PATCH) + .map(GATE1, &GATE1_PATCH); + let mut lines = Lines::default(); + let mut patched = HashSet::new(); + assert!(cert_pass(&mem, &mut lines.sink(), &mut patched)); + assert_eq!(lines.0, vec!["pid 4242: cert gates already patched"]); + assert!(patched.contains(&4242)); + } + + #[test] + fn cert_pass_reports_a_write_failure_and_stays_unpatched() { + let mut mem = FakeMemory::new() + .map(GATE2, &GATE2_ORIG) + .map(GATE1, &GATE1_ORIG); + mem.fail_writes.push(GATE2); + let mut lines = Lines::default(); + let mut patched = HashSet::new(); + assert!(cert_pass(&mem, &mut lines.sink(), &mut patched)); + assert_eq!(lines.0.len(), 1); + assert!( + lines.0[0].starts_with("pid 4242: cert patch write failed: "), + "{}", + lines.0[0] + ); + // Not recorded as patched, so the next tick tries again. + assert!(patched.is_empty()); + assert_eq!(mem.at(GATE2, 3), GATE2_ORIG); + } + + #[test] + fn cert_pass_leaves_an_unrecognised_build_alone() { + let mem = FakeMemory::new() + .map(GATE2, &[0x55, 0x48, 0x89]) + .map(GATE1, &[0x0f, 0x84, 0x76, 0x01, 0x00, 0x00]); + let mut lines = Lines::default(); + let mut patched = HashSet::new(); + assert!(cert_pass(&mem, &mut lines.sink(), &mut patched)); + assert!(lines.0.is_empty(), "{:?}", lines.0); + assert!(patched.is_empty()); + assert_eq!(mem.at(GATE2, 3), [0x55, 0x48, 0x89]); + } + + #[test] + fn store_pass_enforces_every_site_in_table_order_then_advertises() { + let mem = FakeMemory::new().with_store_sites(0xcc); + let mut lines = Lines::default(); + let mut reported = HashSet::new(); + enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap(); + + let mut expected: Vec = STORE_PATCHES + .iter() + .map(|(va, _)| { + let live = live_addr(CBASE, *va); + format!("pid 4242: ENFORCED store patch @ {live:#x}") + }) + .collect(); + // 0xcc is neither the original nor the patch: fail-closed SKIP, and the + // capability is withheld. + expected.push(format!( + "pid 4242: SKIP guarded patch @ {GUARD_LIVE:#x}: unexpected cccc (build mismatch)" + )); + expected.push( + "[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)" + .to_string(), + ); + assert_eq!(lines.0, expected); + assert!(reported.contains(&4242)); + + for (va, data) in STORE_PATCHES { + assert_eq!(mem.at(live_addr(CBASE, va), data.len()), data); + } + // The guarded site was NOT overwritten. + assert_eq!(mem.at(GUARD_LIVE, 2), [0xcc, 0xcc]); + } + + #[test] + fn store_pass_patches_the_guard_and_advertises_the_capability() { + let mem = FakeMemory::new() + .with_store_sites(0xcc) + .map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1); + let mut lines = Lines::default(); + let mut reported = HashSet::new(); + enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap(); + + assert_eq!( + lines.0[lines.0.len() - 2], + format!( + "pid 4242: ENFORCED guarded store patch @ {GUARD_LIVE:#x} (JNZ->JG, empty My Packs)" + ) + ); + assert_eq!( + lines.0[lines.0.len() - 1], + "[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242" + ); + assert_eq!(mem.at(GUARD_LIVE, 2), [0x7f, 0x0f]); + + // Second tick: everything already enforced, and the capability is not + // re-advertised. + let mut lines = Lines::default(); + enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap(); + assert!(lines.0.is_empty(), "{:?}", lines.0); + } + + #[test] + fn store_pass_verifies_an_already_patched_guard() { + let mem = FakeMemory::new() + .with_store_sites(0xcc) + .map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2); + let mut lines = Lines::default(); + let mut reported = HashSet::new(); + enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap(); + assert_eq!( + lines.0.last().unwrap(), + "[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242" + ); + } + + #[test] + fn store_pass_reports_a_guarded_write_failure_without_aborting_the_tick() { + let mut mem = FakeMemory::new() + .with_store_sites(0xcc) + .map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1); + mem.fail_writes.push(GUARD_LIVE); + let mut lines = Lines::default(); + let mut reported = HashSet::new(); + // The guarded write failure is handled inline, so the pass still succeeds. + enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap(); + assert!( + lines.0[lines.0.len() - 2] + .starts_with(&format!("pid 4242: guarded patch write failed @ {GUARD_LIVE:#x}: ")), + "{}", + lines.0[lines.0.len() - 2] + ); + assert_eq!( + lines.0[lines.0.len() - 1], + "[store-guard] guard status=WRITE_FAILED fifa_pid=4242 (no capability advertised)" + ); + } + + #[test] + fn store_pass_withholds_the_capability_when_verification_fails() { + let mut mem = FakeMemory::new() + .with_store_sites(0xcc) + .map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1); + // Write reported OK, memory unchanged: the re-read still shows the original. + mem.swallow_writes.push(GUARD_LIVE); + let mut lines = Lines::default(); + let mut reported = HashSet::new(); + enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap(); + assert_eq!( + lines.0[lines.0.len() - 1], + "[store-guard] guard status=VERIFY_FAILED fifa_pid=4242 (no capability advertised)" + ); + } + + #[test] + fn store_pass_aborts_the_tick_when_a_site_is_not_mapped() { + // CardsDLL is mapped but this tick catches a site mid-unpack. + let mem = FakeMemory::new(); + let mut lines = Lines::default(); + let mut reported = HashSet::new(); + let err = enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + assert!(lines.0.is_empty()); + // Nothing advertised, so the next tick re-evaluates the guard. + assert!(reported.is_empty()); + } + + #[test] + fn store_pass_skips_writing_sites_that_already_hold_the_patch() { + let mut mem = FakeMemory::new().with_store_sites(0xcc); + for (va, data) in STORE_PATCHES { + mem = mem.map(live_addr(CBASE, va), data); + } + mem = mem.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2); + // Any write at all would fail these sites, proving none is attempted. + mem.fail_writes + .extend(STORE_PATCHES.iter().map(|(va, _)| live_addr(CBASE, *va))); + mem.fail_writes.push(GUARD_LIVE); + + let mut lines = Lines::default(); + let mut reported = HashSet::new(); + enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap(); + assert_eq!( + lines.0, + vec!["[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"] + ); + } +} diff --git a/openfut-autopatch/src/procmem.rs b/openfut-autopatch/src/procmem.rs new file mode 100644 index 0000000..eb8ab83 --- /dev/null +++ b/openfut-autopatch/src/procmem.rs @@ -0,0 +1,160 @@ +//! `/proc` access: finding the client, locating CardsDLL, and positioned reads +//! and writes against `/proc//mem`. +//! +//! Positioned I/O (`pread`/`pwrite`) is used rather than seek+read: a 64-bit +//! virtual address is passed straight through as the file offset, so nothing +//! depends on a shared file cursor. + +use std::fs::{self, File, OpenOptions}; +use std::io; +use std::os::unix::fs::FileExt; + +use crate::patch::Memory; +use crate::{is_client_comm, parse_cardsdll_base, pid_from_proc_entry}; + +/// Pids whose `comm` is exactly `FIFA17.exe`. +/// +/// Sorted ascending so that, when more than one client is somehow running, the +/// per-pid log lines come out in a stable order (the Python inherits readdir +/// order, which is arbitrary). +pub fn find_pids() -> Vec { + let mut out = Vec::new(); + let Ok(entries) = fs::read_dir("/proc") else { + return out; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(pid_from_proc_entry) else { + continue; + }; + // A pid can exit between readdir and this read; that is not an error. + if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { + if is_client_comm(&comm) { + out.push(pid); + } + } + } + out.sort_unstable(); + out +} + +/// Base address of the mapped CardsDLL, or `None` while it is not mapped. +pub fn cardsdll_base(pid: u32) -> Option { + let maps = fs::read_to_string(format!("/proc/{pid}/maps")).ok()?; + parse_cardsdll_base(&maps) +} + +/// [`Memory`] over one live client process. +pub struct ProcMem { + pid: u32, +} + +impl ProcMem { + pub fn new(pid: u32) -> Self { + Self { pid } + } +} + +impl Memory for ProcMem { + fn pid(&self) -> u32 { + self.pid + } + + /// A failure here normally means the address is not mapped yet — the packer + /// has not unpacked that code — which the watch loop treats as "come back + /// next tick", not as a failure worth reporting. Read-only handle. + fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> { + File::open(format!("/proc/{}/mem", self.pid))?.read_exact_at(buf, va) + } + + /// Opened read+write like the Python's `r+b`; write-only is not universally + /// accepted for `/proc//mem` across kernels. + fn write(&self, va: u64, data: &[u8]) -> io::Result<()> { + OpenOptions::new() + .read(true) + .write(true) + .open(format!("/proc/{}/mem", self.pid))? + .write_all_at(data, va) + } +} + +/// Whether `/proc/` still exists — the launcher-liveness check. +pub fn pid_alive(pid: i64) -> bool { + // Formatted exactly like the Python so a negative or zero pid behaves the + // same way (the path simply does not exist). + fs::metadata(format!("/proc/{pid}")).is_ok() +} + +/// Real uid of this process, from the ownership of `/proc/self`. +/// +/// std exposes no `getuid`, and this crate takes no dependencies; `/proc` is +/// mandatory for the patcher anyway, so reading it back is not a new assumption. +pub fn current_uid() -> io::Result { + use std::os::unix::fs::MetadataExt; + Ok(fs::metadata("/proc/self")?.uid()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn our_own_pid_is_alive_and_pid_zero_is_not() { + let me: i64 = fs::read_to_string("/proc/self/stat") + .unwrap() + .split(' ') + .next() + .unwrap() + .parse() + .unwrap(); + assert!(pid_alive(me)); + // /proc/0 and /proc/-1 never exist, matching the Python's path check. + assert!(!pid_alive(0)); + assert!(!pid_alive(-1)); + } + + #[test] + fn uid_is_readable() { + // Only that it resolves; the value is environment-dependent. + assert!(current_uid().is_ok()); + } + + #[test] + fn find_pids_scan_is_safe_without_a_client() { + // Deterministic without a client: the scan must not panic and must only + // ever return numeric pids. + for pid in find_pids() { + assert!(pid > 0); + } + } + + #[test] + fn positioned_io_round_trips_against_our_own_address_space() { + // Patching FIFA is not testable here, but the /proc//mem mechanism + // is: read and write this process's own heap through the same code path. + let me: u32 = fs::read_to_string("/proc/self/stat") + .unwrap() + .split(' ') + .next() + .unwrap() + .parse() + .unwrap(); + let mem = ProcMem::new(me); + // black_box throughout: this buffer is mutated by the kernel on our + // behalf, never by Rust code, so the compiler must not assume it is + // unchanged across the write. + let target = std::hint::black_box(vec![0x75u8, 0x0f, 0x11, 0x22]); + let va = target.as_ptr() as u64; + + let mut seen = [0u8; 4]; + mem.read(va, &mut seen).unwrap(); + assert_eq!(seen, *target); + + mem.write(va, &[0x7f, 0x0f]).unwrap(); + assert_eq!(*std::hint::black_box(&target), [0x7f, 0x0f, 0x11, 0x22]); + + // An address that is certainly not mapped reads as an error, which the + // watch loop treats as "not unpacked yet". + assert!(mem.read(0x1000, &mut seen).is_err()); + } +} diff --git a/openfut-launcher b/openfut-launcher index c542415..1cd4f18 160000 --- a/openfut-launcher +++ b/openfut-launcher @@ -1 +1 @@ -Subproject commit c5424158b95ccd0c9e7b5ad4ac6582846eb7e010 +Subproject commit 1cd4f18e92fb553b99b24477f3538e7daff412d1 diff --git a/openfut-lsx/Cargo.toml b/openfut-lsx/Cargo.toml new file mode 100644 index 0000000..2af985e --- /dev/null +++ b/openfut-lsx/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "openfut-lsx" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "EA Origin LSX emulator for the FIFA 17 client (loopback 4216)" +publish = false + +[dependencies] +# AES-128-ECB only. The LSX session cipher is a single fixed-key ECB block operation, +# so the block cipher alone is the whole requirement -- no AEAD, no TLS stack. Using +# the `aes` crate rather than hand-rolling it keeps the one security-shaped primitive +# in reviewed code, while the surrounding framing (PKCS7 pad, lowercase hex, +# NUL-terminated) stays explicit here because it is protocol, not cryptography. +aes = "0.8" + +# Project rule rs-parking-lot: locks that are immediately unwrapped use parking_lot. +parking_lot = "0.12" diff --git a/openfut-lsx/src/crypto.rs b/openfut-lsx/src/crypto.rs new file mode 100644 index 0000000..be3ec84 --- /dev/null +++ b/openfut-lsx/src/crypto.rs @@ -0,0 +1,429 @@ +//! LSX session crypto. +//! +//! Transcription of the crypto block of `fifa17-recon/tools/lsx_responder_v2.py`, +//! which carries the note: +//! +//! > (verbatim from v1 -- verified end-to-end by decrypting captured +//! > captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.) +//! +//! So the Python is the specification and this file is a byte-for-byte port. The +//! block cipher itself comes from the `aes` crate; the framing around it +//! (PKCS7 pad to 16, lowercase hex, NUL terminator) is *protocol*, not +//! cryptography, and is therefore spelled out here rather than delegated to a +//! mode/padding helper. +//! +//! Wire recap (reversed from stp-origin_emu.dll @ 0x6ffffc930000): +//! handshake: server sends `` in PLAINTEXT; the client +//! answers plaintext with `response=`/`key=`; the server answers +//! `` where +//! `H = hex(AES128_ECB(K_FIXED, clientKeyAscii))` + a fixed 3rd block. +//! session: every later frame, both directions, Responses AND Events, is +//! `hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"`, +//! with SESSION_KEY derived from `H` through the MSVCR srand/rand LCG. + +use std::fmt; +use std::sync::LazyLock; + +use aes::cipher::generic_array::GenericArray; +use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; +use aes::Aes128; + +/// A 128-bit LSX key (the fixed handshake key or a derived session key). +pub type Key = [u8; 16]; + +/// Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038. +/// `bytes(range(16))` == `000102030405060708090a0b0c0d0e0f`. +pub const K_FIXED: Key = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +]; + +/// Failure modes of the LSX codec. The variants mirror the exceptions the Python +/// raises at the same points, because they surface through the same log lines +/// (`decrypt fail: ...`, `connection error: ...`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CryptoError { + /// ECB has no padding of its own: pycryptodome raises ValueError here. + NotBlockAligned(usize), + /// `bytes.fromhex` rejected the payload. + BadHex, + /// Nothing before the first NUL, so there is no block to decrypt (the Python + /// dies on `raw[-1]` with IndexError). + Empty, + /// The client's own `response=` carried a 3rd block that is not the emu's + /// constant. The Python asserts here on purpose: a future client that + /// randomises block 3 must fail LOUDLY, not silently. + UnexpectedTail(String), +} + +impl fmt::Display for CryptoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + // Wording kept close to pycryptodome's so existing log greps still hit. + CryptoError::NotBlockAligned(n) => write!( + f, + "Data must be padded to 16 byte boundary in ECB mode (got {n} bytes)" + ), + CryptoError::BadHex => f.write_str("Non-hexadecimal digit found"), + CryptoError::Empty => f.write_str("empty ciphertext"), + CryptoError::UnexpectedTail(t) => { + write!(f, "unexpected ChallengeResponse tail {t:?}") + } + } + } +} + +impl std::error::Error for CryptoError {} + +/// MSVCR120 `srand`/`rand` LCG (verified: `srand(7); rand() == 61`). +pub struct MsvcrRand { + state: u32, +} + +impl MsvcrRand { + /// `srand(seed)`. + pub fn new(seed: u32) -> Self { + Self { state: seed } + } + + /// `rand()` -- 15 bits, exactly as MSVCR120 returns them. + pub fn next_u15(&mut self) -> u16 { + self.state = self.state.wrapping_mul(214013).wrapping_add(2531011); + ((self.state >> 16) & 0x7FFF) as u16 + } +} + +/// Reimplementation of emu `sub_0x6ffffc931f10` tail (0x9320bf-0x932101). +/// +/// ```text +/// 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 +/// ``` +/// +/// `resp[0]`/`resp[1]` are the first two ASCII *characters* of the hex response, +/// not the first decoded byte. +pub fn derive_session_key(resp_hex: &str) -> Key { + let r0 = u32::from(MsvcrRand::new(7).next_u15()); // == 61 + let b = resp_hex.as_bytes(); + assert!( + b.len() >= 2, + "derive_session_key needs the first two response chars, got {:?}", + resp_hex + ); + let bx = ((u32::from(b[0]) << 8) + u32::from(b[1])) & 0xFFFF; + let mut g = MsvcrRand::new(bx.wrapping_add(r0)); + let mut key = [0u8; 16]; + for byte in key.iter_mut() { + *byte = (g.next_u15() & 0xFF) as u8; + } + key +} + +/// `AES128-ECB(K_FIXED, 0x10 * 16)` -- the constant the emu appends as the 3rd +/// hex block (== the PKCS7 pad block of an aligned 32-byte key). See +/// REPACK_INTEL.md sec.0-B. +pub fn tail_const() -> &'static str { + static TAIL: LazyLock = LazyLock::new(|| { + let mut block = [0x10u8; 16]; + ecb_encrypt_blocks(&K_FIXED, &mut block); + hex_lower(&block) + }); + TAIL.as_str() +} + +/// Emu-exact `ChallengeAccepted.response` (stp-origin_emu.dll 0x180001f10). +/// +/// The emu computes only TWO AES blocks from the 32-ASCII client key, then +/// `strcat_s`'s the client's OWN `response[64:]` verbatim (@0x1800020a9) -> 96 +/// hex. The older 3-block PKCS7 form is numerically identical *while the client +/// PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed +/// byte-exact). We reproduce the emu exactly and, when the client's `response=` +/// is available, echo its tail and assert the constant so a future client that +/// randomises block 3 fails LOUDLY instead of silently. +pub fn challenge_response( + client_key_ascii: &str, + client_response_attr: &str, +) -> Result { + let mut buf = client_key_ascii.as_bytes().to_vec(); + if buf.is_empty() || buf.len() % 16 != 0 { + // ECB cannot pad: the Python's AES.encrypt raises here too. + return Err(CryptoError::NotBlockAligned(buf.len())); + } + ecb_encrypt_blocks(&K_FIXED, &mut buf); + let two = hex_lower(&buf); + + // Python slices `client_response_attr[64:]`, i.e. by characters. + if let Some((idx, _)) = client_response_attr.char_indices().nth(64) { + let tail = &client_response_attr[idx..]; + if tail != tail_const() { + return Err(CryptoError::UnexpectedTail(tail.to_string())); + } + return Ok(two + tail); + } + if client_response_attr.chars().count() == 64 { + // len(attr) >= 64 with an empty tail: the assert compares "" against the + // constant and fails, exactly as it would here. + return Err(CryptoError::UnexpectedTail(String::new())); + } + Ok(two + tail_const()) +} + +/// pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated. +pub fn lsx_encrypt(xml: &str, key: &Key) -> Vec { + let body = xml.as_bytes(); + let pad = 16 - (body.len() % 16); // emu always pads (pad==16 when aligned) + let mut buf = Vec::with_capacity(body.len() + pad); + buf.extend_from_slice(body); + buf.resize(body.len() + pad, pad as u8); + ecb_encrypt_blocks(key, &mut buf); + + // hex + NUL, sized up front: this runs on every frame we ever send. + let mut out = Vec::with_capacity(buf.len() * 2 + 1); + for b in &buf { + out.push(HEX_DIGITS[usize::from(*b >> 4)]); + out.push(HEX_DIGITS[usize::from(*b & 0x0f)]); + } + out.push(0); + out +} + +/// Hex up to the first NUL, decrypt, then take up to the first NUL of the +/// plaintext (the PKCS7 tail is stripped when it is well-formed, matching the +/// Python's tolerant check). +pub fn lsx_decrypt(data: &[u8], key: &Key) -> Result { + let head = match data.iter().position(|b| *b == 0) { + Some(i) => &data[..i], + None => data, + }; + let mut raw = hex_decode(trim_ascii_ws(head))?; + if raw.is_empty() { + return Err(CryptoError::Empty); + } + if raw.len() % 16 != 0 { + return Err(CryptoError::NotBlockAligned(raw.len())); + } + ecb_decrypt_blocks(key, &mut raw); + + let pad = usize::from(raw[raw.len() - 1]); + if pad > 0 && pad <= 16 && raw[raw.len() - pad..].iter().all(|c| usize::from(*c) == pad) { + raw.truncate(raw.len() - pad); + } + if let Some(i) = raw.iter().position(|b| *b == 0) { + raw.truncate(i); + } + // Python decodes with errors="replace". + Ok(String::from_utf8_lossy(&raw).into_owned()) +} + +const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef"; + +/// Lowercase hex, like `bytes.hex()`. +pub fn hex_lower(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(char::from(HEX_DIGITS[usize::from(*b >> 4)])); + s.push(char::from(HEX_DIGITS[usize::from(*b & 0x0f)])); + } + s +} + +/// `bytes.fromhex`: ASCII whitespace between bytes is skipped, everything else +/// must be a hex digit pair. +fn hex_decode(s: &[u8]) -> Result, CryptoError> { + let mut out = Vec::with_capacity(s.len() / 2); + let mut hi: Option = None; + for c in s { + if c.is_ascii_whitespace() { + continue; + } + let nib = match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + b'A'..=b'F' => c - b'A' + 10, + _ => return Err(CryptoError::BadHex), + }; + match hi.take() { + None => hi = Some(nib), + Some(h) => out.push((h << 4) | nib), + } + } + if hi.is_some() { + return Err(CryptoError::BadHex); + } + Ok(out) +} + +/// `bytes.strip()` -- ASCII whitespace at both ends. +fn trim_ascii_ws(mut b: &[u8]) -> &[u8] { + while let Some((first, rest)) = b.split_first() { + if first.is_ascii_whitespace() { + b = rest; + } else { + break; + } + } + while let Some((last, rest)) = b.split_last() { + if last.is_ascii_whitespace() { + b = rest; + } else { + break; + } + } + b +} + +fn ecb_encrypt_blocks(key: &Key, buf: &mut [u8]) { + debug_assert_eq!(buf.len() % 16, 0); + let cipher = Aes128::new(GenericArray::from_slice(key)); + for block in buf.chunks_exact_mut(16) { + cipher.encrypt_block(GenericArray::from_mut_slice(block)); + } +} + +fn ecb_decrypt_blocks(key: &Key, buf: &mut [u8]) { + debug_assert_eq!(buf.len() % 16, 0); + let cipher = Aes128::new(GenericArray::from_slice(key)); + for block in buf.chunks_exact_mut(16) { + cipher.decrypt_block(GenericArray::from_mut_slice(block)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The captured client key from the 2026-07-30 session the Python's own + /// selftest pins. + const CAPTURED_CLIENT_KEY: &str = "18a70055a3541fb27ab8e0f47afad18c"; + const CAPTURED_H: &str = concat!( + "e4f5166209929e156a2ca47b81cdd6bf", + "6f2a20371532ed4f968c5a9274899dbf", + "954f64f2e4e86e9eee82d20216684899" + ); + const CAPTURED_SESSION_KEY: &str = "6a9da3e78615153cc2f10eec25ae6382"; + + #[test] + fn msvcr_srand7_first_rand_is_61() { + // The one vector the Python names in its docstring. + assert_eq!(MsvcrRand::new(7).next_u15(), 61); + } + + #[test] + fn tail_const_is_the_emu_constant() { + // AES128-ECB(K_FIXED, 0x10*16), pinned so a cipher/keying regression is + // caught without a live client. + assert_eq!(tail_const(), "954f64f2e4e86e9eee82d20216684899"); + } + + #[test] + fn k_fixed_is_000102_to_0f() { + assert_eq!(hex_lower(&K_FIXED), "000102030405060708090a0b0c0d0e0f"); + } + + #[test] + fn challenge_response_matches_captured_session() { + let h = challenge_response(CAPTURED_CLIENT_KEY, "").unwrap(); + assert!(h.starts_with("e4f5166209929e15"), "{h}"); + assert_eq!(h, CAPTURED_H); + assert_eq!(h.len(), 96); + } + + #[test] + fn challenge_response_echoes_the_clients_own_tail() { + // The emu strcat_s's response[64:] verbatim; identical result while the + // client pads block 3 with the constant. + let h = challenge_response(CAPTURED_CLIENT_KEY, CAPTURED_H).unwrap(); + assert_eq!(h, CAPTURED_H); + } + + #[test] + fn challenge_response_rejects_a_randomised_third_block() { + let bogus = format!("{}{}", &CAPTURED_H[..64], "00".repeat(16)); + let err = challenge_response(CAPTURED_CLIENT_KEY, &bogus).unwrap_err(); + assert_eq!(err, CryptoError::UnexpectedTail("00".repeat(16))); + } + + #[test] + fn challenge_response_rejects_unaligned_client_key() { + let err = challenge_response("short", "").unwrap_err(); + assert_eq!(err, CryptoError::NotBlockAligned(5)); + } + + #[test] + fn session_key_derives_from_the_response() { + let k = derive_session_key(CAPTURED_H); + assert_eq!(hex_lower(&k), CAPTURED_SESSION_KEY); + } + + #[test] + fn encrypt_pins_the_first_login_frame() { + let k = derive_session_key(CAPTURED_H); + let frame = r#""#; + let out = lsx_encrypt(frame, &k); + assert_eq!(*out.last().unwrap(), 0, "frames are NUL-terminated"); + // Pinned against `openssl enc -aes-128-ecb -nopad` over the PKCS7-padded + // frame under the captured session key: 62 bytes of XML -> pad 2 -> 4 blocks. + assert_eq!( + std::str::from_utf8(&out[..out.len() - 1]).unwrap(), + concat!( + "ded1180ab8a2ab85b7408cc009eb0191", + "00b7b4c827fe0b9b4de2ca7834b3ed51", + "31a0714a8eb66ba3e38d1855724b0a48", + "09ad8616e71b3a78880c570c9af7ff80" + ) + ); + } + + #[test] + fn round_trip() { + let k = derive_session_key(CAPTURED_H); + for xml in [ + r#""#, + r#""#, + "", + ] { + assert_eq!(lsx_decrypt(&lsx_encrypt(xml, &k), &k).unwrap(), xml); + } + } + + #[test] + fn round_trip_of_block_aligned_plaintext_pads_a_whole_block() { + let k = derive_session_key(CAPTURED_H); + let xml = "0123456789abcdef"; // exactly 16 bytes + let enc = lsx_encrypt(xml, &k); + // 2 blocks (16 data + 16 pad) -> 64 hex chars + NUL. + assert_eq!(enc.len(), 65); + assert_eq!(lsx_decrypt(&enc, &k).unwrap(), xml); + } + + #[test] + fn decrypt_ignores_everything_past_the_first_nul() { + let k = derive_session_key(CAPTURED_H); + let mut enc = lsx_encrypt("", &k); + enc.extend_from_slice(b"deadbeef\0trailing"); + assert_eq!(lsx_decrypt(&enc, &k).unwrap(), ""); + } + + #[test] + fn decrypt_strips_surrounding_whitespace() { + let k = derive_session_key(CAPTURED_H); + let enc = lsx_encrypt("", &k); + let mut padded = b" ".to_vec(); + padded.extend_from_slice(&enc[..enc.len() - 1]); + padded.extend_from_slice(b"\r\n\0"); + assert_eq!(lsx_decrypt(&padded, &k).unwrap(), ""); + } + + #[test] + fn decrypt_rejects_garbage() { + let k = derive_session_key(CAPTURED_H); + assert_eq!(lsx_decrypt(b"zz\0", &k).unwrap_err(), CryptoError::BadHex); + assert_eq!(lsx_decrypt(b"abc\0", &k).unwrap_err(), CryptoError::BadHex); + assert_eq!(lsx_decrypt(b"\0", &k).unwrap_err(), CryptoError::Empty); + assert_eq!( + lsx_decrypt(b"00112233\0", &k).unwrap_err(), + CryptoError::NotBlockAligned(4) + ); + } +} diff --git a/openfut-lsx/src/events.rs b/openfut-lsx/src/events.rs new file mode 100644 index 0000000..d2f10ad --- /dev/null +++ b/openfut-lsx/src/events.rs @@ -0,0 +1,356 @@ +//! PUSHED EVENTS -- the whole reason v2 exists, plus the per-connection state +//! they need. +//! +//! Frame shape is identical to the server-initiated `` that already +//! works, i.e. ``. No `id` +//! attribute (the Challenge has none; the matcher never reads one). + +use std::io::{self, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::{Mutex, RwLock}; + +use crate::crypto::{self, Key}; +use crate::{env_flag, log, ConfigError, Env}; + +/// `sender` is strcmp'd against the handler's registered service name. A mismatch +/// is SILENTLY DROPPED -- it costs us nothing -- so we emit every candidate. +/// +/// Event handlers are keyed on `serviceNames[facility]` (registrar 0x14710df80); +/// with our empty GetConfigResponse those names are "", so the handlers expect +/// `sender=""`. "" first; the named variants are harmless no-ops (dropped +/// silently) and become correct once GetConfigResponse populates the table. +/// "LOGIN_EVENT" is table index 14 (the one the event-handler factory uses) and +/// "LOGIN" is index 8 (the plain service name); exactly one of the three will +/// match. +pub const LOGIN_EVENT_SENDERS: [&str; 3] = ["", "LOGIN_EVENT", "LOGIN"]; +pub const ONLINE_EVENT_SENDERS: [&str; 2] = ["", "ONLINE_STATUS_EVENT"]; + +pub fn event(sender: &str, element: &str) -> String { + format!(r#"<{element}/>"#) +} + +/// The frames that flip `OriginMgr.m_isLoggedIn` ([OriginMgr+0x13]) to 1. +/// +/// `IsLoggedIn` is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE. Keep the +/// value literally "true" anyway: it is what a real Origin client sends and it +/// keeps the log readable. +pub fn login_event_frames() -> Vec { + let mut out = Vec::with_capacity(LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len()); + out.extend(LOGIN_EVENT_SENDERS.iter().map(|s| event(s, r#"Login IsLoggedIn="true""#))); + out.extend( + ONLINE_EVENT_SENDERS + .iter() + .map(|s| event(s, r#"OnlineStatusEvent isOnline="true""#)), + ); + out +} + +/// Event tuning. +/// +/// Pushes are idempotent state notifications, so re-sending is harmless and is +/// cheap insurance against FIFA registering its `` handler later than our +/// first push. Set `OPENFUT_LSX_EVENTS=0` to fall back to v1 behaviour (useful as +/// an A/B control if you want to prove the events are what moved the needle). +#[derive(Debug, Clone, PartialEq)] +pub struct EventConfig { + /// `OPENFUT_LSX_EVENTS != "0"` (default on). + pub enabled: bool, + /// `OPENFUT_LSX_EVENT_PERIOD` seconds (default 5). + pub period_secs: f64, + /// `OPENFUT_LSX_EVENT_COUNT` heartbeat repeats (default 24). + pub count: i64, + /// EXPERIMENT (`OPENFUT_LSX_LOGIN_PLAINTEXT`, default off): push the Login + /// Event in PLAINTEXT right after ChallengeAccepted (before the stream goes + /// encrypted) instead of via the encrypted heartbeat. Tests the workflow's + /// strongest remaining hypothesis -- that FIFA drops encrypted mid-session + /// Events (the emu's only Event, the Challenge, is plaintext and pre-key). + /// See REPACK_INTEL.md sec.4 step 2. + pub login_plaintext: bool, +} + +impl Default for EventConfig { + fn default() -> Self { + Self { + enabled: true, + period_secs: 5.0, + count: 24, + login_plaintext: false, + } + } +} + +impl EventConfig { + pub fn from_env() -> Result { + Self::from_vars(&crate::os_env) + } + + pub fn from_vars(env: Env<'_>) -> Result { + let period_secs = match env("OPENFUT_LSX_EVENT_PERIOD") { + Some(v) => v.trim().parse::().map_err(|_| ConfigError { + var: "OPENFUT_LSX_EVENT_PERIOD", + value: v.clone(), + expected: "a number of seconds", + })?, + None => 5.0, + }; + let count = match env("OPENFUT_LSX_EVENT_COUNT") { + Some(v) => v.trim().parse::().map_err(|_| ConfigError { + var: "OPENFUT_LSX_EVENT_COUNT", + value: v.clone(), + expected: "an integer", + })?, + None => 24, + }; + Ok(Self { + enabled: env_flag(env, "OPENFUT_LSX_EVENTS", true), + period_secs, + count, + login_plaintext: env_flag(env, "OPENFUT_LSX_LOGIN_PLAINTEXT", false), + }) + } +} + +/// Render a period the way Python's `float` repr does, so the startup line reads +/// `period=5.0s` and not `period=5s`. +pub fn fmt_secs(secs: f64) -> String { + if secs.is_finite() && secs.fract() == 0.0 && secs.abs() < 1e16 { + format!("{secs:.1}") + } else { + format!("{secs}") + } +} + +/// Socket + session key + a send lock. +/// +/// The lock matters: pushes come from a heartbeat thread while the request loop +/// may be writing a Response. LSX frames are NUL-delimited, so two interleaved +/// writes would corrupt the stream and the client would drop the connection +/// (which would look exactly like a protocol bug). +pub struct Conn { + pub peer: SocketAddr, + /// A `try_clone` of the accepted socket: the request loop reads from the + /// original while this half is serialised behind the send lock. + writer: Mutex, + key: RwLock>, + alive: AtomicBool, + pushed_login: AtomicBool, + /// Set once GetAuthCode has been issued, so the heartbeat stops re-pushing + /// Login/OnlineStatus events. Re-pushing after the auth code is granted + /// re-enters FIFA's state-mutating Origin event dispatcher (case 2 + /// @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts on the FE + /// bus) ~24 more times DURING Blaze login, which we do not want. + stop_events: AtomicBool, + events: EventConfig, +} + +impl Conn { + pub fn new(sock: &TcpStream, peer: SocketAddr, events: EventConfig) -> io::Result> { + Ok(Arc::new(Self { + peer, + writer: Mutex::new(sock.try_clone()?), + key: RwLock::new(None), + alive: AtomicBool::new(true), + pushed_login: AtomicBool::new(false), + stop_events: AtomicBool::new(false), + events, + })) + } + + pub fn events(&self) -> &EventConfig { + &self.events + } + + pub fn set_key(&self, key: Key) { + *self.key.write() = Some(key); + } + + pub fn key(&self) -> Option { + *self.key.read() + } + + // These flags are advisory hand-offs between the request loop and the + // heartbeat thread; no data travels with them, so Relaxed is enough. + pub fn is_alive(&self) -> bool { + self.alive.load(Ordering::Relaxed) + } + + pub fn mark_dead(&self) { + self.alive.store(false, Ordering::Relaxed); + } + + pub fn stop_events(&self) { + self.stop_events.store(true, Ordering::Relaxed); + } + + pub fn events_stopped(&self) -> bool { + self.stop_events.load(Ordering::Relaxed) + } + + pub fn send_plain(&self, xml: &str) -> io::Result<()> { + let mut buf = Vec::with_capacity(xml.len() + 1); + buf.extend_from_slice(xml.as_bytes()); + buf.push(0); + self.writer.lock().write_all(&buf) + } + + pub fn send_enc(&self, xml: &str) -> io::Result<()> { + let key = self + .key() + .ok_or_else(|| io::Error::other("session key not initialised"))?; + let frame = crypto::lsx_encrypt(xml, &key); + self.writer.lock().write_all(&frame) + } + + pub fn push_login_state(&self, why: &str) { + if !self.events.enabled { + return; + } + for frame in login_event_frames() { + if let Err(e) = self.send_enc(&frame) { + self.mark_dead(); + log!("push failed: {e}"); + return; + } + log!("PUSH ({why}) >> {frame}"); + } + if !self.pushed_login.swap(true, Ordering::Relaxed) { + log!( + "*** first pushed. Watch for \ + GetAuthCode next. ***" + ); + } + } + + /// Re-push the login state a bounded number of times. + /// + /// FIFA builds its Origin event handlers lazily; if our first push lands + /// before the `` handler is registered the matcher simply finds no + /// handler and drops it. Re-pushing removes that race without needing to + /// guess the exact registration moment. + pub fn heartbeat(&self) { + let period = match Duration::try_from_secs_f64(self.events.period_secs) { + Ok(d) => d, + Err(e) => { + // Python's time.sleep() would raise here and kill just this + // thread; the connection keeps serving requests either way. + log!( + "heartbeat disabled: OPENFUT_LSX_EVENT_PERIOD={} is not a usable delay ({e})", + self.events.period_secs + ); + return; + } + }; + for _ in 0..self.events.count.max(0) { + std::thread::sleep(period); + if !self.is_alive() || self.events_stopped() { + return; + } + self.push_login_state("heartbeat"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{derive_session_key, lsx_decrypt, lsx_encrypt}; + use std::collections::HashMap; + + fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + #[test] + fn frames_are_the_five_candidate_pushes_with_the_empty_sender_first() { + let frames = login_event_frames(); + assert_eq!( + frames.len(), + LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len() + ); + assert_eq!( + frames, + vec![ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] + ); + } + + #[test] + fn frames_round_trip_through_the_session_codec() { + let k = derive_session_key(&crate::crypto::challenge_response( + "18a70055a3541fb27ab8e0f47afad18c", + "", + ) + .unwrap()); + for f in login_event_frames() { + assert_eq!(lsx_decrypt(&lsx_encrypt(&f, &k), &k).unwrap(), f); + } + } + + #[test] + fn defaults_match_the_python() { + let cfg = EventConfig::from_vars(&env_of(&[])).unwrap(); + assert_eq!(cfg, EventConfig::default()); + assert!(cfg.enabled); + assert_eq!(cfg.period_secs, 5.0); + assert_eq!(cfg.count, 24); + assert!(!cfg.login_plaintext); + } + + #[test] + fn events_are_disabled_only_by_the_literal_zero() { + for (value, enabled) in [("0", false), ("1", true), ("", true), ("false", true)] { + let cfg = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENTS", value)])).unwrap(); + assert_eq!(cfg.enabled, enabled, "OPENFUT_LSX_EVENTS={value:?}"); + } + } + + #[test] + fn login_plaintext_is_enabled_by_anything_but_zero() { + for (value, on) in [("0", false), ("1", true), ("", true), ("no", true)] { + let cfg = + EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_LOGIN_PLAINTEXT", value)])).unwrap(); + assert_eq!(cfg.login_plaintext, on, "OPENFUT_LSX_LOGIN_PLAINTEXT={value:?}"); + } + } + + #[test] + fn period_and_count_are_overridable() { + let cfg = EventConfig::from_vars(&env_of(&[ + ("OPENFUT_LSX_EVENT_PERIOD", "0.25"), + ("OPENFUT_LSX_EVENT_COUNT", "3"), + ])) + .unwrap(); + assert_eq!(cfg.period_secs, 0.25); + assert_eq!(cfg.count, 3); + } + + #[test] + fn bad_period_is_refused() { + let err = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_PERIOD", "soon")])) + .unwrap_err(); + assert_eq!(err.var, "OPENFUT_LSX_EVENT_PERIOD"); + let err = + EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_COUNT", "many")])).unwrap_err(); + assert_eq!(err.var, "OPENFUT_LSX_EVENT_COUNT"); + } + + #[test] + fn period_renders_like_a_python_float() { + assert_eq!(fmt_secs(5.0), "5.0"); + assert_eq!(fmt_secs(0.25), "0.25"); + assert_eq!(fmt_secs(0.0), "0.0"); + } +} diff --git a/openfut-lsx/src/identity.rs b/openfut-lsx/src/identity.rs new file mode 100644 index 0000000..054a1da --- /dev/null +++ b/openfut-lsx/src/identity.rs @@ -0,0 +1,158 @@ +//! The identity LSX reports, ported from `fut_account.py`'s tier-1/tier-2 fields. +//! +//! SOURCED FROM `fut_account.ACCOUNT`, shared with blaze_responder_v3b.py, +//! fut_store.py, fut_seed.py and utas_server.py. +//! +//! THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX +//! reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what +//! UTAS serves as userInfo.personaId. (An older comment blamed a mismatch for +//! AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / +//! AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are +//! the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or +//! dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They +//! stay the defaults because they are what the working stack asserts.) +//! +//! PRECEDENCE: env var > built-in default. +//! +//! `fut_account.py` sits one tier deeper -- env > `fut_account.json` > default -- +//! but the JSON is deliberately NOT read here: its persisted values for the three +//! fields LSX uses (`persona_id`, `persona_name`; `locale` is not even stored) are +//! identical to the built-in defaults, and the launcher always passes +//! `FUT_PERSONA_ID`/`FUT_PERSONA_NAME` explicitly when it spawns us +//! (openfut-launcher/src/local_services.rs), so env decides in every real run. +//! Parsing a JSON file to reach the same answer would only add a failure mode -- +//! and the launcher, not a file next to the Python tools, is the identity owner +//! for this binary. + +use crate::{ConfigError, Env}; + +// ------------------------------------------------------------------ tier 1 +// LOCKED WIRE CONSTANTS. No env override on purpose: these are not preferences. + +/// FIFA 17 EA offer id (retail). +pub const CONTENT_ID: &str = "1027460"; + +/// `TRIAL_ONLINE_ACCESS` for FIFA17_Trial.exe; retail uses this. +pub const ENTITLEMENT_TAG: &str = "ONLINE_ACCESS"; + +// ------------------------------------------------------------------ tier 2 +pub const DEFAULT_PERSONA_ID: i64 = 33068179; +pub const DEFAULT_PERSONA_NAME: &str = "CAGE"; +pub const DEFAULT_LOCALE: &str = "en_US"; + +/// The identity fields LSX puts on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + /// Blaze SESS.BUID / SESS.UID / PDTL.PID, LSX PersonaId/UserId, UTAS + /// userInfo.personaId and squad.personaId. + pub persona_id: i64, + /// Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName. + pub persona_name: String, + /// LSX `GetSetting LANGUAGE`. + pub locale: String, +} + +impl Default for Identity { + fn default() -> Self { + Self { + persona_id: DEFAULT_PERSONA_ID, + persona_name: DEFAULT_PERSONA_NAME.to_string(), + locale: DEFAULT_LOCALE.to_string(), + } + } +} + +impl Identity { + /// Read the process environment. + pub fn from_env() -> Result { + Self::from_vars(&crate::os_env) + } + + /// A `FUT_PERSONA_ID` that `int()` would reject kills `fut_account.py` at + /// import; we refuse to start for the same reason, rather than serve one + /// layer of the stack a silently different persona. + pub fn from_vars(env: Env<'_>) -> Result { + let persona_id = match env("FUT_PERSONA_ID") { + // `int()` tolerates surrounding whitespace and a sign. + Some(v) => v.trim().parse::().map_err(|_| ConfigError { + var: "FUT_PERSONA_ID", + value: v.clone(), + expected: "an integer", + })?, + None => DEFAULT_PERSONA_ID, + }; + Ok(Self { + persona_id, + persona_name: env("FUT_PERSONA_NAME").unwrap_or_else(|| DEFAULT_PERSONA_NAME.into()), + locale: env("FUT_LOCALE").unwrap_or_else(|| DEFAULT_LOCALE.into()), + }) + } + + /// Blaze blazeId / userId (SESS.BUID, SESS.UID, AccountInfo.UID). + /// + /// DERIVED, read-only, and deliberately NOT an independent knob: the client + /// sends both `nuc` and `nucleusPersonaId` and both came out equal, so the + /// getter->field mapping is undetermined. Do not split them until a live test + /// proves Blaze USER_ID may legitimately differ from PERSONA_ID. + pub fn user_id(&self) -> i64 { + self.persona_id + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + #[test] + fn defaults_are_the_working_stacks_values() { + let id = Identity::from_vars(&env_of(&[])).unwrap(); + assert_eq!(id.persona_id, 33068179); + assert_eq!(id.persona_name, "CAGE"); + assert_eq!(id.locale, "en_US"); + assert_eq!(id.user_id(), id.persona_id); + assert_eq!(id, Identity::default()); + } + + #[test] + fn env_wins_over_defaults() { + let id = Identity::from_vars(&env_of(&[ + ("FUT_PERSONA_ID", "1234567"), + ("FUT_PERSONA_NAME", "OTHER"), + ("FUT_LOCALE", "de_DE"), + ])) + .unwrap(); + assert_eq!(id.persona_id, 1234567); + assert_eq!(id.persona_name, "OTHER"); + assert_eq!(id.locale, "de_DE"); + assert_eq!(id.user_id(), 1234567); + } + + #[test] + fn empty_env_value_still_wins() { + // `os.environ.get` returns "" for `FUT_PERSONA_NAME=`, and "" is not None. + let id = Identity::from_vars(&env_of(&[("FUT_PERSONA_NAME", "")])).unwrap(); + assert_eq!(id.persona_name, ""); + } + + #[test] + fn whitespace_around_the_persona_id_is_tolerated_like_int() { + let id = Identity::from_vars(&env_of(&[("FUT_PERSONA_ID", " 42 ")])).unwrap(); + assert_eq!(id.persona_id, 42); + } + + #[test] + fn a_non_numeric_persona_id_is_refused() { + let err = Identity::from_vars(&env_of(&[("FUT_PERSONA_ID", "CAGE")])).unwrap_err(); + assert_eq!(err.var, "FUT_PERSONA_ID"); + assert_eq!(err.to_string(), r#"FUT_PERSONA_ID must be an integer (got "CAGE")"#); + } +} diff --git a/openfut-lsx/src/lib.rs b/openfut-lsx/src/lib.rs new file mode 100644 index 0000000..dc6d72a --- /dev/null +++ b/openfut-lsx/src/lib.rs @@ -0,0 +1,109 @@ +//! OpenFUT clean-room LSX responder for FIFA 17 -- Rust port of +//! `fifa17-recon/tools/lsx_responder_v2.py` (v2, EVENT-PUSHING). +//! +//! v1 (`lsx_responder.py`) was REQUEST-DRIVEN ONLY. It answered every verb the +//! client asked for and never sent an unsolicited frame. That is exactly why the +//! client never issued GetAuthCode and never sent Blaze Authentication::login. +//! +//! THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS: +//! +//! 1. "internet is reachable" -> OriginMgr online byte [0x1448a3ac0], +//! fed by the REQUEST verb `GetInternetConnectedState -> connected="1"` +//! (v1 already beat this; live-confirmed == 1). +//! 2. "a user is LOGGED IN" -> `OriginMgr.m_isLoggedIn` [OriginMgr+0x13], +//! fed ONLY by a server-PUSHED ``. +//! There is NO request verb that can set it. +//! +//! v1 fed (1) and never fed (2), so `m_isLoggedIn` was 0 for the whole session, +//! FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever +//! (both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately, +//! OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH +//! could never be filled -> no Blaze login -> "Unable to retrieve account +//! information." +//! +//! The binary evidence for the push (dispatcher @0x146f1e060 case 2, the `` +//! matcher @0x147102880 and its silent sender strcmp, the service-name tables at +//! 0x144341420 / sdk+0x3b0, the `strcmp(v,"false")` truthiness of `IsLoggedIn`) +//! lives in the Python's module docstring; the specific facts that constrain code +//! are repeated at the site that depends on them. Nothing here is derived from +//! the 2021 EA/FIFA leak: every constant came from our own static+dynamic +//! analysis of binaries we own plus traffic we captured ourselves. +//! +//! Transport: TCP `127.0.0.1:4216`, every message NUL-terminated (send strlen+1). + +pub mod crypto; +pub mod events; +pub mod identity; +pub mod protocol; + +/// Loopback port the Steampunks Origin emulator stub binds; we must own it +/// BEFORE FIFA 17 starts so the stub's own `bind()` fails. +pub const LSX_PORT: u16 = 4216; + +/// Emu's own advertised challenge (any 32 hex chars work; the client echoes it +/// back). +pub const CHALLENGE_KEY: &str = "2b8ee7faea76e8a34f5f5d20e5328e32"; +pub const BUILD: &str = "release"; +pub const VERSION: &str = "10,4,13,6637"; + +/// Success-signal files the run's watch steps poll. Written only from a REAL +/// GetAuthCode on a live connection. +pub const AUTHCODE_FILE: &str = "/tmp/openfut_authcode.txt"; +pub const CLIENTID_FILE: &str = "/tmp/openfut_lsx_clientid.txt"; + +/// How the modules read environment knobs. Indirected through a closure so the +/// tests can pin an environment without mutating the process (env vars are +/// process-global and `cargo test` runs threads in parallel). +pub type Env<'a> = &'a dyn Fn(&str) -> Option; + +/// The real environment. Present-but-empty is Some(""), which matters: the +/// Python's `os.environ.get(...)` returns "" for `FOO=`, and "" is not None, so +/// an empty setting WINS over the built-in default. +pub fn os_env(name: &str) -> Option { + std::env::var_os(name).map(|v| v.to_string_lossy().into_owned()) +} + +/// The Python's recurring `os.environ.get(NAME, "0") != "0"` rule: ANY value +/// other than the literal "0" enables the knob (including the empty string). +pub fn env_flag(env: Env<'_>, name: &str, default_on: bool) -> bool { + match env(name) { + Some(v) => v != "0", + None => default_on, + } +} + +/// An env knob was set to something the Python's own `int()`/`float()` would +/// reject. The Python dies at import in that case; we refuse to start for the +/// same reason -- a silently different persona, or a silently absent heartbeat, +/// is worse than a loud failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigError { + pub var: &'static str, + pub value: String, + pub expected: &'static str, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} must be {} (got {:?})", + self.var, self.expected, self.value + ) + } +} + +impl std::error::Error for ConfigError {} + +/// Every diagnostic line carries the `[lsx] ` prefix and is flushed per line: +/// the launcher pipes our stdout+stderr into its log buffer and parses some of +/// these lines, so the formats are a contract. +#[macro_export] +macro_rules! log { + ($($arg:tt)*) => {{ + use std::io::Write; + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "[lsx] {}", format_args!($($arg)*)); + let _ = out.flush(); + }}; +} diff --git a/openfut-lsx/src/main.rs b/openfut-lsx/src/main.rs new file mode 100644 index 0000000..a64324d --- /dev/null +++ b/openfut-lsx/src/main.rs @@ -0,0 +1,278 @@ +//! `openfut-lsx` -- the LSX responder process the launcher spawns. +//! +//! USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's `bind()` fails. +//! This process does NOT auto-start anything; the launcher owns processes. + +use std::io::{self, Read}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::process::ExitCode; +use std::sync::Arc; + +use openfut_lsx::crypto; +use openfut_lsx::events::{fmt_secs, login_event_frames, Conn, EventConfig}; +use openfut_lsx::identity::Identity; +use openfut_lsx::protocol::{ + parse_request, push_after, py_repr, resp, safe_xml_for_log, Attrs, ProtocolConfig, Responder, +}; +use openfut_lsx::{log, os_env, BUILD, CHALLENGE_KEY, LSX_PORT, VERSION}; + +fn main() -> ExitCode { + if std::env::args().skip(1).any(|a| a == "--selftest") { + return match selftest() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + log!("SELFTEST FAILED: {e}"); + ExitCode::FAILURE + } + }; + } + match serve_forever() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + log!("fatal: {e}"); + ExitCode::FAILURE + } + } +} + +fn serve_forever() -> Result<(), Box> { + let identity = Identity::from_env()?; + let events = EventConfig::from_env()?; + let responder = Arc::new(Responder::new(identity, ProtocolConfig::from_env())); + + // Readiness IS the bind: nothing is logged until 4216 is ours, so the launcher + // never reads a ready line for a listener that does not exist. `TcpListener::bind` + // already sets SO_REUSEADDR on every non-Windows target, which is the Python's + // explicit `setsockopt(SO_REUSEADDR, 1)` -- do not reach for socket2 to re-add it. + let bind_host = os_env("OPENFUT_BIND").unwrap_or_else(|| "127.0.0.1".to_string()); + let listener = TcpListener::bind((bind_host.as_str(), LSX_PORT))?; + + // Literal "127.0.0.1:4216" even under OPENFUT_BIND, as the Python logs it: this + // line is what the launcher watches for. + log!("v2 listening on 127.0.0.1:{LSX_PORT} (start FIFA 17 now)"); + log!( + "login-state event push: {} (period={}s count={})", + if events.enabled { "ENABLED" } else { "DISABLED" }, + fmt_secs(events.period_secs), + events.count + ); + + for stream in listener.incoming() { + let stream = match stream { + Ok(s) => s, + Err(e) => { + log!("accept failed: {e}"); + continue; + } + }; + let peer = stream + .peer_addr() + .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0))); + log!("connection from {}", py_addr(peer)); + let responder = Arc::clone(&responder); + let events = events.clone(); + std::thread::spawn(move || serve(stream, peer, &responder, events)); + } + Ok(()) +} + +/// Python prints the accept tuple, e.g. `('127.0.0.1', 54321)`. +fn py_addr(peer: SocketAddr) -> String { + format!("('{}', {})", peer.ip(), peer.port()) +} + +fn serve(mut sock: TcpStream, peer: SocketAddr, responder: &Responder, events: EventConfig) { + let conn = match Conn::new(&sock, peer, events) { + Ok(c) => c, + Err(e) => { + log!("connection error: {e}"); + return; + } + }; + if let Err(e) = session(&mut sock, &conn, responder) { + log!("connection error: {e}"); + } + conn.mark_dead(); + // Dropping our halves closes the socket; the heartbeat thread notices on its + // next tick (and its own send would fail regardless). + drop(sock); + log!("connection closed {}", py_addr(peer)); +} + +fn session( + sock: &mut TcpStream, + conn: &Arc, + responder: &Responder, +) -> Result<(), Box> { + // 1. plaintext Challenge + conn.send_plain(&format!( + r#""# + ))?; + + // 2. plaintext ChallengeResponse from the client. The emu parses `response="` + // BEFORE `key="` (0x180001f10); extract both so challenge_response can echo + // the client's own 3rd block (REPACK_INTEL.md C1/C2). + let mut first = [0u8; 4096]; + let n = sock.read(&mut first)?; + let txt = String::from_utf8_lossy(&first[..n]); + let client_key = first_attr(&txt, "key").unwrap_or(CHALLENGE_KEY); + let client_resp = first_attr(&txt, "response").unwrap_or(""); + let h = crypto::challenge_response(client_key, client_resp)?; + conn.set_key(crypto::derive_session_key(&h)); + log!("handshake accepted; session crypto initialized"); + + // 3. plaintext ChallengeAccepted + conn.send_plain(&resp( + "1", + &format!(r#"ChallengeAccepted response="{h}""#), + "EALS", + ))?; + + // 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY + // unsolicited Event is the plaintext, pre-session-key Challenge; there is + // zero evidence an *encrypted mid-session* Event routes to the same parser + // (REPACK_INTEL.md sec.4 step 2). So push the Login Event here, in + // PLAINTEXT, right after ChallengeAccepted -- before the stream goes + // encrypted -- and suppress the encrypted heartbeat to keep the A/B clean. + if conn.events().login_plaintext && conn.events().enabled { + conn.stop_events(); + for frame in login_event_frames() { + conn.send_plain(&frame)?; + log!("PUSH (plaintext post-accept) >> {frame}"); + } + } + + // 4. encrypted request/response loop + let mut heartbeat_started = false; + let mut buf = Vec::new(); + let mut chunk = [0u8; 65536]; + loop { + let n = sock.read(&mut chunk)?; + if n == 0 { + break; + } + // Buffer partial frames: a 64 KiB read can straddle a NUL boundary, and + // splitting without keeping the remainder would silently drop the trailing + // partial (C3). + buf.extend_from_slice(&chunk[..n]); + let complete = match buf.iter().rposition(|b| *b == 0) { + Some(i) => i + 1, + None => continue, + }; + let frames: Vec> = buf[..complete] + .split(|b| *b == 0) + .filter(|f| !f.is_empty()) + .map(<[u8]>::to_vec) + .collect(); + buf.drain(..complete); + + for frame in frames { + let key = conn.key().expect("session key set during the handshake"); + let xml = match crypto::lsx_decrypt(&frame, &key) { + Ok(x) => x, + Err(e) => { + log!("decrypt fail: {e}"); + continue; + } + }; + let Some(req) = parse_request(&xml) else { + log!("<< {}", safe_xml_for_log(&xml)); + continue; + }; + let reply = responder.build_reply(req.id, req.name, &req.attrs, Some(conn), req.recipient); + log!( + "<< id={} {} recipient={} {}", + req.id, + req.name, + py_repr(req.recipient), + req.attrs.py_repr() + ); + log!(">> {}", safe_xml_for_log(&reply)); + conn.send_enc(&reply)?; + + if let Some(why) = push_after(req.name) { + if conn.events().enabled + // For GetGameInfo only fire on UPTODATE, otherwise we would push + // three times per boot for FREETRIAL/LANGUAGES too. + && (req.name != "GetGameInfo" + || req.attrs.get("GameInfoId") == Some("UPTODATE")) + { + conn.push_login_state(why); + if !heartbeat_started { + heartbeat_started = true; + let conn = Arc::clone(conn); + std::thread::spawn(move || conn.heartbeat()); + } + } + } + } + } + Ok(()) +} + +/// `re.search(r'name="([^"]*)"', txt)` -- the first occurrence anywhere in the +/// plaintext handshake frame, with no word-boundary requirement (which is why the +/// needle keeps its `="`). +fn first_attr<'a>(txt: &'a str, name: &str) -> Option<&'a str> { + let needle = format!("{name}=\""); + let start = txt.find(&needle)? + needle.len(); + let len = txt[start..].find('"')?; + Some(&txt[start..start + len]) +} + +/// No live game needed. Proves the crypto is untouched and the event frames +/// encrypt/decrypt cleanly through our own codec. +fn selftest() -> Result<(), Box> { + let h = crypto::challenge_response("18a70055a3541fb27ab8e0f47afad18c", "")?; + check(h.starts_with("e4f5166209929e15"), &h)?; + let k = crypto::derive_session_key(&h); + let k_hex = crypto::hex_lower(&k); + check(k_hex == "6a9da3e78615153cc2f10eec25ae6382", &k_hex)?; + println!("[ok] crypto matches the captured 2026-07-30 session verbatim"); + + let frames = login_event_frames(); + check( + frames.len() + == openfut_lsx::events::LOGIN_EVENT_SENDERS.len() + + openfut_lsx::events::ONLINE_EVENT_SENDERS.len(), + &format!("{} frames", frames.len()), + )?; + for f in &frames { + let round = crypto::lsx_decrypt(&crypto::lsx_encrypt(f, &k), &k)?; + check(round == *f, &round)?; + println!("[ok] round-trip: {f}"); + } + // "" sender first (correct for the current empty service-name table) + check( + frames[0].contains(r#""#), + &frames[0], + )?; + + // conn=None: the selftest must not write the run's success-signal files. + let responder = Responder::new(Identity::from_env()?, ProtocolConfig::from_env()); + let attrs: Attrs = [("ClientId", "X"), ("Scope", "Y")].into_iter().collect(); + let r = responder.build_reply("42", "GetAuthCode", &attrs, None, ""); + // `value` is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0) + // actually reads; Code=/Return= are legacy padding. + check(r.contains(""#); + check( + !redacted.contains("secret") && redacted.matches("[REDACTED]").count() == 3, + &redacted, + )?; + let status = safe_xml_for_log(r#""#); + check(status.contains(r#"Code="0""#), &status)?; + println!("[ok] GetAuthCode response shape and log redaction"); + println!("[ok] selftest passed"); + Ok(()) +} + +/// The Python's `assert cond, value`. +fn check(cond: bool, value: &str) -> Result<(), io::Error> { + if cond { + Ok(()) + } else { + Err(io::Error::other(format!("assertion failed: {value}"))) + } +} diff --git a/openfut-lsx/src/protocol.rs b/openfut-lsx/src/protocol.rs new file mode 100644 index 0000000..f1e104c --- /dev/null +++ b/openfut-lsx/src/protocol.rs @@ -0,0 +1,914 @@ +//! Request parsing, verb dispatch and log redaction. +//! +//! The dispatch is request-DRIVEN (the Steampunks stub was a blind fixed script), +//! and the frame grammar is the Python's three regexes, transcribed by hand so the +//! crate needs no regex engine. Where the regexes are tolerant, this is tolerant +//! in the same way -- see [`parse_request`]. + +use std::fmt::Write as _; +use std::fs; + +use crate::events::Conn; +use crate::identity::{Identity, CONTENT_ID, ENTITLEMENT_TAG}; +use crate::{env_flag, log, Env, AUTHCODE_FILE, CLIENTID_FILE}; + +/// Attributes of the request's child element, in the order they appeared. +/// +/// Python builds `dict(ATTR_RE.findall(rest))`: a repeated attribute keeps its +/// FIRST position but takes its LAST value, and that dict is echoed into the +/// `<< id=...` log line, so the ordering is observable. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Attrs<'a>(Vec<(&'a str, &'a str)>); + +impl<'a> Attrs<'a> { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn insert(&mut self, key: &'a str, value: &'a str) { + match self.0.iter_mut().find(|(k, _)| *k == key) { + Some(slot) => slot.1 = value, + None => self.0.push((key, value)), + } + } + + pub fn get(&self, key: &str) -> Option<&'a str> { + self.0.iter().find(|(k, _)| *k == key).map(|(_, v)| *v) + } + + /// `attrs.get(key, "")`. + pub fn get_or_empty(&self, key: &str) -> &'a str { + self.get(key).unwrap_or("") + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } + + /// `repr(dict)`, because the request log line embeds it verbatim. + pub fn py_repr(&self) -> String { + let mut s = String::from("{"); + for (i, (k, v)) in self.0.iter().enumerate() { + if i > 0 { + s.push_str(", "); + } + let _ = write!(s, "{}: {}", py_repr(k), py_repr(v)); + } + s.push('}'); + s + } +} + +impl<'a> FromIterator<(&'a str, &'a str)> for Attrs<'a> { + fn from_iter>(iter: T) -> Self { + let mut attrs = Attrs::new(); + for (k, v) in iter { + attrs.insert(k, v); + } + attrs + } +} + +/// One parsed `` frame. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request<'a> { + /// The digits of `id="N"`, kept as text: it is echoed, never arithmetic. + pub id: &'a str, + pub name: &'a str, + pub attrs: Attrs<'a>, + /// The response `sender` must byte-equal this (matcher 0x1471189b0). Captured + /// separately from the rest of the frame, and defaulting to "", so a frame + /// that ever lacks `recipient` still gets answered fast instead of a 15s + /// stall. + pub recipient: &'a str, +} + +/// `REQ_RE = r']*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>'` plus +/// `RECIP_RE = r']*\brecipient="([^"]*)"'`, both as `re.search`. +/// +/// Transcription notes, all of them observable behaviour rather than style: +/// * `[^>]*` cannot cross a `>`, so `id` must live inside the `` +/// tag itself; the tag must be closed for the pattern to match at all. +/// * that leading `[^>]*` is greedy, so with more than one `id="N"` in the tag +/// the RIGHTMOST one wins. +/// * the trailing `([^>]*)/?>` is greedy too, so the captured attribute region +/// keeps a self-closing `/` -- harmless, `ATTR_RE` skips it. +/// * `recipient` is searched over the whole frame independently of the element +/// match, and its value may legally contain `>`. +pub fn parse_request(xml: &str) -> Option> { + const TAG: &str = "') { + Some(i) => from + i, + None => continue, // `[^>]*>` needs the tag to close + }; + let Some(id) = rightmost_id(xml, from, tag_end) else { + continue; + }; + // `>\s*<` + let child = xml[tag_end + 1..].trim_start_matches(char::is_whitespace); + let Some(child) = child.strip_prefix('<') else { + continue; + }; + let name_len = child + .as_bytes() + .iter() + .take_while(|c| c.is_ascii_alphabetic()) + .count(); + if name_len == 0 { + continue; + } + let (name, tail) = child.split_at(name_len); + let Some(gt) = tail.find('>') else { + continue; + }; + return Some(Request { + id, + name, + attrs: parse_attrs(&tail[..gt]), + recipient: find_recipient(xml).unwrap_or(""), + }); + } + None +} + +/// The rightmost `\bid="(\d+)"` inside `xml[from..tag_end]`. +fn rightmost_id(xml: &str, from: usize, tag_end: usize) -> Option<&str> { + let tag = &xml[from..tag_end]; + let mut search_end = tag.len(); + while let Some(at) = tag[..search_end].rfind("id=\"") { + search_end = at; + // `\b`: the character before `id` must not be a word character. Look at + // the whole frame, so `` correctly fails. + if !word_boundary_before(xml, from + at) { + continue; + } + let value = &tag[at + 4..]; + let digits = value.as_bytes().iter().take_while(|c| c.is_ascii_digit()).count(); + if digits > 0 && value.as_bytes().get(digits) == Some(&b'"') { + return Some(&value[..digits]); + } + } + None +} + +fn find_recipient(xml: &str) -> Option<&str> { + const TAG: &str = "').map_or(xml.len(), |i| from + i); + let mut search_end = limit; + while let Some(at) = xml[from..search_end].rfind("recipient=\"") { + let at = from + at; + search_end = at; + if !word_boundary_before(xml, at) { + continue; + } + let value_start = at + "recipient=\"".len(); + if let Some(len) = xml[value_start..].find('"') { + return Some(&xml[value_start..value_start + len]); + } + } + } + None +} + +/// `ATTR_RE.findall` -- `(\w+)="([^"]*)"`, non-overlapping, left to right. +fn parse_attrs(region: &str) -> Attrs<'_> { + let mut attrs = Attrs::new(); + let mut pos = 0; + while let Some(off) = region[pos..].find("=\"") { + let eq = pos + off; + // Greedy `\w+` immediately before `="`. + let name_len: usize = region[..eq] + .chars() + .rev() + .take_while(|c| is_word(*c)) + .map(char::len_utf8) + .sum(); + let value_start = eq + 2; + let Some(value_len) = region[value_start..].find('"') else { + break; // an unterminated value ends the scan, as the regex does + }; + if name_len > 0 { + attrs.insert( + ®ion[eq - name_len..eq], + ®ion[value_start..value_start + value_len], + ); + } + pos = value_start + value_len + 1; + } + attrs +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn word_boundary_before(s: &str, at: usize) -> bool { + at == 0 || !s[..at].chars().next_back().is_some_and(is_word) +} + +// ------------------------------------------------------------------ redaction +const SECRET_ATTRS: [&str; 5] = ["AuthCode", "AuthToken", "SessionKey", "Token", "Sid"]; +const AUTH_CODE_ATTRS: [&str; 3] = ["value", "Code", "Return"]; +const CHALLENGE_ATTRS: [&str; 1] = ["response"]; + +/// Redact credential-bearing LSX attributes from ordinary diagnostics. +/// +/// The blanket pass only catches `Name="..."` pairs; the auth code and the +/// challenge response hide behind generic attribute names (`value`, `Code`, +/// `Return`, `response`), so those two elements get a second, element-scoped pass +/// -- otherwise `` would be redacted too and the ordinary +/// status lines would stop being readable. +pub fn safe_xml_for_log(xml: &str) -> String { + let safe = redact_attrs(xml, &SECRET_ATTRS); + let safe = if safe.contains(" String { + let mut out = String::with_capacity(input.len()); + let mut last = 0; + let mut i = 0; + while i < input.len() { + if !input.is_char_boundary(i) || !word_boundary_before(input, i) { + i += 1; + continue; + } + let rest = &input[i..]; + let hit = names.iter().find_map(|name| { + let after = rest.get(..name.len())?; + if !after.eq_ignore_ascii_case(name) { + return None; + } + let value = rest[name.len()..].strip_prefix("=\"")?; + let len = value.find('"')?; + Some((name.len(), name.len() + 2 + len + 1)) + }); + match hit { + Some((name_len, match_len)) => { + out.push_str(&input[last..i]); + out.push_str(&rest[..name_len]); + out.push_str("=\"[REDACTED]\""); + i += match_len; + last = i; + } + None => i += 1, + } + } + out.push_str(&input[last..]); + out +} + +/// `repr()` of a Python string, for the log lines that embed one. +pub fn py_repr(s: &str) -> String { + let quote = if s.contains('\'') && !s.contains('"') { + '"' + } else { + '\'' + }; + let mut out = String::with_capacity(s.len() + 2); + out.push(quote); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c == quote => { + out.push('\\'); + out.push(c); + } + // Python renders the remaining ASCII controls as \xNN; anything + // printable (including non-ASCII) goes through verbatim. + c if (c as u32) < 0x20 || c as u32 == 0x7f => { + let _ = write!(out, "\\x{:02x}", c as u32); + } + c => out.push(c), + } + } + out.push(quote); + out +} + +// ------------------------------------------------------------------ responses +pub fn resp(mid: &str, body: &str, sender: &str) -> String { + format!(r#"<{body}/>"#) +} + +/// Knobs that shape individual replies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolConfig { + /// A/B-control integrity: v1 (`lsx_responder.py`) answered GetGameInfo + /// FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had + /// silently changed it to "true", which meant `OPENFUT_LSX_EVENTS=0` was NOT a + /// byte-identical control any more. Keep it OFF by default so events-off == + /// v1 exactly; flip `OPENFUT_LSX_FULLGAME=1` to run the FULLGAME="true" + /// experiment on its own. + pub fullgame_purchased: bool, + /// `OPENFUT_AUTHCODE`. + pub auth_code: String, +} + +impl Default for ProtocolConfig { + fn default() -> Self { + Self { + fullgame_purchased: false, + auth_code: format!("OPENFUT-{}", "0".repeat(24)), + } + } +} + +impl ProtocolConfig { + pub fn from_env() -> Self { + Self::from_vars(&crate::os_env) + } + + pub fn from_vars(env: Env<'_>) -> Self { + Self { + fullgame_purchased: env_flag(env, "OPENFUT_LSX_FULLGAME", false), + auth_code: env("OPENFUT_AUTHCODE").unwrap_or_else(|| Self::default().auth_code), + } + } +} + +/// The verb dispatcher. +pub struct Responder { + pub identity: Identity, + pub config: ProtocolConfig, +} + +impl Responder { + pub fn new(identity: Identity, config: ProtocolConfig) -> Self { + Self { identity, config } + } + + /// Request-DRIVEN dispatch. + /// + /// CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher + /// 0x1471189b0 rejects any `` whose `sender` attribute does not + /// byte-equal the `recipient` the client put on the matching `` (it + /// reads `serviceNames[facility]`; with our empty GetConfigResponse all 34 + /// names are "" so recipient="" for every verb after GetConfig, which itself + /// uses the hard-coded literal "EbisuSDK"). We were answering GetProfile/ + /// GetAuthCode/QueryEntitlements with sender="EbisuSDK" -> silently discarded + /// -> GetProfile (the SOLE writer of OriginSDK+0x3a0 default-user) never took + /// -> the whole online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO + /// the request's recipient back as the response sender, which is what every + /// `reply` below does. + /// + /// `conn` is `None` in the selftest; a `None` connection must not touch the + /// run's success-signal files. + pub fn build_reply( + &self, + mid: &str, + req_name: &str, + attrs: &Attrs<'_>, + conn: Option<&Conn>, + recipient: &str, + ) -> String { + let reply = |body: &str| resp(mid, body, recipient); + + match req_name { + // FLAG (1): "internet is reachable". The stub hardcoded + // connected="0" -> "log in to Origin". This is NOT the logged-in + // flag; see the crate docs. + "GetInternetConnectedState" => reply(r#"InternetConnectedState connected="1""#), + + // Request shape is built at 0x14713b8d0: + // + // Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode". + // + // THE ATTRIBUTE NAME IS "value" -- verified, not guessed: the + // "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0 + // -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one + // attribute name (ns-prefix for "lsx" @0x14394def0, then "value" + // @0x1436c7768, concat at 0x14712d130) and does exactly ONE + // get-attribute-as-string call 0x14713fe50(node, "value", &dest). + // dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands + // at +0xc8 -- which is what OriginRequestAuthCodeSync's impl + // 0x1470e67f0 reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as + // *out_len. Code=/Return= are NEVER read; with them alone the parsed + // string is empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the + // OSDK classifier 0x14717d5d0 falls into its `test rbp,rbp / je` arm + // and reports OSDK_UNDERAGE_ERROR (a mislabelled "no auth code" + // fallback). Code=/Return= are kept only as harmless padding. + "GetAuthCode" => { + let client_id = attrs.get_or_empty("ClientId"); + let scope = attrs.get_or_empty("Scope"); + let code = &self.config.auth_code; + // Only touch the run's success-signal files on a REAL request. The + // selftest passes conn=None; if it wrote these files it would + // pre-satisfy watch-step "authcode.txt becomes non-empty" and make + // a non-event read as success on the next live run. + if let Some(conn) = conn { + for (path, val) in [(AUTHCODE_FILE, code.as_str()), (CLIENTID_FILE, client_id)] + { + if let Err(e) = fs::write(path, val) { + log!("could not write {path}: {e}"); + } + } + // GetAuthCode has fired: stop the heartbeat so we do not keep + // re-pushing Login/OnlineStatus events during Blaze login. + conn.stop_events(); + } + log!("*** GetAuthCode ISSUED ***"); + log!( + " ClientId={} Scope={}", + py_repr(client_id), + py_repr(scope) + ); + log!(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)"); + reply(&format!( + r#"AuthCode value="{code}" Code="{code}" Return="{code}""# + )) + } + + // The only reply with a child element, so it cannot use the + // single-element `resp` helper. + "QueryEntitlements" => format!( + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ), + mid = mid, + recipient = recipient, + tag = ENTITLEMENT_TAG, + content = CONTENT_ID + ), + + // This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8] + // (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona + // @0x1470da680 are bare reads of those fields, written only by + // OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete. + // ONLY PersonaId/UserId/Persona come from the identity; the rest of + // this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/ + // AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4 + // and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim. + "GetProfile" => reply(&format!( + concat!( + r#"GetProfileResponse IsSubscriber="true" PersonaId="{persona}" "#, + r#"AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" "#, + r#"UserId="{user}" Persona="{name}" IsUnderAge="false" "#, + r#"CommerceCurrency="USD""# + ), + persona = self.identity.persona_id, + user = self.identity.user_id(), + name = self.identity.persona_name + )), + + "GetGameInfo" => match attrs.get("GameInfoId") { + Some("LANGUAGES") => reply(concat!( + r#"GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,"#, + r#"en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,"#, + r#"pt_PT,ru_RU,sv_SE,tr_TR,zh_TW""# + )), + // MUST be true or the client shows "Your title version is + // outdated" and blocks all online features. + Some("UPTODATE") => reply(r#"GetGameInfoResponse GameInfo="true""#), + // OFF by default: v1 answered "false" here (fell through to the + // default), and keeping this gated makes OPENFUT_LSX_EVENTS=0 + // byte-identical to v1. + Some("FULLGAME_PURCHASED") if self.config.fullgame_purchased => { + reply(r#"GetGameInfoResponse GameInfo="true""#) + } + // FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a + // trial; matches v1 exactly) + _ => reply(r#"GetGameInfoResponse GameInfo="false""#), + }, + + "GetSetting" => { + // The client asks in UPPERCASE. + match attrs.get_or_empty("SettingId").to_uppercase().as_str() { + "ENVIRONMENT" | "ENVIRONMENTNAME" => { + reply(r#"GetSettingResponse Setting="production""#) + } + "LANGUAGE" => reply(&format!( + r#"GetSettingResponse Setting="{}""#, + self.identity.locale + )), + _ => reply(r#"GetSettingResponse Setting="false""#), + } + } + + "GetConfig" => reply(r#"GetConfigResponse Config="false""#), + + "IsProgressiveInstallationAvailable" => reply(concat!( + r#"IsProgressiveInstallationAvailableResponse ItemId="" "#, + r#"Available="false""# + )), + + _ => reply(r#"ErrorSuccess Code="0" Description="""#), + } + } +} + +/// Trigger points: push right after answering these verbs. GetProfile is the +/// earliest safe moment -- by then the SDK has built its handler set and has a +/// default user, so a Login event has somewhere to land. +/// +/// For GetGameInfo the caller only fires on UPTODATE, otherwise we would push +/// three times per boot for FREETRIAL/LANGUAGES too. +pub fn push_after(req_name: &str) -> Option<&'static str> { + match req_name { + "GetProfile" => Some("after GetProfile"), + "GetInternetConnectedState" => Some("after GetInternetConnectedState"), + "GetGameInfo" => Some("after GetGameInfo UPTODATE"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + fn responder() -> Responder { + Responder::new(Identity::default(), ProtocolConfig::default()) + } + + fn reply(verb: &str, attrs: &[(&str, &str)]) -> String { + responder().build_reply("7", verb, &attrs.iter().copied().collect(), None, "") + } + + // ---------------------------------------------------------------- parsing + #[test] + fn parses_a_request_with_a_recipient() { + let xml = r#""#; + let req = parse_request(xml).unwrap(); + assert_eq!(req.id, "12"); + assert_eq!(req.name, "GetConfig"); + assert_eq!(req.recipient, "EbisuSDK"); + assert_eq!(req.attrs.get("Locale"), Some("en_US")); + assert_eq!(req.attrs.get("Env"), Some("prod")); + assert_eq!(req.attrs.len(), 2); + } + + #[test] + fn parses_a_request_without_a_recipient() { + // Must still be answered -- fast -- rather than stalling ~15s. + let xml = r#""#; + let req = parse_request(xml).unwrap(); + assert_eq!((req.id, req.name, req.recipient), ("3", "GetProfile", "")); + assert!(req.attrs.is_empty()); + } + + #[test] + fn parses_an_empty_recipient() { + let xml = r#""#; + let req = parse_request(xml).unwrap(); + assert_eq!(req.recipient, ""); + assert_eq!(req.attrs.get("SettingId"), Some("LANGUAGE")); + } + + #[test] + fn tolerates_whitespace_between_the_request_and_its_element() { + let xml = "\n \n"; + let req = parse_request(xml).unwrap(); + assert_eq!((req.id, req.name, req.recipient), ("4", "GetAuthCode", "X")); + assert_eq!(req.attrs.get("ClientId"), Some("c")); + assert_eq!(req.attrs.get("Scope"), Some("s")); + } + + #[test] + fn rejects_frames_that_are_not_requests() { + for xml in [ + "", + r#""#, + r#""#, // no id + r#""#, // id not digits + r#""#, // \b fails + r#""#, // no element + ] { + assert!(parse_request(xml).is_none(), "{xml}"); + } + } + + #[test] + fn a_repeated_attribute_keeps_its_place_and_takes_the_last_value() { + let xml = r#""#; + let req = parse_request(xml).unwrap(); + assert_eq!(req.attrs.py_repr(), "{'a': '3', 'b': '2'}"); + } + + #[test] + fn greedy_id_takes_the_rightmost_one() { + let xml = r#""#; + assert_eq!(parse_request(xml).unwrap().id, "2"); + } + + #[test] + fn the_self_closing_slash_does_not_become_an_attribute() { + let xml = r#""#; + let req = parse_request(xml).unwrap(); + assert_eq!(req.attrs.len(), 1); + assert_eq!(req.attrs.get("GameInfoId"), Some("UPTODATE")); + } + + // ------------------------------------------------------------------ verbs + #[test] + fn internet_connected_state_is_one() { + assert_eq!( + reply("GetInternetConnectedState", &[]), + r#""# + ); + } + + #[test] + fn auth_code_carries_the_value_attribute() { + let r = reply("GetAuthCode", &[("ClientId", "X"), ("Scope", "Y")]); + assert_eq!( + r, + concat!( + r#""# + ) + ); + } + + #[test] + fn auth_code_honours_the_env_override() { + let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_AUTHCODE", "ABC")])); + let r = Responder::new(Identity::default(), cfg).build_reply( + "1", + "GetAuthCode", + &Attrs::new(), + None, + "", + ); + assert!(r.contains(r#""#), "{r}"); + } + + #[test] + fn query_entitlements_owns_the_retail_offer() { + assert_eq!( + reply("QueryEntitlements", &[]), + concat!( + r#""#, + r#""#, + r#""# + ) + ); + } + + #[test] + fn profile_carries_the_identity_and_the_verbatim_template() { + assert_eq!( + reply("GetProfile", &[]), + concat!( + r#""# + ) + ); + } + + #[test] + fn profile_echoes_an_overridden_persona() { + let ident = Identity::from_vars(&env_of(&[ + ("FUT_PERSONA_ID", "42"), + ("FUT_PERSONA_NAME", "ZED"), + ])) + .unwrap(); + let r = Responder::new(ident, ProtocolConfig::default()) + .build_reply("1", "GetProfile", &Attrs::new(), None, ""); + assert!(r.contains(r#"PersonaId="42""#), "{r}"); + assert!(r.contains(r#"UserId="42""#), "{r}"); + assert!(r.contains(r#"Persona="ZED""#), "{r}"); + } + + #[test] + fn game_info_languages_and_uptodate() { + let r = reply("GetGameInfo", &[("GameInfoId", "LANGUAGES")]); + assert!(r.contains(r#"GameInfo="ar_SA,cs_CZ,"#), "{r}"); + assert!(r.ends_with(r#"tr_TR,zh_TW"/>"#), "{r}"); + assert!(reply("GetGameInfo", &[("GameInfoId", "UPTODATE")]) + .contains(r#"GetGameInfoResponse GameInfo="true""#)); + } + + #[test] + fn fullgame_purchased_is_false_unless_the_knob_is_set() { + // A/B-control integrity: events-off must stay byte-identical to v1. + for id in ["FULLGAME_PURCHASED", "FREETRIAL", "ANYTHING_ELSE"] { + assert!( + reply("GetGameInfo", &[("GameInfoId", id)]) + .contains(r#"GetGameInfoResponse GameInfo="false""#), + "{id}" + ); + } + // ... and a GetGameInfo with no GameInfoId at all. + assert!(reply("GetGameInfo", &[]).contains(r#"GameInfo="false""#)); + + let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", "1")])); + let on = Responder::new(Identity::default(), cfg); + assert!(on + .build_reply( + "7", + "GetGameInfo", + &[("GameInfoId", "FULLGAME_PURCHASED")].into_iter().collect(), + None, + "" + ) + .contains(r#"GameInfo="true""#)); + // Still only that one id flips. + assert!(on + .build_reply( + "7", + "GetGameInfo", + &[("GameInfoId", "FREETRIAL")].into_iter().collect(), + None, + "" + ) + .contains(r#"GameInfo="false""#)); + } + + #[test] + fn fullgame_knob_follows_the_not_zero_rule() { + for (value, on) in [("0", false), ("1", true), ("", true), ("false", true)] { + let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", value)])); + assert_eq!(cfg.fullgame_purchased, on, "OPENFUT_LSX_FULLGAME={value:?}"); + } + } + + #[test] + fn settings() { + for id in ["ENVIRONMENT", "environment", "EnvironmentName"] { + assert!( + reply("GetSetting", &[("SettingId", id)]) + .contains(r#"GetSettingResponse Setting="production""#), + "{id}" + ); + } + assert!(reply("GetSetting", &[("SettingId", "LANGUAGE")]) + .contains(r#"GetSettingResponse Setting="en_US""#)); + assert!(reply("GetSetting", &[("SettingId", "WHATEVER")]) + .contains(r#"GetSettingResponse Setting="false""#)); + assert!(reply("GetSetting", &[]).contains(r#"Setting="false""#)); + + let ident = Identity::from_vars(&env_of(&[("FUT_LOCALE", "fr_FR")])).unwrap(); + assert!(Responder::new(ident, ProtocolConfig::default()) + .build_reply( + "7", + "GetSetting", + &[("SettingId", "LANGUAGE")].into_iter().collect(), + None, + "" + ) + .contains(r#"Setting="fr_FR""#)); + } + + #[test] + fn config_is_empty_which_is_why_every_recipient_is_the_empty_string() { + assert!(reply("GetConfig", &[]).contains(r#"GetConfigResponse Config="false""#)); + } + + #[test] + fn progressive_installation_is_unavailable() { + assert!(reply("IsProgressiveInstallationAvailable", &[]).contains( + r#"IsProgressiveInstallationAvailableResponse ItemId="" Available="false""# + )); + } + + #[test] + fn unknown_verbs_fall_through_to_error_success() { + assert_eq!( + reply("GetSomethingWeHaveNeverSeen", &[]), + r#""# + ); + } + + #[test] + fn the_response_sender_byte_equals_the_request_recipient() { + let r = responder().build_reply("5", "GetProfile", &Attrs::new(), None, "EbisuSDK"); + assert!(r.starts_with(r#""#), "{r}"); + // Including for the one verb built without the `resp` helper. + let q = responder().build_reply("5", "QueryEntitlements", &Attrs::new(), None, "EbisuSDK"); + assert!(q.starts_with(r#""#), "{q}"); + } + + #[test] + fn push_triggers() { + assert_eq!(push_after("GetProfile"), Some("after GetProfile")); + assert_eq!( + push_after("GetInternetConnectedState"), + Some("after GetInternetConnectedState") + ); + assert_eq!(push_after("GetGameInfo"), Some("after GetGameInfo UPTODATE")); + assert_eq!(push_after("GetConfig"), None); + assert_eq!(push_after("GetAuthCode"), None); + } + + // -------------------------------------------------------------- redaction + #[test] + fn redacts_credential_attributes() { + let safe = safe_xml_for_log( + r#""#, + ); + assert_eq!( + safe, + concat!( + r#""# + ) + ); + } + + #[test] + fn redacts_the_auth_code_element_generically() { + let safe = + safe_xml_for_log(r#""#); + assert!(!safe.contains("secret"), "{safe}"); + assert_eq!(safe.matches("[REDACTED]").count(), 3, "{safe}"); + } + + #[test] + fn redacts_the_challenge_response() { + let safe = safe_xml_for_log( + r#""#, + ); + assert!(!safe.contains("e4f5"), "{safe}"); + assert!(safe.contains(r#"response="[REDACTED]""#), "{safe}"); + assert!(safe.contains(r#"id="1""#), "{safe}"); + } + + #[test] + fn leaves_ordinary_status_lines_readable() { + let status = safe_xml_for_log(r#""#); + assert_eq!(status, r#""#); + // The element name alone must not trip the blanket pass. + assert_eq!( + safe_xml_for_log(r#""#), + r#""# + ); + } + + #[test] + fn redaction_is_case_insensitive_and_keeps_the_written_case() { + assert_eq!( + safe_xml_for_log(r#""#), + r#""# + ); + } + + #[test] + fn redaction_respects_word_boundaries() { + // `MySid="x"` has no boundary before `Sid`, so it is left alone. + assert_eq!( + safe_xml_for_log(r#""#), + r#""# + ); + } + + #[test] + fn python_repr_of_strings() { + assert_eq!(py_repr(""), "''"); + assert_eq!(py_repr("X"), "'X'"); + assert_eq!(py_repr("it's"), "\"it's\""); + assert_eq!(py_repr("a\"b"), "'a\"b'"); + assert_eq!(py_repr("a\nb"), r"'a\nb'"); + assert_eq!(py_repr("a\\b"), r"'a\\b'"); + assert_eq!(Attrs::new().py_repr(), "{}"); + } +}