feat(companions): port the launcher's two Python services to Rust
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).
openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.
openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').
The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.
VERIFICATION — the ports are checked against the Python, not against themselves:
* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
constants, which proves consistency, not parity, and the Python cannot run here
(pycryptodome absent) with the client host unreachable. So the LCG and key derivation
were transcribed from the Python and run as plain arithmetic, and every AES value came
from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
netns, so the smoke test runs under `unshare -n` — the real binary on the port the
client actually dials, with no port-override hack and no risk to production. A
hand-written client read the unprompted <Challenge>, completed the handshake, and
decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
the exact Python message, dual stdout+logfile output, and a missing value rejected
with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
every constant by executing the Python module and drove the binary against a synthetic
client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
own parser fixture, so backend capability registration still works.
Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.
This commit is contained in:
@@ -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