feat(adr-118/p3.1): PrivacyGate::demote monotonic class transformer (60/60 GREEN)
Iter 9. Lands ADR-120 §2.4 — the only operation that can lower a frame's
information content. Demote is monotonic by construction (Result::Err
on non-monotone target), strips payload sections per the target class
table, and re-syncs header.privacy_class + CRC32.
Added:
- src/privacy_gate.rs (gated on `feature = "std"`):
* PrivacyGate unit struct (+ Default impl)
* PrivacyGate::demote(BfldFrame, target: PrivacyClass) -> Result<BfldFrame>
* Stripping policy:
target >= Anonymous (2): zeros + clears compressed_angle_matrix and
csi_delta; sets csi_delta = None so from_payload clears HAS_CSI_DELTA
target >= Restricted (3): also zeros + clears amplitude_proxy and phase_proxy
* zeroize_then_clear helper — overwrite with 0 then black_box then truncate
- BfldError::InvalidDemote { from: u8, to: u8 } variant
- pub use PrivacyGate from lib.rs
Note: demote does NOT zero the original Vec capacity that the heap allocator
may still hold — the buffers we own are zeroed and cleared, but the
intermediate Vec passed back to BfldFrame::from_payload reallocates anew.
For strict heap zeroization in regulated deployments, a follow-up iter can
substitute zeroize::Zeroizing<Vec<u8>>.
tests/privacy_gate_demote.rs (7 named tests, all green):
demote_to_same_class_is_identity
demote_derived_to_anonymous_strips_compressed_angle_matrix
(also asserts csi_delta dropped, snr_vector and amplitude_proxy preserved)
demote_derived_to_restricted_strips_amplitude_and_phase_too
(snr_vector and vendor_extension survive at class 3)
demote_anonymous_to_derived_is_rejected
(asserts InvalidDemote { from: 2, to: 1 })
demote_to_raw_is_rejected_from_any_higher_class
(parameterized over Derived, Anonymous, Restricted as sources)
demote_preserves_frame_crc_consistency_through_wire_roundtrip
(post-demote frame survives to_bytes -> from_bytes with no CRC error)
demote_clears_has_csi_delta_flag_bit
ACs progressed:
- AC5 ↑ — privacy_mode enforcement at the frame-class boundary now works
through PrivacyGate, not just the BfldEvent emitter (deferred). When the
active class is Anonymous (2) or Restricted (3), the angle matrix /
csi_delta / amplitude / phase sections that carry identity information
are zeroed before any downstream code sees them.
- AC4 ↑ — demoted frames retain valid CRC; the round-trip-through-bytes
test proves bit-correctness after the class transition.
Test config:
- cargo test --no-default-features → 31 passed (privacy_gate cfg-out)
- cargo test → 60 passed (53 + 7)
Out of scope (next iter target):
- SoulMatchOracle stub trait + no-op default impl (ADR-121 §2.6) so the
Recalibrate exemption hook is wireable from `--features soul-signature`.
- IdentityRiskEngine — multiplicative formula on (sep, stab, consist, conf)
with the coherence-gate GateAction enum (ADR-121 §2.2 + §2.4).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
60eaaa5af1
commit
4a6498fc2f
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<BfldFrame, BfldError> {
|
||||
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<u8>) {
|
||||
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) {}
|
||||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue