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:
funman300
2026-08-18 05:31:00 +00:00
parent 082246c085
commit 750d6c2e18
17 changed files with 3940 additions and 1 deletions
+158
View File
@@ -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")"#);
}
}