Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 750d6c2e18 | |||
| 082246c085 |
Generated
+42
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# FIFA 17 "FUT Squad Update" download failure — root cause
|
||||
|
||||
**Status:** root-caused, not yet fixed. Blocks any FIFA session pointed at a stack whose
|
||||
roster server presents the current self-signed certificate.
|
||||
|
||||
**Symptom (client):** entering the FUT hub shows
|
||||
|
||||
> An error occurred downloading the FUT Squad Update. Please try again.
|
||||
|
||||
Recovered from the live client's memory with `scripts/client-error-string.py`, not from a
|
||||
log. Nothing server-side reports an error: every UTAS route answers 200 and the Blaze
|
||||
session stays healthy, answering PINGs while the client sits on the dialog.
|
||||
|
||||
## What it is
|
||||
|
||||
"Squad Update" here is the **roster update**, not the player's lineup. The Blaze responder
|
||||
advertises it as the FUT loading gate (`blaze_responder_v3b.py`):
|
||||
|
||||
```python
|
||||
ROSTER_HOST = "%s:8081" % _ADVERTISE
|
||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST)
|
||||
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST)
|
||||
```
|
||||
|
||||
The client fetches that URL over TLS, **by IP**, from `roster_server.py`.
|
||||
|
||||
## Root cause
|
||||
|
||||
The client completes TCP, sends a ClientHello, receives the certificate, and then
|
||||
**aborts the handshake itself**. Captured and decoded from the wire:
|
||||
|
||||
```
|
||||
CLIENT: ClientHello client_version = TLS1.2
|
||||
offers 8 suites: RSA_AES_256_GCM_SHA384, RSA_AES_128_GCM_SHA256,
|
||||
RSA_AES_256_CBC_SHA256, RSA_AES_128_CBC_SHA256, RSA_AES_256_CBC_SHA,
|
||||
RSA_AES_128_CBC_SHA, RSA_RC4_128_SHA, RSA_RC4_128_MD5
|
||||
SERVER: ServerHello TLS1.2 -> Certificate (909 B) -> ServerHelloDone
|
||||
CLIENT: Alert level=fatal desc=certificate_unknown
|
||||
```
|
||||
|
||||
Protocol and cipher selection are **compatible** — TLS1.2 with an RSA suite is offered and
|
||||
chosen. The client rejects the **certificate**:
|
||||
|
||||
```
|
||||
subject/issuer = CN = winter15.gosredirector.ea.com (self-signed, CA:TRUE)
|
||||
SAN = DNS:winter15.gosredirector.ea.com, DNS:*.gosredirector.ea.com, DNS:*.ea.com
|
||||
notBefore = Aug 13 01:12:30 2026
|
||||
sha256 = FF:66:69:48:E2:02:AB:F4:A4:40:78:B1:8C:E1:F9:65:96:32:BB:FF:1F:3D:CC:BC:07:54:02:11:8F:4D:12:3F
|
||||
```
|
||||
|
||||
Two facts matter:
|
||||
|
||||
1. **The SAN carries DNS names only, no `iPAddress` entry**, while the advertised URL is an
|
||||
IP literal (`https://10.10.0.120:8081/...`). A DNS-only SAN cannot match an IP dial.
|
||||
2. **The certificate was regenerated on Aug 13.** `roster_server.py` loads the same
|
||||
`redir_cert.pem`/`redir_key.pem` as the Blaze redirector, so the redirector rejects
|
||||
identically — visible as `REDIR REJECTED ... TLS/SSL connection has been closed (EOF)`
|
||||
in the Blaze log. That is tolerated only because the client then reaches Blaze main over
|
||||
plain TCP; the roster fetch has no such fallback, so it is where the failure surfaces.
|
||||
|
||||
`roster_server.py` still carries the comment *"ProtoSSL cert-verify is patched (autopatch),
|
||||
so our self-signed cert is accepted."* That assumption no longer holds for this path.
|
||||
autopatch demonstrably patched both gates in the failing process — the log shows
|
||||
`pid 56298: PATCHED cert gates` and the live bytes read back as the patched patterns
|
||||
(`909090909090` at `0x146132548`, `31c0c3` at `0x1461361b0`) — and the client still sent
|
||||
`certificate_unknown`. So **those two gates do not govern this validation**; either another
|
||||
check exists on the roster path, or it validates before reaching them.
|
||||
|
||||
## Ruled out, with evidence
|
||||
|
||||
| Hypothesis | Evidence against |
|
||||
| --- | --- |
|
||||
| Missing/mistyped UTAS field | 0 structural differences across 14 hub routes vs production (`scripts/hub-diff-prod-staging.py`) |
|
||||
| Player squad shape | matches production field-for-field, including `squadType`, `custom`, `chemistry`, `manager` |
|
||||
| Squad save | PUT/GET round-trips exactly, every field and slot |
|
||||
| Cert gates unpatched | autopatch log + live byte read both confirm patched |
|
||||
| Roster server broken | serves `200`, `application/xml` over TLS1.2 `AES256-GCM-SHA384` |
|
||||
| Advertised host wrong | production and staging Blaze envs are identical for `OPENFUT_ADVERTISE`, `POW_*`; the scripts are byte-identical |
|
||||
| TLS version mismatch | client offers TLS1.2 and the server selects it |
|
||||
| Blaze session | healthy, PING/PING_REPLY throughout |
|
||||
|
||||
## Probing gotcha
|
||||
|
||||
A default modern Python TLS context gets `SSLV3_ALERT_HANDSHAKE_FAILURE` against this
|
||||
server and looks like a server fault. It is not — the server requires legacy-compatible
|
||||
cipher selection. Probe with `ALL:@SECLEVEL=0`, and note it rejects TLS1.0/1.1 outright
|
||||
(`TLSV1_ALERT_PROTOCOL_VERSION`), accepting only TLS1.2.
|
||||
|
||||
## Why production appears unaffected
|
||||
|
||||
Unresolved, and worth stating plainly rather than guessing. Production advertises the same
|
||||
URL, backed by the same server and certificate, so the same rejection should occur. The
|
||||
operator reports production is clean. The likely explanation is that a production client
|
||||
does not re-fetch the roster (already satisfied), while a session against a different Blaze
|
||||
forces the fetch — but that has not been measured. The known-good hook log used as a
|
||||
control (Aug 12 19:13) **predates the Aug 13 certificate regeneration**, so it is not
|
||||
evidence about the current certificate.
|
||||
|
||||
## Fix options (none applied)
|
||||
|
||||
1. **Reissue the certificate with an `iPAddress` SAN** for the advertised address. Smallest
|
||||
change, addresses the concrete mismatch, and benefits the redirector too. Note
|
||||
`openfut-tls` exists precisely so the redirector and roster hosts cannot configure TLS
|
||||
separately — the 2026-08-11 mismatch it was written for is the same class of bug.
|
||||
2. **Serve the roster over plain HTTP** for an isolated stack, by pointing
|
||||
`ROSTERUPDATE_URL`/`ROSTER_URL` at a staging-owned HTTP server. Sidesteps TLS entirely;
|
||||
only acceptable off production.
|
||||
3. **Find the real validation site** on the roster path and extend the patch set. Most
|
||||
faithful, most work, and the only option that restores the original assumption.
|
||||
|
||||
Option 1 is the recommended starting point because it is testable without the client:
|
||||
a probe that validates hostname/IP against the presented certificate will fail before the
|
||||
fix and pass after.
|
||||
@@ -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/<pid>/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]
|
||||
@@ -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/<pid>/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/<pid>/comm` of the client we patch (exact match after trimming).
|
||||
pub const CLIENT_COMM: &str = "FIFA17.exe";
|
||||
|
||||
/// Substring identifying the CardsDLL mapping in `/proc/<pid>/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/<pid>/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/<pid>/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<u32> {
|
||||
if !name.as_bytes().first().is_some_and(u8::is_ascii_digit) {
|
||||
return None;
|
||||
}
|
||||
name.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Base address of the first `CardsDLL` mapping in a `/proc/<pid>/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<u64> {
|
||||
let line = maps.lines().find(|line| line.contains(CARDSDLL_MARKER))?;
|
||||
u64::from_str_radix(line.split('-').next()?, 16).ok()
|
||||
}
|
||||
|
||||
/// `--launcher-pid <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<I, S>(args: I) -> Result<Option<i64>, ()>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let args: Vec<S> = 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::<i64>().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::<String>::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(()));
|
||||
}
|
||||
}
|
||||
@@ -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<Option<TzData>> = 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<TzData> {
|
||||
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<TzData> {
|
||||
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<usize> {
|
||||
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<TzData> {
|
||||
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<u8> {
|
||||
fn block(types: &[(i32, bool)], transitions: &[(i64, u8)], time_len: usize) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `[HH:MM:SS] <msg>` 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-<uid>.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<Self> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<u32>,
|
||||
store_patched: HashSet<u32>,
|
||||
guard_reported: HashSet<u32>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<M: Memory>(
|
||||
mem: &M,
|
||||
log: &mut impl FnMut(&str),
|
||||
patched: &mut HashSet<u32>,
|
||||
) -> 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<M: Memory>(
|
||||
mem: &M,
|
||||
cbase: u64,
|
||||
log: &mut impl FnMut(&str),
|
||||
guard_reported: &mut HashSet<u32>,
|
||||
) -> 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=<version>` 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/<pid>/mem` refusing an address the packer has not produced yet.
|
||||
struct FakeMemory {
|
||||
bytes: RefCell<BTreeMap<u64, u8>>,
|
||||
/// Writes to these addresses fail.
|
||||
fail_writes: Vec<u64>,
|
||||
/// Writes to these addresses report success but change nothing (the
|
||||
/// VERIFY_FAILED shape).
|
||||
swallow_writes: Vec<u64>,
|
||||
/// Reads of these addresses fail even when mapped.
|
||||
fail_reads: Vec<u64>,
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<String>);
|
||||
|
||||
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<String> = 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! `/proc` access: finding the client, locating CardsDLL, and positioned reads
|
||||
//! and writes against `/proc/<pid>/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<u32> {
|
||||
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<u64> {
|
||||
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/<pid>/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/<pid>` 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<u32> {
|
||||
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/<pid>/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());
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule openfut-launcher updated: c5424158b9...1cd4f18e92
@@ -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"
|
||||
@@ -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 `<Challenge key="...">` in PLAINTEXT; the client
|
||||
//! answers plaintext with `response=`/`key=`; the server answers
|
||||
//! `<ChallengeAccepted response="H">` 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<String> = 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<String, CryptoError> {
|
||||
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<u8> {
|
||||
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<String, CryptoError> {
|
||||
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<Vec<u8>, CryptoError> {
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
let mut hi: Option<u8> = 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#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#;
|
||||
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#"<LSX><Response id="1" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#,
|
||||
r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
"",
|
||||
] {
|
||||
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("<LSX/>", &k);
|
||||
enc.extend_from_slice(b"deadbeef\0trailing");
|
||||
assert_eq!(lsx_decrypt(&enc, &k).unwrap(), "<LSX/>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_strips_surrounding_whitespace() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
let enc = lsx_encrypt("<LSX/>", &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(), "<LSX/>");
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 `<Challenge>` that already
|
||||
//! works, i.e. `<LSX><Event sender="..."><Element .../></Event></LSX>`. 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#"<LSX><Event sender="{sender}"><{element}/></Event></LSX>"#)
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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 `<Login>` 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, ConfigError> {
|
||||
Self::from_vars(&crate::os_env)
|
||||
}
|
||||
|
||||
pub fn from_vars(env: Env<'_>) -> Result<Self, ConfigError> {
|
||||
let period_secs = match env("OPENFUT_LSX_EVENT_PERIOD") {
|
||||
Some(v) => v.trim().parse::<f64>().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::<i64>().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<TcpStream>,
|
||||
key: RwLock<Option<Key>>,
|
||||
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<Arc<Self>> {
|
||||
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<Key> {
|
||||
*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 <Login IsLoggedIn=\"true\"> 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 `<Login>` 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<String> {
|
||||
let map: HashMap<String, String> = 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#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender="LOGIN"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender=""><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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, ConfigError> {
|
||||
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<Self, ConfigError> {
|
||||
let persona_id = match env("FUT_PERSONA_ID") {
|
||||
// `int()` tolerates surrounding whitespace and a sign.
|
||||
Some(v) => v.trim().parse::<i64>().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<String> {
|
||||
let map: HashMap<String, String> = 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")"#);
|
||||
}
|
||||
}
|
||||
@@ -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 `<Event sender="LOGIN_EVENT"><Login/>`.
|
||||
//! 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 `<Login>`
|
||||
//! 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<String>;
|
||||
|
||||
/// 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<String> {
|
||||
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();
|
||||
}};
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<Conn>,
|
||||
responder: &Responder,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. plaintext Challenge
|
||||
conn.send_plain(&format!(
|
||||
r#"<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" build="{BUILD}" version="{VERSION}"/></Event></LSX>"#
|
||||
))?;
|
||||
|
||||
// 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<Vec<u8>> = 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<dyn std::error::Error>> {
|
||||
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#"<Event sender=""><Login IsLoggedIn="true"/></Event>"#),
|
||||
&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("<AuthCode value="), &r)?;
|
||||
|
||||
let redacted = safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
|
||||
check(
|
||||
!redacted.contains("secret") && redacted.matches("[REDACTED]").count() == 3,
|
||||
&redacted,
|
||||
)?;
|
||||
let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||
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}")))
|
||||
}
|
||||
}
|
||||
@@ -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<Item = (&'a str, &'a str)> + '_ {
|
||||
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<T: IntoIterator<Item = (&'a str, &'a str)>>(iter: T) -> Self {
|
||||
let mut attrs = Attrs::new();
|
||||
for (k, v) in iter {
|
||||
attrs.insert(k, v);
|
||||
}
|
||||
attrs
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed `<Request>` 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'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>'` plus
|
||||
/// `RECIP_RE = r'<Request[^>]*\brecipient="([^"]*)"'`, both as `re.search`.
|
||||
///
|
||||
/// Transcription notes, all of them observable behaviour rather than style:
|
||||
/// * `[^>]*` cannot cross a `>`, so `id` must live inside the `<Request ...>`
|
||||
/// 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<Request<'_>> {
|
||||
const TAG: &str = "<Request";
|
||||
let mut from = 0;
|
||||
while let Some(off) = xml[from..].find(TAG) {
|
||||
let start = from + off;
|
||||
from = start + TAG.len();
|
||||
let tag_end = match xml[from..].find('>') {
|
||||
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 `<Requestid="1">` 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 = "<Request";
|
||||
let mut from = 0;
|
||||
while let Some(off) = xml[from..].find(TAG) {
|
||||
from += off + TAG.len();
|
||||
// The literal must start before the tag closes; the VALUE may run past it.
|
||||
let limit = xml[from..].find('>').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 `<ErrorSuccess Code="0">` 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("<AuthCode ") {
|
||||
redact_attrs(&safe, &AUTH_CODE_ATTRS)
|
||||
} else {
|
||||
safe
|
||||
};
|
||||
if safe.contains("<ChallengeAccepted ") {
|
||||
redact_attrs(&safe, &CHALLENGE_ATTRS)
|
||||
} else {
|
||||
safe
|
||||
}
|
||||
}
|
||||
|
||||
/// `re.sub(r'(?i)\b(a|b|c)="[^"]*"', r'\1="[REDACTED]"', xml)`; the attribute name
|
||||
/// keeps the case it was written in.
|
||||
fn redact_attrs(input: &str, names: &[&str]) -> 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#"<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>"#)
|
||||
}
|
||||
|
||||
/// 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 `<Response>` whose `sender` attribute does not
|
||||
/// byte-equal the `recipient` the client put on the matching `<Request>` (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:
|
||||
// <GetAuthCode ClientId="..." Scope="..."/>
|
||||
// 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#"<LSX><Response id="{mid}" sender="{recipient}">"#,
|
||||
r#"<QueryEntitlementsResponse>"#,
|
||||
r#"<OriginItem ItemId="{tag}" EntitlementId="1" "#,
|
||||
r#"ResourceId="{content}" OfferId="{content}" "#,
|
||||
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
|
||||
r#"</QueryEntitlementsResponse>"#,
|
||||
r#"</Response></LSX>"#
|
||||
),
|
||||
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<String> {
|
||||
let map: HashMap<String, String> = 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#"<LSX><Request id="12" recipient="EbisuSDK"><GetConfig Locale="en_US" Env="prod"/></Request></LSX>"#;
|
||||
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#"<LSX><Request id="3"><GetProfile/></Request></LSX>"#;
|
||||
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#"<LSX><Request id="9" recipient=""><GetSetting SettingId="LANGUAGE"/></Request></LSX>"#;
|
||||
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 = "<LSX><Request id=\"4\" recipient=\"X\">\n <GetAuthCode ClientId=\"c\" Scope=\"s\" />\n</Request></LSX>";
|
||||
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#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Request recipient="X"><GetConfig/></Request></LSX>"#, // no id
|
||||
r#"<LSX><Request id="abc"><GetConfig/></Request></LSX>"#, // id not digits
|
||||
r#"<LSX><Requestid="1"><GetConfig/></Request></LSX>"#, // \b fails
|
||||
r#"<LSX><Request id="1">"#, // 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#"<LSX><Request id="1"><V a="1" b="2" a="3"/></Request></LSX>"#;
|
||||
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#"<LSX><Request id="1" other-id="2"><GetConfig/></Request></LSX>"#;
|
||||
assert_eq!(parse_request(xml).unwrap().id, "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_self_closing_slash_does_not_become_an_attribute() {
|
||||
let xml = r#"<LSX><Request id="1"><GetGameInfo GameInfoId="UPTODATE"/></Request></LSX>"#;
|
||||
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#"<LSX><Response id="7" sender=""><InternetConnectedState connected="1"/></Response></LSX>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_code_carries_the_value_attribute() {
|
||||
let r = reply("GetAuthCode", &[("ClientId", "X"), ("Scope", "Y")]);
|
||||
assert_eq!(
|
||||
r,
|
||||
concat!(
|
||||
r#"<LSX><Response id="7" sender=""><AuthCode "#,
|
||||
r#"value="OPENFUT-000000000000000000000000" "#,
|
||||
r#"Code="OPENFUT-000000000000000000000000" "#,
|
||||
r#"Return="OPENFUT-000000000000000000000000"/></Response></LSX>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[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#"<AuthCode value="ABC" Code="ABC" Return="ABC"/>"#), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_entitlements_owns_the_retail_offer() {
|
||||
assert_eq!(
|
||||
reply("QueryEntitlements", &[]),
|
||||
concat!(
|
||||
r#"<LSX><Response id="7" sender=""><QueryEntitlementsResponse>"#,
|
||||
r#"<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" "#,
|
||||
r#"ResourceId="1027460" OfferId="1027460" "#,
|
||||
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
|
||||
r#"</QueryEntitlementsResponse></Response></LSX>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_carries_the_identity_and_the_verbatim_template() {
|
||||
assert_eq!(
|
||||
reply("GetProfile", &[]),
|
||||
concat!(
|
||||
r#"<LSX><Response id="7" sender=""><GetProfileResponse IsSubscriber="true" "#,
|
||||
r#"PersonaId="33068179" AvatarId="" Country="US" CommerceCountry="US" "#,
|
||||
r#"GeoCountry="US" UserId="33068179" Persona="CAGE" IsUnderAge="false" "#,
|
||||
r#"CommerceCurrency="USD"/></Response></LSX>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[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"/></Response></LSX>"#), "{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#"<LSX><Response id="7" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[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#"<LSX><Response id="5" sender="EbisuSDK">"#), "{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#"<LSX><Response id="5" sender="EbisuSDK">"#), "{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#"<Thing AuthCode="a" AuthToken="b" SessionKey="c" Token="d" Sid="e" Keep="f"/>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
safe,
|
||||
concat!(
|
||||
r#"<Thing AuthCode="[REDACTED]" AuthToken="[REDACTED]" "#,
|
||||
r#"SessionKey="[REDACTED]" Token="[REDACTED]" Sid="[REDACTED]" Keep="f"/>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_the_auth_code_element_generically() {
|
||||
let safe =
|
||||
safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
|
||||
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#"<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="e4f5"/></Response></LSX>"#,
|
||||
);
|
||||
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#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||
assert_eq!(status, r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||
// The element name alone must not trip the blanket pass.
|
||||
assert_eq!(
|
||||
safe_xml_for_log(r#"<GetConfigResponse Config="false"/>"#),
|
||||
r#"<GetConfigResponse Config="false"/>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_is_case_insensitive_and_keeps_the_written_case() {
|
||||
assert_eq!(
|
||||
safe_xml_for_log(r#"<X authtoken="q" SID="r"/>"#),
|
||||
r#"<X authtoken="[REDACTED]" SID="[REDACTED]"/>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[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#"<X MySid="x"/>"#),
|
||||
r#"<X MySid="x"/>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[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(), "{}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user