Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/src/privacy_gate.rs
T
ruv 4a6498fc2f 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>
2026-05-24 14:48:01 -04:00

101 lines
4.2 KiB
Rust

//! `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) {}