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>
431 lines
14 KiB
Rust
431 lines
14 KiB
Rust
//! Opt-in raw Fire2 frame capture.
|
||
//!
|
||
//! **Evidence infrastructure, not protocol functionality.** Disabled unless
|
||
//! `OPENFUT_BLAZE_CAPTURE` names a file. Nothing in the dispatch path consults
|
||
//! it, and it never changes a byte, an ordering, or a session.
|
||
//!
|
||
//! # Why this exists
|
||
//!
|
||
//! The wire captures every RE finding cites are gitignored and gone from disk,
|
||
//! and a complete live FIFA Blaze session ran past during gates 5–6 without its
|
||
//! bytes being recorded. Those sessions cannot be reproduced: a later run is a
|
||
//! different session, and the migration-validation runs in particular happen
|
||
//! once.
|
||
//!
|
||
//! # Two layers
|
||
//!
|
||
//! ```text
|
||
//! live FIFA traffic
|
||
//! ├── raw capture exact RX/TX bytes, gitignored, mode 0600
|
||
//! └── blaze-sanitize → repository-safe, auditable fixtures
|
||
//! ```
|
||
//!
|
||
//! Raw captures are forensic evidence and may carry session material. They are
|
||
//! never committed and never printed to the ordinary log.
|
||
//!
|
||
//! # Format
|
||
//!
|
||
//! Deterministic and self-describing, big-endian throughout to match the
|
||
//! protocol.
|
||
//!
|
||
//! ```text
|
||
//! file header (20 bytes)
|
||
//! magic 8 "OFUTCAP1"
|
||
//! version u16
|
||
//! flags u16
|
||
//! created u64 unix seconds
|
||
//!
|
||
//! record (repeated)
|
||
//! len u32 bytes after this field
|
||
//! conn_id u64
|
||
//! seq u64 global, monotonic — total order across connections
|
||
//! ts_ms u64
|
||
//! dir u8 0 = RX (from client), 1 = TX (to client)
|
||
//! pad u8×3
|
||
//! frame_len u32
|
||
//! frame [frame_len] EXACT bytes, one whole Fire2 frame
|
||
//! ```
|
||
//!
|
||
//! Component, command, message number, message type and payload length are
|
||
//! deliberately **not** stored alongside 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. The frame is authoritative and readers
|
||
//! derive those fields from it — [`Record::header`] does exactly that, so every
|
||
//! field named in the capture requirements is available without duplicating it.
|
||
|
||
use std::fs::File;
|
||
use std::io::{self, Read, Write};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::Mutex;
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
use openfut_protocol_blaze::fire2::Header;
|
||
|
||
pub const MAGIC: &[u8; 8] = b"OFUTCAP1";
|
||
pub const VERSION: u16 = 1;
|
||
pub const FILE_HEADER_LEN: usize = 20;
|
||
/// Bytes of a record after its own length field, excluding the frame.
|
||
const RECORD_FIXED: usize = 8 + 8 + 8 + 1 + 3 + 4;
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Direction {
|
||
/// Received from the client.
|
||
Rx,
|
||
/// Sent to the client.
|
||
Tx,
|
||
}
|
||
|
||
impl Direction {
|
||
pub fn as_byte(self) -> u8 {
|
||
match self {
|
||
Direction::Rx => 0,
|
||
Direction::Tx => 1,
|
||
}
|
||
}
|
||
pub fn from_byte(b: u8) -> Option<Direction> {
|
||
match b {
|
||
0 => Some(Direction::Rx),
|
||
1 => Some(Direction::Tx),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Direction::Rx => "RX",
|
||
Direction::Tx => "TX",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A capture sink. Cheap and inert when disabled.
|
||
pub struct Capture {
|
||
sink: Option<Mutex<File>>,
|
||
seq: AtomicU64,
|
||
}
|
||
|
||
fn now_ms() -> u64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.as_millis() as u64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
impl Capture {
|
||
/// Disabled sink.
|
||
pub fn disabled() -> Capture {
|
||
Capture {
|
||
sink: None,
|
||
seq: AtomicU64::new(0),
|
||
}
|
||
}
|
||
|
||
/// Create a capture file, replacing any existing one.
|
||
///
|
||
/// Created mode 0600: these bytes are forensic evidence and may contain
|
||
/// session material.
|
||
pub fn create(path: &str) -> io::Result<Capture> {
|
||
let mut opts = File::options();
|
||
opts.write(true).create(true).truncate(true);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
opts.mode(0o600);
|
||
}
|
||
let mut file = opts.open(path)?;
|
||
|
||
let mut hdr = Vec::with_capacity(FILE_HEADER_LEN);
|
||
hdr.extend_from_slice(MAGIC);
|
||
hdr.extend_from_slice(&VERSION.to_be_bytes());
|
||
hdr.extend_from_slice(&0u16.to_be_bytes()); // flags
|
||
hdr.extend_from_slice(
|
||
&SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0)
|
||
.to_be_bytes(),
|
||
);
|
||
file.write_all(&hdr)?;
|
||
file.flush()?;
|
||
|
||
Ok(Capture {
|
||
sink: Some(Mutex::new(file)),
|
||
seq: AtomicU64::new(0),
|
||
})
|
||
}
|
||
|
||
pub fn enabled(&self) -> bool {
|
||
self.sink.is_some()
|
||
}
|
||
|
||
/// Record one whole frame.
|
||
///
|
||
/// Callers pass the exact bytes that crossed the socket, and call this
|
||
/// AFTER the I/O has happened — so a TX record means "these bytes were
|
||
/// written", not "these bytes were intended". Errors are swallowed
|
||
/// deliberately: a capture problem must never take down a live session that
|
||
/// is otherwise healthy.
|
||
pub fn record(&self, conn_id: u64, dir: Direction, frame: &[u8]) {
|
||
let Some(sink) = &self.sink else { return };
|
||
let seq = self.seq.fetch_add(1, Ordering::SeqCst);
|
||
|
||
let mut buf = Vec::with_capacity(4 + RECORD_FIXED + frame.len());
|
||
buf.extend_from_slice(&((RECORD_FIXED + frame.len()) as u32).to_be_bytes());
|
||
buf.extend_from_slice(&conn_id.to_be_bytes());
|
||
buf.extend_from_slice(&seq.to_be_bytes());
|
||
buf.extend_from_slice(&now_ms().to_be_bytes());
|
||
buf.push(dir.as_byte());
|
||
buf.extend_from_slice(&[0u8; 3]);
|
||
buf.extend_from_slice(&(frame.len() as u32).to_be_bytes());
|
||
buf.extend_from_slice(frame);
|
||
|
||
if let Ok(mut f) = sink.lock() {
|
||
let _ = f.write_all(&buf);
|
||
// Flushed per record: a crash mid-session must not cost the
|
||
// evidence collected so far. Volume is a few dozen frames per FIFA
|
||
// session, so this is not a hot path.
|
||
let _ = f.flush();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One captured frame.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Record {
|
||
pub conn_id: u64,
|
||
pub seq: u64,
|
||
pub ts_ms: u64,
|
||
pub dir: Direction,
|
||
/// Exact bytes as they crossed the socket.
|
||
pub frame: Vec<u8>,
|
||
}
|
||
|
||
impl Record {
|
||
/// Parse the Fire2 header from the captured bytes.
|
||
///
|
||
/// Component, command, msgNum, msgType, userIndex and payload length all
|
||
/// come from here rather than from stored copies.
|
||
pub fn header(&self) -> Option<Header> {
|
||
Header::parse(&self.frame).ok()
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub enum ReadError {
|
||
NotACapture,
|
||
UnsupportedVersion(u16),
|
||
Truncated { at: usize, want: usize, have: usize },
|
||
Io(String),
|
||
}
|
||
|
||
impl std::fmt::Display for ReadError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
ReadError::NotACapture => write!(f, "not an OpenFUT capture (bad magic)"),
|
||
ReadError::UnsupportedVersion(v) => write!(f, "unsupported capture version {v}"),
|
||
ReadError::Truncated { at, want, have } => {
|
||
write!(
|
||
f,
|
||
"truncated capture at offset {at}: want {want} bytes, have {have}"
|
||
)
|
||
}
|
||
ReadError::Io(e) => write!(f, "io error: {e}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for ReadError {}
|
||
|
||
/// Read a whole capture. Fails clearly rather than returning partial data.
|
||
pub fn read_file(path: &str) -> Result<Vec<Record>, ReadError> {
|
||
let mut buf = Vec::new();
|
||
File::open(path)
|
||
.and_then(|mut f| f.read_to_end(&mut buf))
|
||
.map_err(|e| ReadError::Io(e.to_string()))?;
|
||
parse(&buf)
|
||
}
|
||
|
||
pub fn parse(buf: &[u8]) -> Result<Vec<Record>, ReadError> {
|
||
if buf.len() < FILE_HEADER_LEN || &buf[..8] != MAGIC {
|
||
return Err(ReadError::NotACapture);
|
||
}
|
||
let version = u16::from_be_bytes([buf[8], buf[9]]);
|
||
if version != VERSION {
|
||
return Err(ReadError::UnsupportedVersion(version));
|
||
}
|
||
|
||
let mut out = Vec::new();
|
||
let mut i = FILE_HEADER_LEN;
|
||
while i < buf.len() {
|
||
if i + 4 > buf.len() {
|
||
return Err(ReadError::Truncated {
|
||
at: i,
|
||
want: 4,
|
||
have: buf.len() - i,
|
||
});
|
||
}
|
||
let len = u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]) as usize;
|
||
i += 4;
|
||
if len < RECORD_FIXED || i + len > buf.len() {
|
||
return Err(ReadError::Truncated {
|
||
at: i,
|
||
want: len,
|
||
have: buf.len().saturating_sub(i),
|
||
});
|
||
}
|
||
let rec = &buf[i..i + len];
|
||
let conn_id = u64::from_be_bytes(rec[0..8].try_into().unwrap());
|
||
let seq = u64::from_be_bytes(rec[8..16].try_into().unwrap());
|
||
let ts_ms = u64::from_be_bytes(rec[16..24].try_into().unwrap());
|
||
let dir = Direction::from_byte(rec[24]).ok_or(ReadError::Truncated {
|
||
at: i + 24,
|
||
want: 1,
|
||
have: 1,
|
||
})?;
|
||
let frame_len = u32::from_be_bytes(rec[28..32].try_into().unwrap()) as usize;
|
||
if 32 + frame_len != len {
|
||
return Err(ReadError::Truncated {
|
||
at: i + 32,
|
||
want: frame_len,
|
||
have: len.saturating_sub(32),
|
||
});
|
||
}
|
||
out.push(Record {
|
||
conn_id,
|
||
seq,
|
||
ts_ms,
|
||
dir,
|
||
frame: rec[32..32 + frame_len].to_vec(),
|
||
});
|
||
i += len;
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use openfut_protocol_blaze::fire2::{Frame, MsgType};
|
||
|
||
fn tmp(name: &str) -> String {
|
||
let dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
|
||
format!("{dir}/ofcap-test-{}-{name}.ofcap", std::process::id())
|
||
}
|
||
|
||
#[test]
|
||
fn disabled_capture_writes_nothing() {
|
||
let c = Capture::disabled();
|
||
assert!(!c.enabled());
|
||
c.record(1, Direction::Rx, &[1, 2, 3]); // must not panic or create anything
|
||
}
|
||
|
||
#[test]
|
||
fn round_trips_frames_exactly_and_in_order() {
|
||
let path = tmp("roundtrip");
|
||
let c = Capture::create(&path).unwrap();
|
||
|
||
let a = Frame::new(0x0009, 0x0007, 1, MsgType::Message, vec![1, 2, 3]).encode();
|
||
let b = Frame::new(0x0009, 0x0007, 1, MsgType::Reply, vec![4, 5]).encode();
|
||
c.record(7, Direction::Rx, &a);
|
||
c.record(7, Direction::Tx, &b);
|
||
drop(c);
|
||
|
||
let recs = read_file(&path).unwrap();
|
||
assert_eq!(recs.len(), 2);
|
||
assert_eq!(recs[0].frame, a, "RX bytes must be exact");
|
||
assert_eq!(recs[1].frame, b, "TX bytes must be exact");
|
||
assert_eq!(recs[0].dir, Direction::Rx);
|
||
assert_eq!(recs[1].dir, Direction::Tx);
|
||
assert_eq!(recs[0].conn_id, 7);
|
||
assert!(recs[0].seq < recs[1].seq, "sequence preserves order");
|
||
|
||
// Metadata is derivable rather than stored.
|
||
let h = recs[0].header().unwrap();
|
||
assert_eq!(h.component, 0x0009);
|
||
assert_eq!(h.command, 0x0007);
|
||
assert_eq!(h.msg_type, MsgType::Message);
|
||
assert_eq!(h.payload_len, 3);
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn interleaved_connections_keep_a_total_order() {
|
||
let path = tmp("interleaved");
|
||
let c = Capture::create(&path).unwrap();
|
||
for i in 0..6u64 {
|
||
let f = Frame::new(9, 2, i as u32, MsgType::Message, vec![i as u8]).encode();
|
||
c.record(i % 2, Direction::Rx, &f);
|
||
}
|
||
drop(c);
|
||
|
||
let recs = read_file(&path).unwrap();
|
||
assert_eq!(recs.len(), 6);
|
||
let seqs: Vec<u64> = recs.iter().map(|r| r.seq).collect();
|
||
assert_eq!(seqs, (0..6).collect::<Vec<_>>());
|
||
// And per-connection order is recoverable from the same total order.
|
||
let conn0: Vec<u8> = recs
|
||
.iter()
|
||
.filter(|r| r.conn_id == 0)
|
||
.map(|r| r.frame[16])
|
||
.collect();
|
||
assert_eq!(conn0, vec![0, 2, 4]);
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn capture_files_are_owner_only() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let path = tmp("perms");
|
||
let _c = Capture::create(&path).unwrap();
|
||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||
assert_eq!(mode, 0o600, "captures may contain session material");
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_a_file_that_is_not_a_capture() {
|
||
assert!(matches!(
|
||
parse(b"hello world padding.."),
|
||
Err(ReadError::NotACapture)
|
||
));
|
||
assert!(matches!(parse(b""), Err(ReadError::NotACapture)));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_an_unsupported_version() {
|
||
let mut buf = MAGIC.to_vec();
|
||
buf.extend_from_slice(&99u16.to_be_bytes());
|
||
buf.extend_from_slice(&0u16.to_be_bytes());
|
||
buf.extend_from_slice(&0u64.to_be_bytes());
|
||
assert!(matches!(
|
||
parse(&buf),
|
||
Err(ReadError::UnsupportedVersion(99))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn truncation_fails_clearly_rather_than_returning_partial_data() {
|
||
let path = tmp("truncated");
|
||
let c = Capture::create(&path).unwrap();
|
||
c.record(
|
||
1,
|
||
Direction::Rx,
|
||
&Frame::new(9, 2, 1, MsgType::Message, vec![7; 40]).encode(),
|
||
);
|
||
drop(c);
|
||
|
||
let full = std::fs::read(&path).unwrap();
|
||
for cut in [FILE_HEADER_LEN + 2, FILE_HEADER_LEN + 10, full.len() - 5] {
|
||
match parse(&full[..cut]) {
|
||
Err(ReadError::Truncated { .. }) => {}
|
||
other => panic!("cut at {cut} should be Truncated, got {other:?}"),
|
||
}
|
||
}
|
||
// The whole file still parses.
|
||
assert_eq!(parse(&full).unwrap().len(), 1);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
}
|