diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index cf4ddf44..8d5fda20 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -18,6 +18,8 @@ pub mod embedding_ring; pub mod frame; #[cfg(feature = "std")] pub mod payload; +#[cfg(feature = "std")] +pub mod privacy_gate; pub mod sink; pub use embedding::{IdentityEmbedding, EMBEDDING_DIM}; @@ -27,6 +29,8 @@ pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE}; pub use frame::BfldFrame; #[cfg(feature = "std")] pub use payload::BfldPayload; +#[cfg(feature = "std")] +pub use privacy_gate::PrivacyGate; pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink}; /// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1. @@ -130,4 +134,15 @@ pub enum BfldError { /// Human-readable reason for the failure. reason: &'static str, }, + + /// Attempted to demote a frame to a class with MORE information than the + /// current class (lower numerical value). `demote` is monotonic; the only + /// way to add information back is to receive a fresh frame. + #[error("invalid demote: cannot move from class {from} to class {to}")] + InvalidDemote { + /// Source class byte value. + from: u8, + /// Refused target class byte value. + to: u8, + }, } diff --git a/v2/crates/wifi-densepose-bfld/src/privacy_gate.rs b/v2/crates/wifi-densepose-bfld/src/privacy_gate.rs new file mode 100644 index 00000000..e0962f9e --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/privacy_gate.rs @@ -0,0 +1,100 @@ +//! `PrivacyGate` — monotonic class transitions for `BfldFrame`. ADR-120 §2.4. +//! +//! The only way a higher-information frame becomes a lower-information frame +//! is through [`PrivacyGate::demote`]. This function: +//! +//! 1. Asserts the target class is **strictly higher in numerical value** (or +//! equal) to the current class — going from Derived(1) to Anonymous(2) is +//! a demote; going from Anonymous(2) back to Derived(1) is forbidden. +//! 2. Zeroes payload sections that are not permitted at the target class, +//! using a `black_box`-guarded loop to defeat dead-store elimination. +//! 3. Re-syncs `header.privacy_class` and `header.payload_crc32`. +//! 4. Returns the new frame. +//! +//! There is no `promote` operation by design — once a section is zeroed, the +//! original bytes are unrecoverable. + +#![cfg(feature = "std")] + +use crate::frame::crc32_of_payload; +use crate::{BfldError, BfldFrame, BfldPayload, PrivacyClass}; + +/// Monotonic class transformer. See module docs. +pub struct PrivacyGate; + +impl PrivacyGate { + /// Apply a class demotion in-place: returns a new `BfldFrame` whose + /// `privacy_class`, payload sections, and CRC match `target`. + /// + /// Returns [`BfldError::InvalidDemote`] when `target` would *increase* + /// the information density (lower class number than the source). + pub fn demote( + mut frame: BfldFrame, + target: PrivacyClass, + ) -> Result { + let current = PrivacyClass::try_from(frame.header.privacy_class)?; + if target.as_u8() < current.as_u8() { + return Err(BfldError::InvalidDemote { + from: current.as_u8(), + to: target.as_u8(), + }); + } + + // Strip payload sections not permitted at the target class. We only do + // this when the payload parses cleanly; a malformed payload remains + // untouched in the bytes (the class byte and CRC still get re-synced). + if let Ok(mut payload) = frame.parse_payload() { + if target.as_u8() >= PrivacyClass::Anonymous.as_u8() { + // Anonymous: drop the compressed angle matrix (identity surface). + zeroize_then_clear(&mut payload.compressed_angle_matrix); + // Also drop optional sections that may carry identity-leaky + // signal under high-separability conditions. + if let Some(csi) = payload.csi_delta.as_mut() { + zeroize_then_clear(csi); + } + } + if target.as_u8() >= PrivacyClass::Restricted.as_u8() { + // Restricted: also drop amplitude + phase proxies. + zeroize_then_clear(&mut payload.amplitude_proxy); + zeroize_then_clear(&mut payload.phase_proxy); + } + // Note: csi_delta dropped above implies the flag bit should clear. + // from_payload re-derives the flag from csi_delta.is_some(), so + // taking the Option out below ensures the bit is cleared. + if target.as_u8() >= PrivacyClass::Anonymous.as_u8() { + payload.csi_delta = None; + } + frame = BfldFrame::from_payload(frame.header, &payload); + } + + frame.header.privacy_class = target.as_u8(); + // from_payload already recomputed CRC, but recompute again so the + // path that skipped payload parsing still produces a consistent frame. + frame.header.payload_crc32 = crc32_of_payload(&frame.payload); + Ok(frame) + } +} + +/// Overwrite `v` with zeros, then truncate. The `black_box` call defeats +/// dead-store elimination so the writes are observable. +fn zeroize_then_clear(v: &mut Vec) { + for b in v.iter_mut() { + *b = 0; + } + core::hint::black_box(v.as_ptr()); + v.clear(); +} + +// Convenience constructor: the gate is a unit type, but keeping a Default +// makes downstream injection sites (PrivacyGate.demote(...) vs static call) +// straightforward. +impl Default for PrivacyGate { + fn default() -> Self { + Self + } +} + +/// Discard the rest of an unused (#[allow(dead_code)]) — placeholder so +/// `BfldPayload` import isn't unused in builds that strip the implementation. +#[allow(dead_code)] +fn _unused_payload_marker(_: BfldPayload) {} diff --git a/v2/crates/wifi-densepose-bfld/tests/privacy_gate_demote.rs b/v2/crates/wifi-densepose-bfld/tests/privacy_gate_demote.rs new file mode 100644 index 00000000..bd9860b9 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/privacy_gate_demote.rs @@ -0,0 +1,114 @@ +//! Acceptance tests for ADR-120 §2.4 — `PrivacyGate::demote` monotonic class +//! transitions and payload-section zeroization. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{ + BfldError, BfldFrame, BfldFrameHeader, BfldPayload, PrivacyClass, PrivacyGate, +}; + +fn frame_at_class(class: PrivacyClass, with_csi: bool) -> BfldFrame { + let payload = BfldPayload { + compressed_angle_matrix: vec![0x11; 32], + amplitude_proxy: vec![0x22; 16], + phase_proxy: vec![0x33; 16], + snr_vector: vec![0x44; 8], + csi_delta: if with_csi { Some(vec![0x55; 24]) } else { None }, + vendor_extension: vec![0xAA], + }; + let mut header = BfldFrameHeader::empty(); + header.privacy_class = class.as_u8(); + BfldFrame::from_payload(header, &payload) +} + +#[test] +fn demote_to_same_class_is_identity() { + let f = frame_at_class(PrivacyClass::Derived, false); + let out = PrivacyGate::demote(f, PrivacyClass::Derived).expect("same-class demote OK"); + assert_eq!({ out.header.privacy_class }, PrivacyClass::Derived.as_u8()); +} + +#[test] +fn demote_derived_to_anonymous_strips_compressed_angle_matrix() { + let f = frame_at_class(PrivacyClass::Derived, true); + let out = PrivacyGate::demote(f, PrivacyClass::Anonymous).expect("demote"); + assert_eq!({ out.header.privacy_class }, PrivacyClass::Anonymous.as_u8()); + + let payload = out.parse_payload().expect("payload still parses"); + assert!( + payload.compressed_angle_matrix.is_empty(), + "angle matrix must be stripped at class 2", + ); + // CSI delta also dropped at Anonymous. + assert!(payload.csi_delta.is_none(), "csi_delta dropped at class 2"); + // Sensing sections preserved. + assert_eq!(payload.snr_vector.len(), 8); + assert_eq!(payload.amplitude_proxy.len(), 16); +} + +#[test] +fn demote_derived_to_restricted_strips_amplitude_and_phase_too() { + let f = frame_at_class(PrivacyClass::Derived, true); + let out = PrivacyGate::demote(f, PrivacyClass::Restricted).expect("demote"); + assert_eq!({ out.header.privacy_class }, PrivacyClass::Restricted.as_u8()); + + let payload = out.parse_payload().expect("payload parses"); + assert!(payload.compressed_angle_matrix.is_empty()); + assert!(payload.amplitude_proxy.is_empty(), "amplitude stripped at class 3"); + assert!(payload.phase_proxy.is_empty(), "phase stripped at class 3"); + // SNR + vendor still survive. + assert_eq!(payload.snr_vector.len(), 8); + assert_eq!(payload.vendor_extension.len(), 1); +} + +#[test] +fn demote_anonymous_to_derived_is_rejected() { + let f = frame_at_class(PrivacyClass::Anonymous, false); + match PrivacyGate::demote(f, PrivacyClass::Derived) { + Err(BfldError::InvalidDemote { from, to }) => { + assert_eq!(from, PrivacyClass::Anonymous.as_u8()); + assert_eq!(to, PrivacyClass::Derived.as_u8()); + } + other => panic!("expected InvalidDemote, got {other:?}"), + } +} + +#[test] +fn demote_to_raw_is_rejected_from_any_higher_class() { + for src in [ + PrivacyClass::Derived, + PrivacyClass::Anonymous, + PrivacyClass::Restricted, + ] { + let f = frame_at_class(src, false); + match PrivacyGate::demote(f, PrivacyClass::Raw) { + Err(BfldError::InvalidDemote { .. }) => {} + other => panic!("expected InvalidDemote from {src:?}, got {other:?}"), + } + } +} + +#[test] +fn demote_preserves_frame_crc_consistency_through_wire_roundtrip() { + // Demote produces a frame; that frame must round-trip through bytes + // with no CRC error. + let f = frame_at_class(PrivacyClass::Derived, true); + let demoted = PrivacyGate::demote(f, PrivacyClass::Anonymous).expect("demote"); + let bytes = demoted.to_bytes(); + let parsed = BfldFrame::from_bytes(&bytes).expect("post-demote frame must round-trip"); + assert_eq!({ parsed.header.privacy_class }, PrivacyClass::Anonymous.as_u8()); +} + +#[test] +fn demote_clears_has_csi_delta_flag_bit() { + use wifi_densepose_bfld::frame::flags; + let f = frame_at_class(PrivacyClass::Derived, true); + assert_ne!({ f.header.flags } & flags::HAS_CSI_DELTA, 0); + + let out = PrivacyGate::demote(f, PrivacyClass::Anonymous).expect("demote"); + assert_eq!( + { out.header.flags } & flags::HAS_CSI_DELTA, + 0, + "HAS_CSI_DELTA must clear when csi_delta is stripped", + ); +}