//! 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, /// Frames whose payload did not decode as TDF and were passed through. pub undecodable: Vec, } /// 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) -> 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, 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); /// Sanitize a whole capture. pub fn sanitize(records: &[Record]) -> Result<(Vec, 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 = (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" ); } } }