23374312bc
Evidence infrastructure, not protocol functionality. Built before gates 7-10
because those sessions cannot be reproduced -- a later run is a different
session, and the migration-validation runs happen once. Gates 5-6 already went
past without their bytes being recorded.
TWO LAYERS
live FIFA traffic
├── raw capture exact RX/TX bytes, mode 0600, gitignored
└── blaze-sanitize → repository-safe, replayable fixtures
CAPTURE. Off unless OPENFUT_BLAZE_CAPTURE names a file. Deterministic
big-endian container: 20-byte file header, then per-frame records carrying
connection id, a global monotonic sequence, timestamp, direction and the EXACT
frame bytes. RX is recorded as received; TX only AFTER a successful write, so a
record means the bytes were sent rather than intended.
Component/command/msgNum/msgType/payload length are deliberately NOT stored
beside the frame: they are already in its 16-byte header, and a redundant copy
can disagree with the bytes, leaving a reader unable to tell which is true.
Record::header() derives them, so every field the requirements name is
available without duplicating it.
SANITIZER. Redacts only the named tags in SENSITIVE_TAGS (KEY, AUTH, SESS,
MAIL, PML) and reports every substitution with path, kind and length.
Replacement is LENGTH-PRESERVING, so the TDF varint, payload length and Fire2
header are unchanged and the sanitized frame is exactly the size of the
captured one -- asserted per frame, failing rather than emitting a subtly
different conversation. Frames with nothing sensitive keep their exact wire
bytes. Payloads that will not decode are passed through and REPORTED, so a
reader knows they were never inspected rather than assuming they were checked.
TESTS. 39 in this crate. All nine required cases: capture disabled produces no
artefact; RX and TX captured exactly; ordering preserved; fragmented input
(one byte at a time) reconstructs the same frames as a single write; coalesced
input is captured as separate frames, not per-read; capture does not alter wire
output; sanitization removes a real session key from a real captured login;
malformed/truncated/wrong-version captures fail clearly; every listed sensitive
tag is provably reachable.
MUTATION TESTED. Dropping TX capture, truncating captured frames to their
header, and removing KEY from the sensitive list were each verified to turn the
suite red. One mutation was NOT caught: moving the TX capture above the write.
It is indistinguishable while writes succeed and only diverges when one fails.
That invariant is held by code placement and a comment saying so, not by a
test, and the code says as much rather than implying coverage it does not have.
Python oracle unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
13 KiB
Rust
360 lines
13 KiB
Rust
//! Turn a raw capture into a repository-safe fixture, auditably.
|
|
//!
|
|
//! Raw captures are forensic evidence and may carry session material. This
|
|
//! produces something committable from one, and the governing rule is that
|
|
//! sanitization is **explicit and reported**, never a silent byte substitution
|
|
//! followed by a claim of equivalence.
|
|
//!
|
|
//! # What is preserved
|
|
//!
|
|
//! Everything except identified sensitive values: Fire2 headers, component and
|
|
//! command IDs, message types and numbers, user index, frame ordering,
|
|
//! connection identity, payload structure, and every non-sensitive protocol
|
|
//! value.
|
|
//!
|
|
//! # What is replaced, and how
|
|
//!
|
|
//! Only the tags in [`SENSITIVE_TAGS`], and only their values. Replacement is
|
|
//! **length-preserving**: a redacted string occupies exactly as many bytes as
|
|
//! the original, so the TDF length varint, the payload length and the Fire2
|
|
//! header are all unchanged and the sanitized frame is the same size as the
|
|
//! captured one. That is asserted per frame — a size change means the
|
|
//! sanitizer is wrong, and it fails rather than emitting a subtly different
|
|
//! conversation.
|
|
//!
|
|
//! # What this costs
|
|
//!
|
|
//! A sanitized frame is NOT byte-identical to the wire: that is the entire
|
|
//! point, and it is why the raw capture is kept separately. Every difference is
|
|
//! enumerated in the report.
|
|
|
|
use openfut_protocol_blaze::fire2::Frame;
|
|
use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
|
|
|
use crate::capture::Record;
|
|
|
|
/// Tags whose values are session-specific or credential-shaped.
|
|
///
|
|
/// A deliberately small, named list. Anything not here is preserved, so a
|
|
/// reviewer can see exactly what was touched — and a new sensitive field shows
|
|
/// up as un-redacted rather than being silently caught by a broad pattern.
|
|
pub const SENSITIVE_TAGS: &[&str] = &[
|
|
"KEY", // Blaze session key (LoginResponse.SESS.KEY, UserAuthenticated.KEY)
|
|
"AUTH", // auth code / token (LoginRequest.AUTH, GetAuthTokenResponse.AUTH)
|
|
"SESS", // telemetry session echo, when carried as a string
|
|
"MAIL", // account email
|
|
"PML", // parental email
|
|
];
|
|
|
|
/// One replacement that was made.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Redaction {
|
|
pub seq: u64,
|
|
pub conn_id: u64,
|
|
/// Dotted path to the member, e.g. `SESS.KEY`.
|
|
pub path: String,
|
|
pub kind: &'static str,
|
|
pub original_len: usize,
|
|
pub replacement_len: usize,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct Report {
|
|
pub frames_in: usize,
|
|
pub frames_out: usize,
|
|
pub frames_redacted: usize,
|
|
pub redactions: Vec<Redaction>,
|
|
/// Frames whose payload did not decode as TDF and were passed through.
|
|
pub undecodable: Vec<u64>,
|
|
}
|
|
|
|
/// A length-preserving, obviously-synthetic replacement.
|
|
fn filler(len: usize) -> String {
|
|
const PATTERN: &[u8] = b"REDACTED_";
|
|
(0..len)
|
|
.map(|i| PATTERN[i % PATTERN.len()] as char)
|
|
.collect()
|
|
}
|
|
|
|
fn redact_struct(s: &Struct, prefix: &str, rec: &Record, out: &mut Vec<Redaction>) -> Struct {
|
|
let mut fields = Vec::with_capacity(s.len());
|
|
for (tag, value) in s.iter() {
|
|
let label = tag.to_label();
|
|
let path = if prefix.is_empty() {
|
|
label.clone()
|
|
} else {
|
|
format!("{prefix}.{label}")
|
|
};
|
|
|
|
let new_value = match value {
|
|
Value::Struct(inner) => Value::Struct(redact_struct(inner, &path, rec, out)),
|
|
Value::List { elem, items } => Value::List {
|
|
elem: *elem,
|
|
items: items
|
|
.iter()
|
|
.map(|it| match it {
|
|
Value::Struct(inner) => {
|
|
Value::Struct(redact_struct(inner, &path, rec, out))
|
|
}
|
|
other => other.clone(),
|
|
})
|
|
.collect(),
|
|
},
|
|
Value::String(v) if SENSITIVE_TAGS.contains(&label.as_str()) && !v.is_empty() => {
|
|
// Byte length, not char count: the wire length is what must be
|
|
// preserved, and the filler is ASCII so the two coincide.
|
|
let n = v.len();
|
|
out.push(Redaction {
|
|
seq: rec.seq,
|
|
conn_id: rec.conn_id,
|
|
path: path.clone(),
|
|
kind: "string",
|
|
original_len: n,
|
|
replacement_len: n,
|
|
});
|
|
Value::String(filler(n))
|
|
}
|
|
other => other.clone(),
|
|
};
|
|
fields.push((*tag, new_value));
|
|
}
|
|
Struct { fields }
|
|
}
|
|
|
|
/// Sanitize one captured frame. Returns the new frame bytes.
|
|
pub fn sanitize_record(rec: &Record, report: &mut Report) -> Result<Vec<u8>, String> {
|
|
let (frame, _) = Frame::parse(&rec.frame).map_err(|e| format!("seq {}: {e}", rec.seq))?;
|
|
|
|
if frame.payload.is_empty() {
|
|
return Ok(rec.frame.clone());
|
|
}
|
|
let body = match heat2::decode(&frame.payload) {
|
|
Ok(b) => b,
|
|
Err(_) => {
|
|
// Not decodable as TDF. Passed through unchanged and reported, so a
|
|
// reader knows it was never inspected rather than assuming it was
|
|
// checked and found clean.
|
|
report.undecodable.push(rec.seq);
|
|
return Ok(rec.frame.clone());
|
|
}
|
|
};
|
|
|
|
let before = report.redactions.len();
|
|
let redacted = redact_struct(&body, "", rec, &mut report.redactions);
|
|
if report.redactions.len() == before {
|
|
// Nothing sensitive: emit the original bytes, not a re-encoding, so
|
|
// untouched frames stay byte-identical to the wire.
|
|
return Ok(rec.frame.clone());
|
|
}
|
|
report.frames_redacted += 1;
|
|
|
|
let mut out = Frame::new(
|
|
frame.header.component,
|
|
frame.header.command,
|
|
frame.header.msg_num,
|
|
frame.header.msg_type,
|
|
heat2::encode(&redacted),
|
|
);
|
|
out.header.user_index = frame.header.user_index;
|
|
out.header.options = frame.header.options;
|
|
let bytes = out.encode();
|
|
|
|
// Length preservation is the contract that keeps a sanitized capture a
|
|
// faithful conversation. If it does not hold, something is wrong with the
|
|
// replacement and emitting the result would be worse than failing.
|
|
if bytes.len() != rec.frame.len() {
|
|
return Err(format!(
|
|
"seq {}: sanitized frame is {} bytes, original {} — replacement was not \
|
|
length-preserving",
|
|
rec.seq,
|
|
bytes.len(),
|
|
rec.frame.len()
|
|
));
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
/// A captured record paired with its sanitized frame bytes.
|
|
pub type Sanitized = (Record, Vec<u8>);
|
|
|
|
/// Sanitize a whole capture.
|
|
pub fn sanitize(records: &[Record]) -> Result<(Vec<Sanitized>, Report), String> {
|
|
let mut report = Report {
|
|
frames_in: records.len(),
|
|
..Default::default()
|
|
};
|
|
let mut out = Vec::with_capacity(records.len());
|
|
for rec in records {
|
|
let bytes = sanitize_record(rec, &mut report)?;
|
|
out.push((rec.clone(), bytes));
|
|
}
|
|
report.frames_out = out.len();
|
|
Ok((out, report))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::capture::Direction;
|
|
use openfut_protocol_blaze::fire2::MsgType;
|
|
|
|
fn rec(seq: u64, body: &Struct) -> Record {
|
|
Record {
|
|
conn_id: 1,
|
|
seq,
|
|
ts_ms: 0,
|
|
dir: Direction::Tx,
|
|
frame: Frame::new(0x0001, 0x000A, 7, MsgType::Reply, heat2::encode(body)).encode(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn redacts_a_session_key_without_changing_frame_size() {
|
|
let body = Struct::new()
|
|
.with(
|
|
"KEY",
|
|
Value::String("a-real-looking-session-key-0123456789".into()),
|
|
)
|
|
.with("UID", Value::Int(33068179));
|
|
let r = rec(1, &body);
|
|
|
|
let mut report = Report::default();
|
|
let out = sanitize_record(&r, &mut report).unwrap();
|
|
|
|
assert_eq!(out.len(), r.frame.len(), "framing must be preserved");
|
|
assert_eq!(report.redactions.len(), 1);
|
|
assert_eq!(report.redactions[0].path, "KEY");
|
|
assert_eq!(
|
|
report.redactions[0].original_len,
|
|
report.redactions[0].replacement_len
|
|
);
|
|
|
|
let (f, _) = Frame::parse(&out).unwrap();
|
|
let decoded = heat2::decode(&f.payload).unwrap();
|
|
let key = decoded.get("KEY").and_then(Value::as_str).unwrap();
|
|
assert!(!key.contains("real-looking"), "secret must be gone");
|
|
assert!(
|
|
key.starts_with("REDACTED"),
|
|
"and obviously synthetic: {key}"
|
|
);
|
|
// Non-sensitive values survive untouched.
|
|
assert_eq!(decoded.get("UID").and_then(Value::as_int), Some(33068179));
|
|
}
|
|
|
|
#[test]
|
|
fn redacts_nested_and_reports_the_path() {
|
|
let inner = Struct::new()
|
|
.with("KEY", Value::String("secret-key-value".into()))
|
|
.with("MAIL", Value::String("someone@example.com".into()));
|
|
let body = Struct::new()
|
|
.with("SESS", Value::Struct(inner))
|
|
.with("ANON", Value::Int(0));
|
|
|
|
let mut report = Report::default();
|
|
let out = sanitize_record(&rec(2, &body), &mut report).unwrap();
|
|
|
|
let paths: Vec<&str> = report.redactions.iter().map(|r| r.path.as_str()).collect();
|
|
assert!(paths.contains(&"SESS.KEY"), "{paths:?}");
|
|
assert!(paths.contains(&"SESS.MAIL"), "{paths:?}");
|
|
assert!(!String::from_utf8_lossy(&out).contains("someone@example.com"));
|
|
assert!(!String::from_utf8_lossy(&out).contains("secret-key-value"));
|
|
}
|
|
|
|
#[test]
|
|
fn redacts_inside_lists() {
|
|
let body = Struct::new().with(
|
|
"NLST",
|
|
Value::List {
|
|
elem: openfut_protocol_blaze::heat2::TypeId::Struct,
|
|
items: vec![Value::Struct(
|
|
Struct::new().with("AUTH", Value::String("token-abcdef".into())),
|
|
)],
|
|
},
|
|
);
|
|
let mut report = Report::default();
|
|
let out = sanitize_record(&rec(3, &body), &mut report).unwrap();
|
|
assert_eq!(report.redactions.len(), 1, "list members must be reached");
|
|
assert!(!String::from_utf8_lossy(&out).contains("token-abcdef"));
|
|
}
|
|
|
|
#[test]
|
|
fn frames_with_nothing_sensitive_stay_byte_identical() {
|
|
let body = Struct::new()
|
|
.with("UID", Value::Int(1))
|
|
.with("CO", Value::String("US".into()));
|
|
let r = rec(4, &body);
|
|
let mut report = Report::default();
|
|
let out = sanitize_record(&r, &mut report).unwrap();
|
|
assert_eq!(out, r.frame, "untouched frames keep the exact wire bytes");
|
|
assert!(report.redactions.is_empty());
|
|
assert_eq!(report.frames_redacted, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_payloads_pass_through() {
|
|
let r = Record {
|
|
conn_id: 1,
|
|
seq: 5,
|
|
ts_ms: 0,
|
|
dir: Direction::Rx,
|
|
frame: Frame::new(9, 2, 1, MsgType::Message, vec![]).encode(),
|
|
};
|
|
let mut report = Report::default();
|
|
assert_eq!(sanitize_record(&r, &mut report).unwrap(), r.frame);
|
|
}
|
|
|
|
#[test]
|
|
fn undecodable_payloads_are_passed_through_and_flagged() {
|
|
let r = Record {
|
|
conn_id: 1,
|
|
seq: 6,
|
|
ts_ms: 0,
|
|
dir: Direction::Rx,
|
|
frame: Frame::new(9, 2, 1, MsgType::Message, vec![0xFF; 8]).encode(),
|
|
};
|
|
let mut report = Report::default();
|
|
let out = sanitize_record(&r, &mut report).unwrap();
|
|
assert_eq!(out, r.frame);
|
|
assert_eq!(
|
|
report.undecodable,
|
|
vec![6],
|
|
"must be reported, not silently kept"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ordering_and_identity_survive_a_whole_capture() {
|
|
let recs: Vec<Record> = (0..5)
|
|
.map(|i| {
|
|
rec(
|
|
i,
|
|
&Struct::new().with("KEY", Value::String(format!("key-{i}-padding"))),
|
|
)
|
|
})
|
|
.collect();
|
|
let (out, report) = sanitize(&recs).unwrap();
|
|
assert_eq!(report.frames_in, 5);
|
|
assert_eq!(report.frames_out, 5);
|
|
assert_eq!(report.frames_redacted, 5);
|
|
for (i, (r, _)) in out.iter().enumerate() {
|
|
assert_eq!(r.seq, i as u64, "sequence order preserved");
|
|
assert_eq!(r.conn_id, 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_sensitive_tag_is_actually_reachable() {
|
|
// Guards against a tag being listed but never matched — e.g. a typo.
|
|
for tag in SENSITIVE_TAGS {
|
|
let body = Struct::new().with(tag, Value::String("sensitive-value".into()));
|
|
let mut report = Report::default();
|
|
let out = sanitize_record(&rec(9, &body), &mut report).unwrap();
|
|
assert_eq!(report.redactions.len(), 1, "tag {tag} was not redacted");
|
|
assert!(
|
|
!String::from_utf8_lossy(&out).contains("sensitive-value"),
|
|
"tag {tag} leaked"
|
|
);
|
|
}
|
|
}
|
|
}
|