mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
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
This commit is contained in:
@@ -21,6 +21,7 @@ members = [
|
||||
"crates/wifi-densepose-train",
|
||||
"crates/wifi-densepose-sensing-server",
|
||||
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
|
||||
"crates/wifi-densepose-privshield", # ADR-288 — VEIL privacy shield (compliant-waveform anti-sensing; std-only leaf)
|
||||
"crates/wifi-densepose-wifiscan",
|
||||
"crates/wifi-densepose-vitals",
|
||||
"crates/wifi-densepose-ruvector",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "wifi-densepose-privshield"
|
||||
description = "VEIL privacy shield (ADR-288): compliant-waveform countermeasure against unauthorized WiFi sensing. Deterministic attacker-vs-protector experiment that drives beamforming-feedback identity inference toward chance while preserving link throughput. Std-only pure-compute leaf, no async/server/RF-hardware deps; SYNTHETIC data only."
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
# Intentionally dependency-free (mirrors `wifi-densepose-aether`, ADR-185 §13).
|
||||
# VEIL is a pure-compute experiment/reference: no `rand` (its own deterministic
|
||||
# PRNG), no `std::time`/`std::fs`/`std::env`/threads, so it builds unchanged for
|
||||
# `wasm32-unknown-unknown` and can never emit RF or touch a radio. The shield
|
||||
# *models* compliant waveform controls; it does not drive hardware.
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_privshield"
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,69 @@
|
||||
# wifi-densepose-privshield — VEIL
|
||||
|
||||
**VEIL** (Verifiable Emission-shaping for Identity-Leakage prevention) is the
|
||||
compliant-waveform **countermeasure** counterpart to
|
||||
[BFLD](../wifi-densepose-bfld) (ADR-118/121). BFLD *detects* when beamforming
|
||||
feedback becomes identifying; VEIL *acts* — it shapes a node's own outgoing
|
||||
beamforming feedback so that an unauthorized passive sniffer cannot
|
||||
re-identify people or infer activity, while a legitimate receiver (which shares
|
||||
the per-session key) sees an essentially unchanged link.
|
||||
|
||||
This crate is a **deterministic, dependency-free, WASM-ready reference and
|
||||
experiment** — not a radio driver. It never emits RF. Every number it prints is
|
||||
`SYNTHETIC`, reproduced by `cargo test -p wifi-densepose-privshield`.
|
||||
|
||||
See [ADR-288](../../../docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md)
|
||||
and the [research bundle](../../../docs/research/privacy-shield/).
|
||||
|
||||
## The idea
|
||||
|
||||
Identity leaks through the **fine** cross-subcarrier phase structure of a
|
||||
compressed beamforming report; data throughput rides the **dominant** beam
|
||||
direction. These live in (mostly) separable subspaces. VEIL composes extra
|
||||
**keyed Givens rotations** — the exact primitive the report is already built
|
||||
from — over the *fine* subspace only:
|
||||
|
||||
| Property | Consequence |
|
||||
|---|---|
|
||||
| **Orthogonal** (energy-preserving) | No added transmit power ⇒ **not jamming** (47 U.S.C. §333/§302a) |
|
||||
| **Keyed per session** | The legitimate AP inverts it ⇒ throughput preserved |
|
||||
| **Fresh each session** | A sniffer sees a different rotation every time and can't average it back ⇒ re-identification collapses to chance |
|
||||
|
||||
## Result (default synthetic scene, N = 16 identities)
|
||||
|
||||
| Metric | Shield off | Shield on |
|
||||
|---|---|---|
|
||||
| Passive re-ID accuracy | **100%** | **7.8%** (chance = 6.25%) |
|
||||
| Link throughput ratio | 100% | **98.0%** |
|
||||
| Emission energy ratio | — | **1.000000** (compliant) |
|
||||
|
||||
## Threat model & scope (stated plainly)
|
||||
|
||||
VEIL defends against a **third-party passive sniffer** capturing plaintext
|
||||
beamforming feedback. It does **not** hide identity from the AP a node is
|
||||
associated with (that party holds the key by construction) — that is BFLD's
|
||||
detection/policy problem, not this shield's. It is **compliant by
|
||||
construction**: it only shapes the node's own standards-conformant frames, never
|
||||
transmits to interfere with another station, and never operates an unauthorized
|
||||
emitter. It is not jamming, not RF denial, and not a claim of camera-grade
|
||||
anything.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cargo test -p wifi-densepose-privshield --no-default-features
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `prng` | Deterministic, WASM-safe PRNG + key derivation |
|
||||
| `linalg` | Givens-rotation vector algebra |
|
||||
| `identity` | SYNTHETIC two-subspace beamforming-feedback model |
|
||||
| `protector` | The compliant waveform controls (the shield) |
|
||||
| `attacker` | Passive re-identification adversary |
|
||||
| `throughput` | Link-throughput model |
|
||||
| `compliance` | Machine-checkable "not jamming" audit |
|
||||
| `experiment` | Attacker-vs-protector head-to-head |
|
||||
| `proof` | Byte-stable deterministic witness |
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Machine-checkable compliance: the shield shapes its own frames, never jams.
|
||||
//!
|
||||
//! Jamming (47 U.S.C. §333, §302a) is defined by *adding energy to interfere
|
||||
//! with others' transmissions*. VEIL's protector applies an **orthogonal**
|
||||
//! transform to its own beamforming feedback, which preserves the report's
|
||||
//! energy exactly. This module turns that invariant into a checked artifact: it
|
||||
//! measures the input/output energy of a protection step and asserts the ratio
|
||||
//! is ~1, i.e. no energy was added. A regulator, an auditor, or the runtime
|
||||
//! attestation layer (ADR-141) can read a [`ComplianceReport`] and see the
|
||||
//! shield is a waveform-shaping control, not an emitter of interference.
|
||||
|
||||
use crate::identity::BfiSample;
|
||||
use crate::linalg::norm_sq;
|
||||
|
||||
/// Tolerance on the energy ratio. Orthogonal rotations are exact up to f32
|
||||
/// round-off across many Givens passes.
|
||||
pub const ENERGY_TOLERANCE: f32 = 1e-2;
|
||||
|
||||
/// The result of auditing one protection step.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ComplianceReport {
|
||||
/// Energy of the report before protection.
|
||||
pub input_energy: f32,
|
||||
/// Energy of the report after protection.
|
||||
pub output_energy: f32,
|
||||
/// `output_energy / input_energy`. ~1.0 for an energy-preserving control.
|
||||
pub energy_ratio: f32,
|
||||
/// True iff the energy ratio is within [`ENERGY_TOLERANCE`] of 1.0.
|
||||
pub energy_conserving: bool,
|
||||
/// True iff the control adds energy on top of another station's signal.
|
||||
/// Always false for VEIL by construction — it transforms its own report.
|
||||
pub adds_interfering_energy: bool,
|
||||
}
|
||||
|
||||
impl ComplianceReport {
|
||||
/// Audit a `(before, after)` protection pair.
|
||||
#[must_use]
|
||||
pub fn audit(before: &BfiSample, after: &BfiSample) -> Self {
|
||||
let input_energy = norm_sq(&before.values);
|
||||
let output_energy = norm_sq(&after.values);
|
||||
let energy_ratio = if input_energy > 1e-12 {
|
||||
output_energy / input_energy
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
Self {
|
||||
input_energy,
|
||||
output_energy,
|
||||
energy_ratio,
|
||||
energy_conserving: (energy_ratio - 1.0).abs() <= ENERGY_TOLERANCE,
|
||||
adds_interfering_energy: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bottom-line compliance verdict: energy-preserving and
|
||||
/// non-interfering ⇒ a compliant waveform control, not jamming.
|
||||
#[must_use]
|
||||
pub fn is_compliant(&self) -> bool {
|
||||
self.energy_conserving && !self.adds_interfering_energy
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::{Channel, SceneConfig};
|
||||
use crate::protector::{Protector, ShieldConfig};
|
||||
|
||||
#[test]
|
||||
fn protection_is_compliant() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
let s = ch.observe(0, b"enroll", 3);
|
||||
let p = Protector::new(ShieldConfig::default());
|
||||
let out = p.protect(&s, 555);
|
||||
let report = ComplianceReport::audit(&s, &out);
|
||||
assert!(report.is_compliant(), "{report:?}");
|
||||
assert!((report.energy_ratio - 1.0).abs() < ENERGY_TOLERANCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! The attacker-vs-protector head-to-head.
|
||||
//!
|
||||
//! This is the "one node is the attacker, one node is the protector" experiment
|
||||
//! from the project brief, in deterministic synthetic form. It runs the passive
|
||||
//! re-identification attacker ([`crate::attacker`]) twice — once against
|
||||
//! unprotected traffic and once against traffic shaped by the protector
|
||||
//! ([`crate::protector`]) — and reports both accuracies against the chance
|
||||
//! floor, alongside the modeled link throughput ([`crate::throughput`]) and a
|
||||
//! compliance audit ([`crate::compliance`]).
|
||||
//!
|
||||
//! Success criteria (the brief's own bar):
|
||||
//! 1. protection drives re-identification toward chance (`1/identities`);
|
||||
//! 2. throughput stays above 95% of the unshielded baseline;
|
||||
//! 3. the control is compliant (energy-preserving, non-jamming).
|
||||
|
||||
use crate::attacker::NearestCentroidAttacker;
|
||||
use crate::compliance::ComplianceReport;
|
||||
use crate::identity::{Channel, SceneConfig};
|
||||
use crate::prng::derive_key;
|
||||
use crate::protector::{Protector, ShieldConfig};
|
||||
use crate::throughput::LinkModel;
|
||||
|
||||
/// Configuration for a full experiment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExperimentConfig {
|
||||
/// Synthetic scene.
|
||||
pub scene: SceneConfig,
|
||||
/// Protector configuration.
|
||||
pub shield: ShieldConfig,
|
||||
/// Link model for the throughput estimate.
|
||||
pub link: LinkModel,
|
||||
/// Enrollment sessions per identity.
|
||||
pub enroll_sessions: u64,
|
||||
/// Test sessions per identity.
|
||||
pub test_sessions: u64,
|
||||
/// Accept re-ID as "at chance" if it is at or below
|
||||
/// `chance × chance_multiple + chance_margin`.
|
||||
pub chance_multiple: f32,
|
||||
/// Additive slack on the chance band.
|
||||
pub chance_margin: f32,
|
||||
/// Minimum acceptable throughput ratio.
|
||||
pub min_throughput_ratio: f64,
|
||||
}
|
||||
|
||||
impl Default for ExperimentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scene: SceneConfig::default(),
|
||||
shield: ShieldConfig::default(),
|
||||
link: LinkModel::default(),
|
||||
enroll_sessions: 12,
|
||||
test_sessions: 12,
|
||||
chance_multiple: 2.0,
|
||||
chance_margin: 0.03,
|
||||
min_throughput_ratio: 0.95,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of an experiment.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExperimentReport {
|
||||
/// Number of candidate identities.
|
||||
pub identities: usize,
|
||||
/// Ideal chance-level accuracy (`1/identities`).
|
||||
pub chance_level: f32,
|
||||
/// Re-identification accuracy with the shield off.
|
||||
pub accuracy_shield_off: f32,
|
||||
/// Re-identification accuracy with the shield on.
|
||||
pub accuracy_shield_on: f32,
|
||||
/// Modeled throughput ratio of the protected link vs baseline.
|
||||
pub throughput_ratio: f64,
|
||||
/// Compliance audit of a representative protected frame.
|
||||
pub compliance: ComplianceReport,
|
||||
/// Upper edge of the accepted "at chance" band.
|
||||
pub chance_band: f32,
|
||||
}
|
||||
|
||||
impl ExperimentReport {
|
||||
/// Did protection drive re-identification into the chance band?
|
||||
#[must_use]
|
||||
pub fn drives_to_chance(&self) -> bool {
|
||||
self.accuracy_shield_on <= self.chance_band
|
||||
}
|
||||
|
||||
/// Is the shield-off attacker meaningfully better than chance (i.e. the
|
||||
/// threat is real in this scene, so the collapse is meaningful)?
|
||||
#[must_use]
|
||||
pub fn attack_is_effective_without_shield(&self) -> bool {
|
||||
self.accuracy_shield_off >= 0.5
|
||||
}
|
||||
|
||||
/// Did throughput stay above the required floor?
|
||||
#[must_use]
|
||||
pub fn preserves_throughput(&self) -> bool {
|
||||
self.throughput_ratio >= 0.95
|
||||
}
|
||||
|
||||
/// Overall pass: real threat, collapsed to chance, throughput preserved,
|
||||
/// and compliant.
|
||||
#[must_use]
|
||||
pub fn passed(&self) -> bool {
|
||||
self.attack_is_effective_without_shield()
|
||||
&& self.drives_to_chance()
|
||||
&& self.preserves_throughput()
|
||||
&& self.compliance.is_compliant()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the enroll/test capture sets for a given shield, then measure attacker
|
||||
/// accuracy. `shield_on` selects whether the protector is applied to every
|
||||
/// captured frame (the attacker only ever sees what is transmitted).
|
||||
fn measure_accuracy(cfg: &ExperimentConfig, protector: &Protector, shield_on: bool) -> f32 {
|
||||
let ch = Channel::new(cfg.scene.clone());
|
||||
let mut enroll = Vec::new();
|
||||
let mut test = Vec::new();
|
||||
|
||||
for id in 0..cfg.scene.identities {
|
||||
for s in 0..cfg.enroll_sessions {
|
||||
let raw = ch.observe(id, b"enroll", s);
|
||||
let seen = if shield_on {
|
||||
// Per-session precoder rotation is the SAME for every identity
|
||||
// present in that session (the AP rotates its precoder per
|
||||
// sounding interval, not per person). Keying it on the session
|
||||
// is what lets a legitimate receiver invert it and what makes
|
||||
// the attacker's cross-session average collapse.
|
||||
let key = derive_key(cfg.scene.seed, b"rot-enroll", s, 0);
|
||||
protector.protect(&raw, key)
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
enroll.push((id, seen));
|
||||
}
|
||||
for s in 0..cfg.test_sessions {
|
||||
let raw = ch.observe(id, b"test", s);
|
||||
let seen = if shield_on {
|
||||
let key = derive_key(cfg.scene.seed, b"rot-test", s, 0);
|
||||
protector.protect(&raw, key)
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
test.push((id, seen));
|
||||
}
|
||||
}
|
||||
|
||||
let mut atk = NearestCentroidAttacker::new();
|
||||
atk.enroll(&enroll);
|
||||
atk.accuracy(&test)
|
||||
}
|
||||
|
||||
/// Run the full attacker-vs-protector experiment.
|
||||
#[must_use]
|
||||
pub fn run(cfg: &ExperimentConfig) -> ExperimentReport {
|
||||
let protector = Protector::new(cfg.shield.clone());
|
||||
|
||||
let accuracy_shield_off = measure_accuracy(cfg, &protector, false);
|
||||
let accuracy_shield_on = measure_accuracy(cfg, &protector, true);
|
||||
|
||||
let throughput_ratio = cfg.link.throughput_ratio(&cfg.shield);
|
||||
|
||||
// Representative compliance audit: one protected frame vs its clean form.
|
||||
let ch = Channel::new(cfg.scene.clone());
|
||||
let clean = ch.observe(0, b"test", 0);
|
||||
let protected = protector.protect(&clean, derive_key(cfg.scene.seed, b"rot-test", 0, 0));
|
||||
let compliance = ComplianceReport::audit(&clean, &protected);
|
||||
|
||||
let chance_level = cfg.scene.chance_level();
|
||||
let chance_band = chance_level * cfg.chance_multiple + cfg.chance_margin;
|
||||
|
||||
ExperimentReport {
|
||||
identities: cfg.scene.identities,
|
||||
chance_level,
|
||||
accuracy_shield_off,
|
||||
accuracy_shield_on,
|
||||
throughput_ratio,
|
||||
compliance,
|
||||
chance_band,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shield_off_attack_succeeds() {
|
||||
let report = run(&ExperimentConfig::default());
|
||||
assert!(
|
||||
report.attack_is_effective_without_shield(),
|
||||
"shield-off accuracy {} should be well above chance {}",
|
||||
report.accuracy_shield_off,
|
||||
report.chance_level
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shield_on_drives_to_chance() {
|
||||
let report = run(&ExperimentConfig::default());
|
||||
assert!(
|
||||
report.drives_to_chance(),
|
||||
"shield-on accuracy {} should be within chance band {}",
|
||||
report.accuracy_shield_on,
|
||||
report.chance_band
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shield_preserves_throughput() {
|
||||
let report = run(&ExperimentConfig::default());
|
||||
assert!(
|
||||
report.preserves_throughput(),
|
||||
"throughput ratio {} below 0.95",
|
||||
report.throughput_ratio
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overall_experiment_passes() {
|
||||
let report = run(&ExperimentConfig::default());
|
||||
assert!(report.passed(), "{report:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experiment_is_deterministic() {
|
||||
assert_eq!(
|
||||
run(&ExperimentConfig::default()),
|
||||
run(&ExperimentConfig::default())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Synthetic beamforming-feedback model. **SYNTHETIC data only.**
|
||||
//!
|
||||
//! Nothing here is captured from a real radio. The model is a deliberately
|
||||
//! simple, physically-motivated abstraction of a flattened 802.11 compressed
|
||||
//! beamforming report, chosen so the attacker/protector dynamics are
|
||||
//! transparent and the experiment is byte-reproducible. It is *not* a channel
|
||||
//! simulator and its accuracy numbers describe this model, not real hardware
|
||||
//! (per CLAUDE.md: results are `SYNTHETIC`, reproduced by `cargo test`).
|
||||
//!
|
||||
//! # The two-subspace abstraction
|
||||
//!
|
||||
//! A beamforming report is split into two orthogonal blocks:
|
||||
//!
|
||||
//! - **Comm block** (`comm_dims` leading coordinates) — the dominant beam
|
||||
//! direction the AP actually uses to steer data. It varies per session with
|
||||
//! position/traffic and carries **no** identity. Link throughput rides here.
|
||||
//! - **Fine block** (the remainder) — the fine cross-subcarrier phase
|
||||
//! structure. This is where a re-identification attacker's signal lives: the
|
||||
//! literature (BFId, CCS 2025) shows the *stable* fine structure re-IDs
|
||||
//! people. Communication barely uses it.
|
||||
//!
|
||||
//! Each identity owns a fixed, near-orthogonal signature vector in the fine
|
||||
//! block. A session observation is `signature + environmental nuisance`; the
|
||||
//! comm block is fresh per session. This is the honest crux of the whole
|
||||
//! design: **identity leakage and data throughput live in (mostly) separable
|
||||
//! subspaces**, so a transform can wreck the former while sparing the latter.
|
||||
|
||||
use crate::linalg::set_norm_inplace;
|
||||
use crate::prng::{derive_key, Rng};
|
||||
|
||||
/// A flattened compressed-beamforming-report vector, split into a comm block
|
||||
/// and a fine block.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BfiSample {
|
||||
/// The full report: `comm_dims` comm coordinates followed by fine ones.
|
||||
pub values: Vec<f32>,
|
||||
/// Number of leading coordinates that form the comm (data-carrying) block.
|
||||
pub comm_dims: usize,
|
||||
}
|
||||
|
||||
impl BfiSample {
|
||||
/// Comm (data-carrying) block.
|
||||
#[must_use]
|
||||
pub fn comm(&self) -> &[f32] {
|
||||
&self.values[..self.comm_dims]
|
||||
}
|
||||
|
||||
/// Fine (identity-bearing) block.
|
||||
#[must_use]
|
||||
pub fn fine(&self) -> &[f32] {
|
||||
&self.values[self.comm_dims..]
|
||||
}
|
||||
|
||||
/// Mutable fine block — the only part the protector is allowed to rotate.
|
||||
pub fn fine_mut(&mut self) -> &mut [f32] {
|
||||
&mut self.values[self.comm_dims..]
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration of the synthetic scene.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SceneConfig {
|
||||
/// Total report dimension.
|
||||
pub dim: usize,
|
||||
/// Leading coordinates forming the comm block.
|
||||
pub comm_dims: usize,
|
||||
/// Number of distinct identities (candidates). Chance level is `1/identities`.
|
||||
pub identities: usize,
|
||||
/// L2 norm of each identity's fine-block signature.
|
||||
pub signature_norm: f32,
|
||||
/// Std-dev of per-session environmental nuisance added to the fine block.
|
||||
pub env_sigma: f32,
|
||||
/// L2 norm of the fresh per-session comm-block beam.
|
||||
pub beam_amplitude: f32,
|
||||
/// Master seed. All keys derive from this; nothing touches OS entropy.
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for SceneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dim: 64,
|
||||
comm_dims: 8,
|
||||
identities: 16,
|
||||
signature_norm: 1.0,
|
||||
env_sigma: 0.15,
|
||||
beam_amplitude: 0.30,
|
||||
seed: 0x5EED_1BF1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SceneConfig {
|
||||
/// Ideal chance-level accuracy, `1 / identities`.
|
||||
#[must_use]
|
||||
pub fn chance_level(&self) -> f32 {
|
||||
1.0 / self.identities as f32
|
||||
}
|
||||
|
||||
/// Length of the fine block.
|
||||
#[must_use]
|
||||
pub fn fine_dims(&self) -> usize {
|
||||
self.dim - self.comm_dims
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic channel: turns `(identity, session)` into a [`BfiSample`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Channel {
|
||||
cfg: SceneConfig,
|
||||
/// Precomputed per-identity fine-block signatures.
|
||||
signatures: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
/// Build the channel, drawing each identity's stable signature.
|
||||
#[must_use]
|
||||
pub fn new(cfg: SceneConfig) -> Self {
|
||||
let fine = cfg.fine_dims();
|
||||
let mut signatures = Vec::with_capacity(cfg.identities);
|
||||
for id in 0..cfg.identities {
|
||||
let mut rng = Rng::new(derive_key(cfg.seed, b"signature", id as u64, 0));
|
||||
let mut s: Vec<f32> = (0..fine).map(|_| rng.next_gaussian()).collect();
|
||||
set_norm_inplace(&mut s, cfg.signature_norm);
|
||||
signatures.push(s);
|
||||
}
|
||||
Self { cfg, signatures }
|
||||
}
|
||||
|
||||
/// The scene configuration.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> &SceneConfig {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
/// The stable fine-block signature of `identity` (the thing an attacker
|
||||
/// wants and the thing the shield must hide).
|
||||
#[must_use]
|
||||
pub fn signature(&self, identity: usize) -> &[f32] {
|
||||
&self.signatures[identity]
|
||||
}
|
||||
|
||||
/// Observe the unprotected report for `identity` in the given session under
|
||||
/// `phase` (an experiment stage label, e.g. `b"enroll"` / `b"test"`, so the
|
||||
/// same session index draws independent nuisance across stages).
|
||||
#[must_use]
|
||||
pub fn observe(&self, identity: usize, phase: &[u8], session: u64) -> BfiSample {
|
||||
let cfg = &self.cfg;
|
||||
let mut values = vec![0.0f32; cfg.dim];
|
||||
|
||||
// Comm block: fresh per session, identity-independent. This is the
|
||||
// data-carrying dominant beam — it holds no re-ID information.
|
||||
let mut brng = Rng::new(derive_key(cfg.seed, b"beam", session, phase[0] as u64));
|
||||
for v in values[..cfg.comm_dims].iter_mut() {
|
||||
*v = brng.next_gaussian();
|
||||
}
|
||||
set_norm_inplace(&mut values[..cfg.comm_dims], cfg.beam_amplitude);
|
||||
|
||||
// Fine block: stable identity signature + per-session nuisance.
|
||||
let mut nrng = Rng::new(derive_key(cfg.seed, phase, identity as u64, session));
|
||||
let sig = &self.signatures[identity];
|
||||
for (v, s) in values[cfg.comm_dims..].iter_mut().zip(sig) {
|
||||
*v = s + cfg.env_sigma * nrng.next_gaussian();
|
||||
}
|
||||
|
||||
BfiSample {
|
||||
values,
|
||||
comm_dims: cfg.comm_dims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::linalg::{dist_sq, norm};
|
||||
|
||||
#[test]
|
||||
fn signatures_are_well_separated() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
// Distinct identities' signatures are near-orthogonal in high-dim,
|
||||
// so pairwise distance is large relative to env noise.
|
||||
let d = dist_sq(ch.signature(0), ch.signature(1)).sqrt();
|
||||
assert!(d > 1.0, "signatures too close: {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_norm_matches_config() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
assert!((norm(ch.signature(3)) - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_is_deterministic() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
assert_eq!(ch.observe(2, b"enroll", 5), ch.observe(2, b"enroll", 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_session_different_phase_differs() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
assert_ne!(ch.observe(2, b"enroll", 5), ch.observe(2, b"test", 5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! # VEIL — a compliant-waveform privacy shield against WiFi sensing
|
||||
//!
|
||||
//! VEIL (Verifiable Emission-shaping for Identity-Leakage prevention) is the
|
||||
//! countermeasure counterpart to BFLD (ADR-118/121, `wifi-densepose-bfld`).
|
||||
//! Where BFLD *detects* when beamforming feedback becomes identifying, VEIL
|
||||
//! *acts*: it shapes a node's own outgoing beamforming feedback so that an
|
||||
//! unauthorized passive sniffer cannot re-identify people or infer activity,
|
||||
//! while a legitimate receiver — which shares the per-session key — sees an
|
||||
//! essentially unchanged link.
|
||||
//!
|
||||
//! This crate is a **deterministic, dependency-free, WASM-ready reference and
|
||||
//! experiment**, not a radio driver. It models the physics faithfully enough to
|
||||
//! measure the core claim, and it never emits RF. Per ADR-288 and CLAUDE.md,
|
||||
//! every number it produces is `SYNTHETIC`, reproduced by
|
||||
//! `cargo test -p wifi-densepose-privshield`.
|
||||
//!
|
||||
//! ## The idea in one paragraph
|
||||
//!
|
||||
//! Identity leaks through the *fine* cross-subcarrier phase structure of a
|
||||
//! compressed beamforming report; data throughput rides the *dominant* beam
|
||||
//! direction. These live in (mostly) separable subspaces. VEIL composes extra
|
||||
//! keyed [`linalg::apply_givens`] rotations — the exact primitive the report is
|
||||
//! already built from — over the **fine** subspace only. The rotation is:
|
||||
//! orthogonal (energy-preserving ⇒ no added transmit power ⇒ **not jamming**,
|
||||
//! [`compliance`]); keyed per session (the legitimate AP inverts it ⇒
|
||||
//! throughput preserved, [`throughput`]); and fresh each session (a sniffer
|
||||
//! sees a different rotation every time and cannot average back the signature
|
||||
//! ⇒ re-identification collapses to chance, [`attacker`]/[`experiment`]).
|
||||
//!
|
||||
//! ## Threat model and scope (stated plainly)
|
||||
//!
|
||||
//! VEIL defends against a **third-party passive sniffer** capturing
|
||||
//! plaintext beamforming feedback. It does **not** hide identity from the AP a
|
||||
//! node is associated with (that party holds the key). It is **compliant by
|
||||
//! construction**: it only shapes the node's own standards-conformant frames;
|
||||
//! it never transmits to interfere with another station (47 U.S.C. §333) and
|
||||
//! never operates an unauthorized emitter (§302a). It is not jamming, not RF
|
||||
//! denial, and not a claim of camera-grade anything.
|
||||
//!
|
||||
//! ## Modules
|
||||
//!
|
||||
//! - [`prng`] — deterministic, WASM-safe PRNG and key derivation.
|
||||
//! - [`linalg`] — the small Givens-rotation vector algebra.
|
||||
//! - [`identity`] — the SYNTHETIC two-subspace beamforming-feedback model.
|
||||
//! - [`protector`] — the compliant waveform controls (the shield).
|
||||
//! - [`attacker`] — the passive re-identification adversary.
|
||||
//! - [`throughput`] — the link-throughput model.
|
||||
//! - [`compliance`] — the machine-checkable "not jamming" audit.
|
||||
//! - [`experiment`] — the attacker-vs-protector head-to-head.
|
||||
//! - [`proof`] — the byte-stable deterministic witness.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use wifi_densepose_privshield::experiment::{run, ExperimentConfig};
|
||||
//!
|
||||
//! let report = run(&ExperimentConfig::default());
|
||||
//! assert!(report.attack_is_effective_without_shield()); // threat is real
|
||||
//! assert!(report.drives_to_chance()); // shield collapses re-ID
|
||||
//! assert!(report.preserves_throughput()); // throughput ≥ 95%
|
||||
//! assert!(report.compliance.is_compliant()); // energy-preserving
|
||||
//! ```
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod attacker;
|
||||
pub mod compliance;
|
||||
pub mod experiment;
|
||||
pub mod identity;
|
||||
pub mod linalg;
|
||||
pub mod prng;
|
||||
pub mod proof;
|
||||
pub mod protector;
|
||||
pub mod throughput;
|
||||
|
||||
pub use compliance::ComplianceReport;
|
||||
pub use experiment::{run, ExperimentConfig, ExperimentReport};
|
||||
pub use identity::{BfiSample, Channel, SceneConfig};
|
||||
pub use proof::Proof;
|
||||
pub use protector::{Protector, SensingDetector, ShieldConfig};
|
||||
pub use throughput::LinkModel;
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Minimal, dependency-free vector algebra over `f32` slices.
|
||||
//!
|
||||
//! VEIL deliberately avoids `ndarray`/BLAS: the vectors are short (tens of
|
||||
//! elements — a flattened compressed-beamforming angle report), the crate is
|
||||
//! a WASM-ready leaf, and keeping the math inline makes the energy-conservation
|
||||
//! proof in [`crate::compliance`] auditable line-by-line.
|
||||
|
||||
/// Euclidean inner product. Panics if lengths differ.
|
||||
#[must_use]
|
||||
pub fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
assert_eq!(a.len(), b.len(), "dot: length mismatch");
|
||||
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
/// Squared L2 norm.
|
||||
#[must_use]
|
||||
pub fn norm_sq(a: &[f32]) -> f32 {
|
||||
a.iter().map(|x| x * x).sum()
|
||||
}
|
||||
|
||||
/// L2 norm.
|
||||
#[must_use]
|
||||
pub fn norm(a: &[f32]) -> f32 {
|
||||
norm_sq(a).sqrt()
|
||||
}
|
||||
|
||||
/// Squared Euclidean distance. Panics if lengths differ.
|
||||
#[must_use]
|
||||
pub fn dist_sq(a: &[f32], b: &[f32]) -> f32 {
|
||||
assert_eq!(a.len(), b.len(), "dist_sq: length mismatch");
|
||||
a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
|
||||
}
|
||||
|
||||
/// Scale in place.
|
||||
pub fn scale_inplace(a: &mut [f32], k: f32) {
|
||||
for x in a.iter_mut() {
|
||||
*x *= k;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize `a` to a target L2 norm in place. No-op if `a` is (near) zero.
|
||||
pub fn set_norm_inplace(a: &mut [f32], target: f32) {
|
||||
let n = norm(a);
|
||||
if n > 1e-12 {
|
||||
scale_inplace(a, target / n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a Givens rotation to coordinates `(i, j)` of `v` by angle `theta`.
|
||||
///
|
||||
/// A Givens rotation is the exact primitive 802.11 compressed beamforming
|
||||
/// feedback is built from (the ψ/φ angles a beamformee reports). It is an
|
||||
/// **orthogonal** operation: it preserves `‖v‖` to machine precision, which is
|
||||
/// precisely why composing extra keyed Givens rotations adds *no transmit
|
||||
/// energy* — the compliance argument in [`crate::compliance`].
|
||||
pub fn apply_givens(v: &mut [f32], i: usize, j: usize, theta: f32) {
|
||||
debug_assert!(i < v.len() && j < v.len() && i != j);
|
||||
let (c, s) = (theta.cos(), theta.sin());
|
||||
let (vi, vj) = (v[i], v[j]);
|
||||
v[i] = c * vi - s * vj;
|
||||
v[j] = s * vi + c * vj;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn givens_preserves_norm() {
|
||||
let mut v = vec![0.3, -1.2, 0.7, 2.1, -0.5];
|
||||
let before = norm(&v);
|
||||
apply_givens(&mut v, 1, 3, 0.9);
|
||||
apply_givens(&mut v, 0, 4, -2.3);
|
||||
apply_givens(&mut v, 2, 3, 1.1);
|
||||
let after = norm(&v);
|
||||
assert!((before - after).abs() < 1e-5, "{before} vs {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn givens_is_invertible() {
|
||||
let orig = vec![1.0f32, 2.0, 3.0, 4.0];
|
||||
let mut v = orig.clone();
|
||||
apply_givens(&mut v, 0, 2, 0.7);
|
||||
apply_givens(&mut v, 0, 2, -0.7);
|
||||
for (a, b) in orig.iter().zip(&v) {
|
||||
assert!((a - b).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Deterministic, WASM-safe pseudo-random generator.
|
||||
//!
|
||||
//! VEIL never draws from OS entropy: every stochastic quantity in the
|
||||
//! experiment (identity signatures, environmental nuisance, per-session
|
||||
//! precoder rotations) seeds from an explicit `u64`. Same seed in → same
|
||||
//! bytes out, on any platform including `wasm32-unknown-unknown`. This is
|
||||
//! what makes [`crate::proof`] a byte-stable witness rather than a flaky
|
||||
//! statistical assertion.
|
||||
//!
|
||||
//! The core is SplitMix64 (Steele, Lea & Flood 2014) — a well-mixed
|
||||
//! finalizer that is more than adequate for synthetic-data generation and
|
||||
//! keyed subspace rotation. It is **not** a cryptographic RNG and must not
|
||||
//! be used to derive real key material; in a deployment the per-session
|
||||
//! rotation key comes from the negotiated link secret, not from this PRNG.
|
||||
|
||||
/// A deterministic SplitMix64 stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Rng {
|
||||
/// Seed the stream. Distinct seeds yield independent streams.
|
||||
#[must_use]
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
state: seed ^ 0x9E37_79B9_7F4A_7C15,
|
||||
}
|
||||
}
|
||||
|
||||
/// Next raw 64-bit word.
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Uniform `f32` in `[0, 1)` using the top 24 mantissa bits.
|
||||
pub fn next_f32(&mut self) -> f32 {
|
||||
// 24 bits of precision keeps the value exactly representable.
|
||||
((self.next_u64() >> 40) as f32) / ((1u64 << 24) as f32)
|
||||
}
|
||||
|
||||
/// Uniform `f32` in `[lo, hi)`.
|
||||
pub fn next_range(&mut self, lo: f32, hi: f32) -> f32 {
|
||||
lo + (hi - lo) * self.next_f32()
|
||||
}
|
||||
|
||||
/// Standard-normal `f32` via the Box–Muller transform.
|
||||
pub fn next_gaussian(&mut self) -> f32 {
|
||||
let u1 = self.next_f32().max(1e-7);
|
||||
let u2 = self.next_f32();
|
||||
(-2.0 * u1.ln()).sqrt() * (core::f32::consts::TAU * u2).cos()
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash — a dependency-free, deterministic byte folder used to
|
||||
/// derive per-session keys from `(scene_seed, phase, index)` tuples and to
|
||||
/// build the [`crate::proof`] witness. Not cryptographic.
|
||||
#[must_use]
|
||||
pub fn fnv1a_64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xCBF2_9CE4_8422_2325;
|
||||
for &b in bytes {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01B3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Fold a label and two indices into a stable `u64` key.
|
||||
#[must_use]
|
||||
pub fn derive_key(scene_seed: u64, label: &[u8], a: u64, b: u64) -> u64 {
|
||||
let mut buf = Vec::with_capacity(label.len() + 24);
|
||||
buf.extend_from_slice(&scene_seed.to_le_bytes());
|
||||
buf.extend_from_slice(label);
|
||||
buf.extend_from_slice(&a.to_le_bytes());
|
||||
buf.extend_from_slice(&b.to_le_bytes());
|
||||
fnv1a_64(&buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stream_is_deterministic() {
|
||||
let mut a = Rng::new(42);
|
||||
let mut b = Rng::new(42);
|
||||
for _ in 0..1000 {
|
||||
assert_eq!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_seeds_diverge() {
|
||||
let mut a = Rng::new(1);
|
||||
let mut b = Rng::new(2);
|
||||
assert_ne!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_in_range() {
|
||||
let mut r = Rng::new(7);
|
||||
for _ in 0..10_000 {
|
||||
let x = r.next_f32();
|
||||
assert!((0.0..1.0).contains(&x));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaussian_mean_near_zero() {
|
||||
let mut r = Rng::new(9);
|
||||
let n = 100_000;
|
||||
let mean: f64 = (0..n).map(|_| f64::from(r.next_gaussian())).sum::<f64>() / f64::from(n);
|
||||
assert!(mean.abs() < 0.02, "mean {mean} not near 0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Deterministic proof bundle — the byte-stable witness for VEIL.
|
||||
//!
|
||||
//! Mirrors the `nvsim` / `archive/v1` proof pattern: run a fixed reference
|
||||
//! experiment, fold its salient outputs into a single FNV-1a witness, and pin
|
||||
//! that witness as a constant. If any constant drifts — the PRNG stream, the
|
||||
//! rotation schedule, the throughput formula, the scene geometry — the witness
|
||||
//! changes and the test fails loudly.
|
||||
//!
|
||||
//! The witness is derived from **quantized** outputs (accuracies to 1e-4,
|
||||
//! throughput to 1e-6) so that legitimate cross-platform f32 round-off in the
|
||||
//! last bits does not spuriously break the proof, while any real change to the
|
||||
//! experiment's behavior still does.
|
||||
|
||||
use crate::experiment::{run, ExperimentConfig, ExperimentReport};
|
||||
use crate::prng::fnv1a_64;
|
||||
|
||||
/// Deterministic-proof harness.
|
||||
pub struct Proof;
|
||||
|
||||
impl Proof {
|
||||
/// Pinned witness over the reference experiment. Re-derived by
|
||||
/// [`Proof::witness`]; asserted by the test below.
|
||||
pub const EXPECTED_WITNESS: u64 = 0xD098_C38D_B7C6_BCA9;
|
||||
|
||||
/// The reference configuration. Uses every default so the proof tracks the
|
||||
/// shipped behavior of the crate.
|
||||
#[must_use]
|
||||
pub fn reference_config() -> ExperimentConfig {
|
||||
ExperimentConfig::default()
|
||||
}
|
||||
|
||||
/// Run the reference experiment.
|
||||
#[must_use]
|
||||
pub fn run_reference() -> ExperimentReport {
|
||||
run(&Self::reference_config())
|
||||
}
|
||||
|
||||
/// Fold a report's salient outputs into a stable witness.
|
||||
#[must_use]
|
||||
pub fn witness(report: &ExperimentReport) -> u64 {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&(report.identities as u64).to_le_bytes());
|
||||
// Quantize floats before folding so last-bit round-off is not part of
|
||||
// the witness.
|
||||
let q4 = |x: f32| (f64::from(x) * 10_000.0).round() as i64;
|
||||
let q6 = |x: f64| (x * 1_000_000.0).round() as i64;
|
||||
buf.extend_from_slice(&q4(report.chance_level).to_le_bytes());
|
||||
buf.extend_from_slice(&q4(report.accuracy_shield_off).to_le_bytes());
|
||||
buf.extend_from_slice(&q4(report.accuracy_shield_on).to_le_bytes());
|
||||
buf.extend_from_slice(&q6(report.throughput_ratio).to_le_bytes());
|
||||
buf.extend_from_slice(&q4(report.compliance.energy_ratio).to_le_bytes());
|
||||
fnv1a_64(&buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_experiment_passes() {
|
||||
assert!(Proof::run_reference().passed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_is_stable() {
|
||||
let a = Proof::witness(&Proof::run_reference());
|
||||
let b = Proof::witness(&Proof::run_reference());
|
||||
assert_eq!(a, b, "witness must be reproducible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_matches_pinned() {
|
||||
let w = Proof::witness(&Proof::run_reference());
|
||||
assert_eq!(
|
||||
w,
|
||||
Proof::EXPECTED_WITNESS,
|
||||
"witness drifted to {w:#018x}; update EXPECTED_WITNESS only if the \
|
||||
change to the reference experiment is intentional"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! The VEIL protector: compliant waveform controls that hide identity.
|
||||
//!
|
||||
//! # What it does (and does not do)
|
||||
//!
|
||||
//! The protector shapes the node's **own** beamforming feedback before it goes
|
||||
//! on air. It applies a per-session, key-derived **orthogonal rotation** to the
|
||||
//! fine block of the report, composed from extra Givens rotations — the same
|
||||
//! angle primitive the report already carries. Because the rotation is:
|
||||
//!
|
||||
//! - **orthogonal** → it preserves the report's energy exactly (no added
|
||||
//! transmit power, no out-of-mask emission → **not jamming**, see
|
||||
//! [`crate::compliance`]);
|
||||
//! - **keyed per session** → the legitimate AP/STA, which shares the session
|
||||
//! key, inverts it and recovers the true precoder (throughput preserved,
|
||||
//! see [`crate::throughput`]);
|
||||
//! - **fresh each session** → an external sniffer sees a different rotation of
|
||||
//! the identity signature every session and cannot average them back to the
|
||||
//! signature, so cross-session re-identification collapses toward chance.
|
||||
//!
|
||||
//! This is the shared-secret precoding idea (cf. MIMOCrypt, NSDI-adjacent work)
|
||||
//! specialized to the identity-bearing fine subspace.
|
||||
//!
|
||||
//! # Scope limit (stated honestly)
|
||||
//!
|
||||
//! VEIL defends against a **third-party passive sniffer**. It does *not* hide
|
||||
//! identity from the AP the node is associated with (that party holds the key
|
||||
//! by construction). Protecting against a malicious AP is a different problem
|
||||
//! handled by the BFLD detection layer and privacy-class policy (ADR-118/141),
|
||||
//! not by this shield. VEIL never jams and never touches another station's
|
||||
//! frames.
|
||||
|
||||
use crate::identity::BfiSample;
|
||||
use crate::linalg::apply_givens;
|
||||
use crate::prng::Rng;
|
||||
|
||||
/// Configuration of the protector.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShieldConfig {
|
||||
/// Master switch. When `false`, [`Protector::protect`] is the identity map
|
||||
/// (used to model the "shield off" baseline).
|
||||
pub enabled: bool,
|
||||
/// Number of keyed Givens rotations composed per session. Enough passes
|
||||
/// (≈ `2 × fine_dims`) approximate a Haar-random rotation of the fine
|
||||
/// block, which is what drives the attacker to chance.
|
||||
pub givens_passes: usize,
|
||||
/// Bits used to quantize each reported angle (802.11 uses 5–9). Higher
|
||||
/// resolution ⇒ smaller uncompensated residual at the legitimate receiver
|
||||
/// ⇒ smaller throughput cost. See [`crate::throughput`].
|
||||
pub feedback_bits: u32,
|
||||
/// Fractional airtime overhead from sounding-cadence randomization
|
||||
/// (jittering NDP intervals so an eavesdropper under-samples motion).
|
||||
pub sounding_overhead: f64,
|
||||
}
|
||||
|
||||
impl Default for ShieldConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
givens_passes: 112, // 2 × 56 fine dims at the default scene
|
||||
feedback_bits: 7,
|
||||
sounding_overhead: 0.02,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies compliant waveform controls to outgoing beamforming feedback.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Protector {
|
||||
cfg: ShieldConfig,
|
||||
}
|
||||
|
||||
impl Protector {
|
||||
/// Build a protector.
|
||||
#[must_use]
|
||||
pub fn new(cfg: ShieldConfig) -> Self {
|
||||
Self { cfg }
|
||||
}
|
||||
|
||||
/// The configuration.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> &ShieldConfig {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
/// Build the list of `(i, j, theta)` Givens rotations for a session. The
|
||||
/// legitimate receiver derives the identical list from the shared session
|
||||
/// key and applies the inverse (negated angles, reversed order).
|
||||
fn session_rotation(&self, fine_dims: usize, session_key: u64) -> Vec<(usize, usize, f32)> {
|
||||
let mut rng = Rng::new(session_key);
|
||||
let mut ops = Vec::with_capacity(self.cfg.givens_passes);
|
||||
for _ in 0..self.cfg.givens_passes {
|
||||
// Draw a distinct coordinate pair in the fine block.
|
||||
let i = (rng.next_u64() as usize) % fine_dims;
|
||||
let mut j = (rng.next_u64() as usize) % fine_dims;
|
||||
if j == i {
|
||||
j = (j + 1) % fine_dims;
|
||||
}
|
||||
let theta = rng.next_range(0.0, core::f32::consts::TAU);
|
||||
ops.push((i, j, theta));
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Protect an outgoing report for the given session. When the shield is
|
||||
/// disabled this clones the input unchanged.
|
||||
#[must_use]
|
||||
pub fn protect(&self, sample: &BfiSample, session_key: u64) -> BfiSample {
|
||||
let mut out = sample.clone();
|
||||
if !self.cfg.enabled {
|
||||
return out;
|
||||
}
|
||||
let fine_dims = out.fine().len();
|
||||
let ops = self.session_rotation(fine_dims, session_key);
|
||||
let fine = out.fine_mut();
|
||||
for (i, j, theta) in ops {
|
||||
apply_givens(fine, i, j, theta);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Recover the true report at the legitimate receiver, which shares the
|
||||
/// session key. Applies the inverse rotation. Used to demonstrate that the
|
||||
/// transform is reversible for the authorized party (the basis of the
|
||||
/// throughput claim), not part of the attacker's world.
|
||||
#[must_use]
|
||||
pub fn recover(&self, sample: &BfiSample, session_key: u64) -> BfiSample {
|
||||
let mut out = sample.clone();
|
||||
if !self.cfg.enabled {
|
||||
return out;
|
||||
}
|
||||
let fine_dims = out.fine().len();
|
||||
let ops = self.session_rotation(fine_dims, session_key);
|
||||
let fine = out.fine_mut();
|
||||
for (i, j, theta) in ops.into_iter().rev() {
|
||||
apply_givens(fine, i, j, -theta);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal detector for unsolicited sensing activity. In a deployment this
|
||||
/// watches the rate of NDP/sensing-sounding solicitations; here it exposes the
|
||||
/// decision rule so the control plane (ADR-280) can engage the shield only when
|
||||
/// sensing is actually observed, rather than perturbing continuously.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SensingDetector {
|
||||
/// Solicitations per second above which the shield engages.
|
||||
pub threshold_hz: f32,
|
||||
}
|
||||
|
||||
impl Default for SensingDetector {
|
||||
fn default() -> Self {
|
||||
Self { threshold_hz: 5.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl SensingDetector {
|
||||
/// Should the shield engage given the observed solicitation rate?
|
||||
#[must_use]
|
||||
pub fn should_engage(&self, observed_hz: f32) -> bool {
|
||||
observed_hz >= self.threshold_hz
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::{Channel, SceneConfig};
|
||||
use crate::linalg::{dist_sq, norm};
|
||||
|
||||
#[test]
|
||||
fn protection_preserves_energy() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
let s = ch.observe(0, b"enroll", 1);
|
||||
let p = Protector::new(ShieldConfig::default());
|
||||
let out = p.protect(&s, 12345);
|
||||
assert!((norm(&s.values) - norm(&out.values)).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_leaves_comm_block_untouched() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
let s = ch.observe(0, b"enroll", 1);
|
||||
let p = Protector::new(ShieldConfig::default());
|
||||
let out = p.protect(&s, 999);
|
||||
assert_eq!(s.comm(), out.comm());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_scrambles_fine_block() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
let s = ch.observe(0, b"enroll", 1);
|
||||
let p = Protector::new(ShieldConfig::default());
|
||||
let out = p.protect(&s, 42);
|
||||
assert!(dist_sq(s.fine(), out.fine()).sqrt() > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legitimate_receiver_recovers() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
let s = ch.observe(0, b"enroll", 1);
|
||||
let p = Protector::new(ShieldConfig::default());
|
||||
let out = p.protect(&s, 7);
|
||||
let back = p.recover(&out, 7);
|
||||
assert!(dist_sq(s.fine(), back.fine()).sqrt() < 1e-2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_shield_is_identity() {
|
||||
let ch = Channel::new(SceneConfig::default());
|
||||
let s = ch.observe(0, b"enroll", 1);
|
||||
let cfg = ShieldConfig {
|
||||
enabled: false,
|
||||
..ShieldConfig::default()
|
||||
};
|
||||
let p = Protector::new(cfg);
|
||||
assert_eq!(s, p.protect(&s, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detector_engages_above_threshold() {
|
||||
let d = SensingDetector::default();
|
||||
assert!(d.should_engage(10.0));
|
||||
assert!(!d.should_engage(1.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Link-throughput model for the protected node.
|
||||
//!
|
||||
//! The claim under test is "throughput stays above 95% with the shield on".
|
||||
//! The model is intentionally transparent and errs toward *charging* the
|
||||
//! shield, not flattering it:
|
||||
//!
|
||||
//! - **Beamforming residual.** The legitimate receiver shares the session key
|
||||
//! and inverts the protector's rotation, so it does not pay the rotation
|
||||
//! itself — only the residual from quantizing the extra angles at
|
||||
//! `feedback_bits` resolution. Per-angle mean-square quantization error is
|
||||
//! `Δ²/12` for step `Δ = (π/2)/2^bits`; this fraction of beamforming gain is
|
||||
//! lost. At 7 bits it is ~1e-5 — negligible, which matches the DySPAN-2026
|
||||
//! finding that fine feedback resolution makes the privacy–utility tradeoff
|
||||
//! nearly free.
|
||||
//! - **Sounding overhead.** Randomizing the NDP sounding cadence costs airtime
|
||||
//! directly; charged as a flat `sounding_overhead` fraction of throughput.
|
||||
//!
|
||||
//! Throughput ratio = `(1 − overhead) · C(SNR·(1−ρ)) / C(SNR)` where
|
||||
//! `C(x) = log2(1 + x)` is the Shannon capacity of the data-carrying beam. The
|
||||
//! comm block is never perturbed, so its geometry is intact; only the SNR is
|
||||
//! nudged by the residual `ρ`.
|
||||
|
||||
use crate::protector::ShieldConfig;
|
||||
|
||||
/// A single-stream link model.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinkModel {
|
||||
/// Operating SNR of the data-carrying beam, in dB.
|
||||
pub snr_db: f64,
|
||||
}
|
||||
|
||||
impl Default for LinkModel {
|
||||
fn default() -> Self {
|
||||
Self { snr_db: 20.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkModel {
|
||||
/// Linear SNR.
|
||||
#[must_use]
|
||||
pub fn snr_linear(&self) -> f64 {
|
||||
10f64.powf(self.snr_db / 10.0)
|
||||
}
|
||||
|
||||
/// Baseline Shannon capacity (bits/s/Hz) with no shield.
|
||||
#[must_use]
|
||||
pub fn baseline_capacity(&self) -> f64 {
|
||||
(1.0 + self.snr_linear()).log2()
|
||||
}
|
||||
|
||||
/// Uncompensated beamforming-gain residual from finite feedback resolution.
|
||||
#[must_use]
|
||||
pub fn beamforming_residual(shield: &ShieldConfig) -> f64 {
|
||||
if !shield.enabled {
|
||||
return 0.0;
|
||||
}
|
||||
let step = (core::f64::consts::FRAC_PI_2) / f64::from(1u32 << shield.feedback_bits);
|
||||
// Mean-square quantization error of a uniform quantizer, as a fraction
|
||||
// of unit gain. Clamp for safety at absurdly low resolutions.
|
||||
(step * step / 12.0).min(0.5)
|
||||
}
|
||||
|
||||
/// Throughput ratio of the protected link versus the unshielded baseline,
|
||||
/// in `[0, 1]`.
|
||||
#[must_use]
|
||||
pub fn throughput_ratio(&self, shield: &ShieldConfig) -> f64 {
|
||||
if !shield.enabled {
|
||||
return 1.0;
|
||||
}
|
||||
let rho = Self::beamforming_residual(shield);
|
||||
let snr = self.snr_linear();
|
||||
let protected = (1.0 + snr * (1.0 - rho)).log2();
|
||||
let ratio = protected / self.baseline_capacity();
|
||||
((1.0 - shield.sounding_overhead) * ratio).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn baseline_ratio_is_one() {
|
||||
let cfg = ShieldConfig {
|
||||
enabled: false,
|
||||
..ShieldConfig::default()
|
||||
};
|
||||
assert!((LinkModel::default().throughput_ratio(&cfg) - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fine_resolution_is_nearly_free() {
|
||||
let ratio = LinkModel::default().throughput_ratio(&ShieldConfig::default());
|
||||
assert!(ratio > 0.95, "ratio {ratio}");
|
||||
// Almost all of the (small) loss is the sounding overhead, not the
|
||||
// perturbation — consistent with the DySPAN-2026 fine-resolution result.
|
||||
assert!(ratio < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coarse_resolution_costs_more() {
|
||||
// Lowering feedback resolution raises the residual and lowers throughput
|
||||
// — the tradeoff is real, just cheap at fine resolution.
|
||||
let fine = ShieldConfig {
|
||||
feedback_bits: 9,
|
||||
..ShieldConfig::default()
|
||||
};
|
||||
let coarse = ShieldConfig {
|
||||
feedback_bits: 2,
|
||||
..ShieldConfig::default()
|
||||
};
|
||||
let link = LinkModel::default();
|
||||
assert!(link.throughput_ratio(&fine) > link.throughput_ratio(&coarse));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user