Files
ruvnet--RuView/v2/crates/wifi-densepose-privshield/src/attacker.rs
T
Claude 16b2a629d1 Add VEIL privacy shield: compliant-waveform defense against WiFi sensing (ADR-288)
VEIL (Verifiable Emission-shaping for Identity-Leakage prevention) is the
countermeasure counterpart to BFLD (ADR-118/121): where BFLD detects when
beamforming feedback becomes identifying, VEIL shapes a node's own outgoing
feedback so an unauthorized passive sniffer cannot re-identify people, while
a legitimate receiver that shares the per-session key sees an unchanged link.

Mechanism: identity leaks through the fine cross-subcarrier phase structure of
a compressed beamforming report; throughput rides the dominant beam direction.
These are (mostly) separable subspaces. VEIL composes extra keyed Givens
rotations (the report's native primitive) over the fine subspace only. The
rotation is orthogonal (energy-preserving -> not jamming), keyed per session
(the AP inverts it -> throughput preserved), and fresh each session (a sniffer
cannot average it back -> re-identification collapses to chance).

Contents:
- v2/crates/wifi-densepose-privshield: deterministic, dependency-free,
  WASM-ready pure-compute leaf implementing the attacker-vs-protector
  experiment, the four compliant controls, a throughput model, a
  machine-checkable "not jamming" compliance audit, and a pinned witness.
  29 tests + doctest pass; clippy -D warnings clean; builds for
  wasm32-unknown-unknown.
- docs/research/privacy-shield: 8-file research bundle (SOTA, threat model,
  design, compliance/regulatory, experiment protocol, market, roadmap).
- docs/adr/ADR-288: formal decision record.

Reference results (SYNTHETIC / L0, N=16 identities): passive re-ID accuracy
100% shield-off -> 7.8% shield-on (chance 6.25%); modeled throughput ratio
98.0%; emission energy ratio 1.000000 (compliant). All defense numbers are
SYNTHETIC until a two-node hardware capture with a witness exists.

Compliant waveform controls only; never jamming (47 U.S.C. 333/302a analysis
in the bundle).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
2026-08-09 13:51:12 +00:00

119 lines
4.1 KiB
Rust

//! The adversary: a passive re-identification classifier over captured
//! beamforming feedback.
//!
//! The attacker models the BFId/CCS-2025 threat: a sniffer that enrolls a
//! template per candidate from observed reports, then classifies fresh
//! captures. We use a **nearest-centroid** classifier over the full report
//! vector. It is deliberately simple but is the right shape for the effect
//! under test: it succeeds exactly when a *stable* per-identity signature
//! survives across capture sessions, and fails when the signature is rotated
//! unpredictably each session (which is what the protector does).
//!
//! Nearest-centroid is also the honest choice for the collapse claim: a more
//! elaborate classifier cannot recover identity that has been mapped through a
//! fresh secret orthogonal transform each session — the mutual information
//! between a Haar-rotated signature and the identity label, marginalized over
//! unknown rotations, is what the protector drives down. The classifier
//! strength is not the lever; signature stability is.
use crate::identity::BfiSample;
use crate::linalg::dist_sq;
/// A nearest-centroid re-identification attacker.
#[derive(Debug, Clone, Default)]
pub struct NearestCentroidAttacker {
centroids: Vec<Vec<f32>>,
ids: Vec<usize>,
}
impl NearestCentroidAttacker {
/// Build an empty attacker.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Enroll from labeled captures: one centroid per identity, the mean of
/// that identity's observed report vectors.
pub fn enroll(&mut self, samples: &[(usize, BfiSample)]) {
// Group by identity, preserving first-seen order.
let mut ids: Vec<usize> = Vec::new();
let mut sums: Vec<Vec<f32>> = Vec::new();
let mut counts: Vec<usize> = Vec::new();
for (id, s) in samples {
let slot = ids.iter().position(|x| x == id).unwrap_or_else(|| {
ids.push(*id);
sums.push(vec![0.0; s.values.len()]);
counts.push(0);
ids.len() - 1
});
for (acc, v) in sums[slot].iter_mut().zip(&s.values) {
*acc += v;
}
counts[slot] += 1;
}
for (sum, &c) in sums.iter_mut().zip(&counts) {
if c > 0 {
let inv = 1.0 / c as f32;
for v in sum.iter_mut() {
*v *= inv;
}
}
}
self.ids = ids;
self.centroids = sums;
}
/// Classify a capture to the nearest enrolled centroid. Returns the
/// predicted identity, or `None` if the attacker has not enrolled.
#[must_use]
pub fn classify(&self, sample: &BfiSample) -> Option<usize> {
let mut best: Option<(usize, f32)> = None;
for (id, c) in self.ids.iter().zip(&self.centroids) {
let d = dist_sq(c, &sample.values);
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((*id, d));
}
}
best.map(|(id, _)| id)
}
/// Top-1 re-identification accuracy over a labeled test set.
#[must_use]
pub fn accuracy(&self, test: &[(usize, BfiSample)]) -> f32 {
if test.is_empty() {
return 0.0;
}
let correct = test
.iter()
.filter(|(id, s)| self.classify(s) == Some(*id))
.count();
correct as f32 / test.len() as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{Channel, SceneConfig};
#[test]
fn attacker_re_ids_unprotected_traffic() {
let ch = Channel::new(SceneConfig::default());
let mut enroll = Vec::new();
let mut test = Vec::new();
for id in 0..ch.config().identities {
for s in 0..12 {
enroll.push((id, ch.observe(id, b"enroll", s)));
}
for s in 0..12 {
test.push((id, ch.observe(id, b"test", s)));
}
}
let mut atk = NearestCentroidAttacker::new();
atk.enroll(&enroll);
// On unprotected traffic the stable signature is trivially recovered.
assert!(atk.accuracy(&test) > 0.85);
}
}