Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/tests/pipeline_to_frame.rs
T
rUv a369fbe66e fix(bfld security): close HIGH privacy-bypass in process_to_frame (identity surface leaked despite restrictive class) + JSON-injection (#1075)
* fix(bfld): route process_to_frame payload through PrivacyGate (ADR-141 privacy bypass)

BfldPipeline::process_to_frame stamped the frame header with the active
privacy class but serialized the caller-supplied BfldPayload UNCHANGED via
BfldFrame::from_payload. This let a frame labeled Anonymous(2) or
Restricted(3) carry the full identity-leaky compressed_angle_matrix
(+ amplitude/phase proxies, csi_delta) that PrivacyGate::demote is documented
and tested (privacy_gate_demote.rs) to strip at exactly those classes.

A NetworkSink accepts class >= Derived(1), so such a frame would publish the
beamforming angle matrix — the identity surface — across the node boundary
despite its restrictive class byte. The class byte lied about payload content.

Fix: after building the frame at the active class, apply PrivacyGate::demote to
the same class. demote() strips sections by target-class threshold (independent
of any class transition), so a same-class demote performs no class change but
brings the payload into policy compliance. Research classes (Raw/Derived) keep
the full payload — demote is a no-op there.

Pinned by three fails-on-old tests in pipeline_to_frame.rs:
- process_to_frame_at_anonymous_strips_identity_leaky_sections (FAILED pre-fix)
- process_to_frame_in_privacy_mode_strips_amplitude_and_phase (FAILED pre-fix)
- process_to_frame_at_derived_preserves_full_payload (guards against over-strip)
The pre-existing round-trip test is updated to assert the gated payload.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(bfld): JSON-escape zone_id in MQTT state-topic payload

render_events emitted the zone_activity payload as format!("\"{zone}\"") with no
escaping, while ha_discovery.rs already escapes operator-controlled strings via
push_str_field. A zone name containing a double-quote or backslash therefore
produced malformed / injectable JSON on the state topic that Home Assistant
parses (e.g. zone `a"b` -> payload `"a"b"`).

Fix: add json_string_literal() mirroring ha_discovery's escaping (", \, \n, \r,
\t, control chars) and use it for the zone payload. Value-identical for normal
zone names (living_room etc.).

Pinned by zone_payload_escapes_json_metacharacters (FAILED pre-fix); the
existing zone_payload_is_json_string_with_quotes still passes unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-141): record bfld privacy+security review findings + CHANGELOG

Document the two fixed bugs (process_to_frame privacy-bypass; zone_id JSON
injection) and the dimensions confirmed clean (event-field gating, witness/hash
framing, fail-closed) in ADR-141, plus CHANGELOG [Unreleased] Security/Fixed
entries.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 16:15:42 -04:00

255 lines
8.9 KiB
Rust

//! Acceptance tests for `BfldPipeline::process_to_frame`. ADR-118 §2.1 wire-bytes path.
#![cfg(feature = "std")]
use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS;
use wifi_densepose_bfld::{
BfldConfig, BfldFrame, BfldFrameHeader, BfldPayload, BfldPipeline, IdentityEmbedding,
PrivacyClass, SensingInputs, EMBEDDING_DIM,
};
fn inputs(timestamp_ns: u64, risk: [f32; 4]) -> SensingInputs {
let [sep, stab, consist, risk_conf] = risk;
SensingInputs {
timestamp_ns,
presence: true,
motion: 0.4,
person_count: 1,
sensing_confidence: 0.9,
sep,
stab,
consist,
risk_conf,
rf_signature_hash: None,
}
}
fn embedding() -> IdentityEmbedding {
IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM])
}
fn header_template() -> BfldFrameHeader {
let mut h = BfldFrameHeader::empty();
h.ap_hash = [0xA1; 16];
h.sta_hash = [0xA2; 16];
h.session_id = [0xA3; 16];
h.channel = 36;
h.bandwidth_mhz = 80;
h.n_subcarriers = 234;
h.n_tx = 2;
h.n_rx = 2;
h
}
fn typed_payload() -> BfldPayload {
BfldPayload {
compressed_angle_matrix: vec![0x11; 32],
amplitude_proxy: vec![0x22; 16],
phase_proxy: vec![0x33; 16],
snr_vector: vec![0x44; 8],
csi_delta: None,
vendor_extension: vec![],
}
}
#[test]
fn process_to_frame_emits_frame_under_low_risk() {
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
let frame = p
.process_to_frame(
inputs(1_700_000_000_000_000_000, [0.2, 0.2, 0.2, 0.2]),
header_template(),
typed_payload(),
Some(embedding()),
)
.expect("low-risk frame must be emitted");
assert_eq!({ frame.header.timestamp_ns }, 1_700_000_000_000_000_000);
assert_eq!({ frame.header.privacy_class }, PrivacyClass::Anonymous.as_u8());
}
#[test]
fn process_to_frame_returns_none_under_sustained_high_risk() {
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
// Push gate into Reject via two consecutive high-risk evaluations.
let _ = p.process_to_frame(
inputs(0, [1.0, 1.0, 1.0, 0.8]),
header_template(),
typed_payload(),
Some(embedding()),
);
let after = p.process_to_frame(
inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]),
header_template(),
typed_payload(),
Some(embedding()),
);
assert!(after.is_none(), "Reject gate must drop the frame");
}
#[test]
fn process_to_frame_round_trips_through_bytes() {
// Default pipeline class is Anonymous(2). The frame must round-trip through
// wire bytes with no CRC error; the payload it carries is the privacy-gated
// (angle-matrix-stripped) form, not the raw input — see
// process_to_frame_at_anonymous_strips_identity_leaky_sections for the
// content assertion. This test pins byte/CRC consistency only.
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
let frame = p
.process_to_frame(
inputs(1_700_000_000_000_000_000, [0.1, 0.1, 0.1, 0.1]),
header_template(),
typed_payload(),
Some(embedding()),
)
.unwrap();
let bytes = frame.to_bytes();
let parsed = BfldFrame::from_bytes(&bytes).expect("frame must round-trip");
let parsed_payload = parsed.parse_payload().expect("payload must round-trip");
// Round-trip preserves whatever the privacy gate left in place.
assert_eq!(parsed_payload, frame.parse_payload().unwrap());
// And the identity surface is gone at Anonymous.
assert!(parsed_payload.compressed_angle_matrix.is_empty());
}
#[test]
fn process_to_frame_overrides_class_in_privacy_mode() {
let mut p = BfldPipeline::new(
BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Anonymous),
);
p.enable_privacy_mode();
let frame = p
.process_to_frame(
inputs(0, [0.1, 0.1, 0.1, 0.1]),
header_template(),
typed_payload(),
Some(embedding()),
)
.unwrap();
assert_eq!(
{ frame.header.privacy_class },
PrivacyClass::Restricted.as_u8(),
"privacy_mode must override into the frame header byte too",
);
}
#[test]
fn process_to_frame_preserves_header_template_identity_fields() {
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
let frame = p
.process_to_frame(
inputs(0, [0.1, 0.1, 0.1, 0.1]),
header_template(),
typed_payload(),
Some(embedding()),
)
.unwrap();
assert_eq!(frame.header.ap_hash, [0xA1; 16]);
assert_eq!(frame.header.sta_hash, [0xA2; 16]);
assert_eq!(frame.header.session_id, [0xA3; 16]);
assert_eq!({ frame.header.channel }, 36);
}
// --- ADR-141 privacy-gate-correctness regression -------------------------
//
// `process_to_frame` stamps the frame with the pipeline's privacy_class but
// (pre-fix) serialized the caller-supplied payload UNCHANGED. That let a frame
// labeled Anonymous(2) / Restricted(3) carry the full identity-leaky
// `compressed_angle_matrix` (+ amplitude/phase/csi_delta) that
// `PrivacyGate::demote` is documented (privacy_gate_demote.rs) to strip at
// exactly those classes. A NetworkSink accepts class >= Derived, so such a
// frame would publish the beamforming angle matrix (identity surface) to the
// network despite its restrictive class byte. These tests pin that the payload
// content matches what the stamped class permits.
#[test]
fn process_to_frame_at_anonymous_strips_identity_leaky_sections() {
// Default pipeline class is Anonymous(2): the angle matrix and csi_delta
// MUST NOT survive into the emitted frame, matching PrivacyGate::demote.
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
let mut leaky = typed_payload();
leaky.csi_delta = Some(vec![0x55; 24]);
let frame = p
.process_to_frame(
inputs(1_700_000_000_000_000_000, [0.1, 0.1, 0.1, 0.1]),
header_template(),
leaky,
Some(embedding()),
)
.expect("low-risk frame must be emitted");
assert_eq!({ frame.header.privacy_class }, PrivacyClass::Anonymous.as_u8());
let payload = frame.parse_payload().expect("payload parses");
assert!(
payload.compressed_angle_matrix.is_empty(),
"Anonymous frame must NOT carry the compressed_angle_matrix (identity surface)",
);
assert!(
payload.csi_delta.is_none(),
"Anonymous frame must NOT carry csi_delta",
);
// Aggregate sensing sections survive.
assert_eq!(payload.snr_vector.len(), 8);
assert_eq!(payload.amplitude_proxy.len(), 16);
}
#[test]
fn process_to_frame_in_privacy_mode_strips_amplitude_and_phase() {
// privacy_mode -> Restricted(3): amplitude + phase proxies must ALSO drop.
let mut p = BfldPipeline::new(
BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Anonymous),
);
p.enable_privacy_mode();
let frame = p
.process_to_frame(
inputs(0, [0.1, 0.1, 0.1, 0.1]),
header_template(),
typed_payload(),
Some(embedding()),
)
.expect("frame emitted");
assert_eq!({ frame.header.privacy_class }, PrivacyClass::Restricted.as_u8());
let payload = frame.parse_payload().expect("payload parses");
assert!(payload.compressed_angle_matrix.is_empty(), "angle matrix stripped at Restricted");
assert!(payload.amplitude_proxy.is_empty(), "amplitude stripped at Restricted");
assert!(payload.phase_proxy.is_empty(), "phase stripped at Restricted");
assert_eq!(payload.snr_vector.len(), 8, "snr_vector survives");
}
#[test]
fn process_to_frame_at_derived_preserves_full_payload() {
// Derived(1) is a research mode that legitimately keeps the angle matrix.
// The strip must NOT over-fire at classes below Anonymous.
let mut p = BfldPipeline::new(
BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Derived),
);
let frame = p
.process_to_frame(
inputs(0, [0.1, 0.1, 0.1, 0.1]),
header_template(),
typed_payload(),
Some(embedding()),
)
.expect("frame emitted");
assert_eq!({ frame.header.privacy_class }, PrivacyClass::Derived.as_u8());
let payload = frame.parse_payload().expect("payload parses");
assert_eq!(
payload, typed_payload(),
"Derived research frame keeps the full payload unchanged",
);
}
#[test]
fn process_to_frame_uses_input_timestamp_not_template_timestamp() {
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
let mut tmpl = header_template();
tmpl.timestamp_ns = 12345; // sentinel that must be overridden
let frame = p
.process_to_frame(
inputs(9_999_999_999_999_999, [0.1, 0.1, 0.1, 0.1]),
tmpl,
typed_payload(),
Some(embedding()),
)
.unwrap();
assert_eq!({ frame.header.timestamp_ns }, 9_999_999_999_999_999);
}