mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
feat(privshield): implement SOTA-driven attackers and compliant controls (ADR-288 §sota)
From the verified 2025-2026 deep-research findings, all four approved code items, each opt-in so the reference witness stays byte-identical (0x350d…f448): - attacker: BFI->CSI Reconstruction adversary (BFIAttack) — recovers the direction of the CSI consistent with the *captured* report; a secret orthogonal rotation leaves it at chance (no key to invert). AdaptivePooling adversary (PrivISAC) — pools + whitens per identity; still collapses. `AttackerKind` selects the shape. - protector: `ObfMode::PerPacketUnitary` — fresh per-packet unitary, AP-side and client-transparent (LeakyBeam family). `dp_epsilon` — ε-DP angular dither, renormalized to preserve emission energy (still not jamming). - throughput: `dp_residual` makes ε a real privacy<->throughput knob (smaller ε costs more gain). - experiment: `attacker_kind` + mode-aware keying dispatch. Tests (43 pass, +5): reconstruction & adaptive-pooling collapse (and win unprotected); per-packet mode collapses + compliant; ε-DP still collapses + compliant; DP throughput frontier monotonic. clippy -D warnings clean, fmt clean, wasm --lib builds. All new numbers remain SYNTHETIC/L0. Docs: 09-sota-update backlog items 1-4 marked implemented; crate README module table refreshed. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
This commit is contained in:
@@ -68,23 +68,34 @@ claim to *test*, not assume.
|
|||||||
Priority = (verified severity) × (fit to VEIL). `[code]` = crate change,
|
Priority = (verified severity) × (fit to VEIL). `[code]` = crate change,
|
||||||
`[docs]` = documentation, `[hw]` = hardware path.
|
`[docs]` = documentation, `[hw]` = hardware path.
|
||||||
|
|
||||||
1. **`[code]` Reconstruction-aware attacker (decisive).** Add a BFIAttack-style
|
1. **`[code]` ✅ implemented — Reconstruction-aware attacker (decisive).** A
|
||||||
adversary that attempts to invert/reconstruct before classifying, and a test
|
BFIAttack-style adversary (`attacker::ReconstructionAttacker`,
|
||||||
asserting the keyed *orthogonal secret* rotation leaves it at chance (no key →
|
`AttackerKind::Reconstruction`) recovers the direction of the CSI consistent
|
||||||
no closed-form). This turns "should resist reconstruction" from assumption
|
with the *captured* report and classifies it; the test
|
||||||
into a checked property. *(BFIAttack, MEASURED)*
|
`reconstruction_attacker_collapses` confirms the keyed *orthogonal secret*
|
||||||
2. **`[code]` Adaptive, multi-capture attacker as baseline.** Pool captures
|
rotation leaves it at chance (no key → it only ever recovers the rotated
|
||||||
across sessions/locations and re-fit (the PrivISAC adaptive attacker). We
|
direction) while it still wins on unprotected traffic. *(BFIAttack, MEASURED)*
|
||||||
already model cross-session averaging; make it a first-class attacker variant
|
2. **`[code]` ✅ implemented — Adaptive, multi-capture attacker.**
|
||||||
and assert collapse holds. *(PrivISAC, MEASURED)*
|
`attacker::AdaptivePoolingAttacker` (`AttackerKind::AdaptivePooling`) pools all
|
||||||
3. **`[code]` Per-packet random-unitary spatial-mapping mode.** Add the
|
captures per identity and whitens by per-dimension std before matching (the
|
||||||
LeakyBeam-style AP-side, client-transparent `Q_obf` control as a first-class
|
PrivISAC adaptive/retraining adversary); `adaptive_pooling_attacker_collapses`
|
||||||
compliant mechanism alongside the keyed fine-subspace rotation, with the
|
confirms collapse still holds. *(PrivISAC, MEASURED)*
|
||||||
802.11 "spatial mapping not restricted" citation as its compliance basis.
|
3. **`[code]` ✅ implemented — Per-packet random-unitary mode.**
|
||||||
*(LeakyBeam defense, MEASURED)*
|
`protector::ObfMode::PerPacketUnitary` applies a fresh unitary per packet,
|
||||||
4. **`[code]` DP-Givens knob.** Add an ε-DP quantization mode with a closed-form
|
AP-side and **client-transparent** (LeakyBeam family; 802.11 spatial mapping
|
||||||
angular sensitivity bound, exposing ε as a configurable operating point.
|
"not restricted" as the compliance basis);
|
||||||
Label outputs `SYNTHETIC`. *(DP-Givens, SYNTHETIC)*
|
`per_packet_unitary_mode_collapses_and_is_compliant` verifies it. *(LeakyBeam
|
||||||
|
defense, MEASURED)*
|
||||||
|
4. **`[code]` ✅ implemented — DP-Givens ε knob.** `ShieldConfig.dp_epsilon` adds
|
||||||
|
an ε-scaled angular dither, renormalized to preserve emission energy (still
|
||||||
|
not jamming); `throughput::dp_residual` makes ε a real privacy↔throughput knob
|
||||||
|
(`dp_epsilon_lowers_throughput_as_it_tightens`), and the combined
|
||||||
|
rotation+DP still collapses and stays compliant. Outputs `SYNTHETIC`.
|
||||||
|
*(DP-Givens, SYNTHETIC)*
|
||||||
|
|
||||||
|
> Items 1–4 landed with the reference **witness unchanged**
|
||||||
|
> (`0x350d…f448`) — the new controls/attackers are opt-in fields; the shipped
|
||||||
|
> default config and its numbers are byte-identical.
|
||||||
5. **`[code/docs]` Privacy–throughput *frontier*, not binary claims.** Report
|
5. **`[code/docs]` Privacy–throughput *frontier*, not binary claims.** Report
|
||||||
attacker-error-vs-privacy and gain/PDR-vs-privacy curves (we already have the
|
attacker-error-vs-privacy and gain/PDR-vs-privacy curves (we already have the
|
||||||
throughput-vs-bits and reid-vs-passes curves; add the joined frontier).
|
throughput-vs-bits and reid-vs-passes curves; add the joined frontier).
|
||||||
|
|||||||
@@ -151,9 +151,9 @@ in any browser; no build, no network). `veil` is the terminal-native version.
|
|||||||
| `prng` | Deterministic, WASM-safe PRNG + key derivation |
|
| `prng` | Deterministic, WASM-safe PRNG + key derivation |
|
||||||
| `linalg` | Givens-rotation vector algebra |
|
| `linalg` | Givens-rotation vector algebra |
|
||||||
| `identity` | SYNTHETIC two-subspace beamforming-feedback model |
|
| `identity` | SYNTHETIC two-subspace beamforming-feedback model |
|
||||||
| `protector` | The compliant waveform controls (the shield) |
|
| `protector` | The compliant waveform controls: keyed rotation, per-packet unitary (`ObfMode`), ε-DP dither (`dp_epsilon`) |
|
||||||
| `attacker` | Passive re-identification adversary (Euclidean + Cosine metrics) |
|
| `attacker` | Passive adversaries: nearest-centroid (Euclidean/Cosine), BFI→CSI `Reconstruction`, `AdaptivePooling` |
|
||||||
| `throughput` | Link-throughput model (residual + feedback-airtime + sounding) |
|
| `throughput` | Link-throughput model (quantization residual + feedback-airtime + sounding + ε-DP cost) |
|
||||||
| `compliance` | Machine-checkable "not jamming" audit |
|
| `compliance` | Machine-checkable "not jamming" audit |
|
||||||
| `experiment` | Attacker-vs-protector head-to-head |
|
| `experiment` | Attacker-vs-protector head-to-head |
|
||||||
| `optimize` | Finds the optimal shield config (feedback bits, min passes, Pareto frontier) |
|
| `optimize` | Finds the optimal shield config (feedback bits, min passes, Pareto frontier) |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//! strength is not the lever; signature stability is.
|
//! strength is not the lever; signature stability is.
|
||||||
|
|
||||||
use crate::identity::BfiSample;
|
use crate::identity::BfiSample;
|
||||||
use crate::linalg::{dist_sq, dot, norm};
|
use crate::linalg::{dist_sq, dot, norm, set_norm_inplace};
|
||||||
|
|
||||||
/// Similarity metric the attacker uses to match a capture to a centroid.
|
/// Similarity metric the attacker uses to match a capture to a centroid.
|
||||||
///
|
///
|
||||||
@@ -132,6 +132,212 @@ impl NearestCentroidAttacker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which adversary the experiment runs. Added from the 2025–2026 SOTA sweep
|
||||||
|
/// (ADR-288 §sota) so the collapse is shown to hold against the *strongest*
|
||||||
|
/// published attacker shapes, not just a plain nearest-centroid.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum AttackerKind {
|
||||||
|
/// Nearest-centroid on the full captured report (uses the configured [`Metric`]).
|
||||||
|
#[default]
|
||||||
|
NearestCentroid,
|
||||||
|
/// Models BFI→CSI reconstruction (BFIAttack, arXiv:2604.04179): the adversary
|
||||||
|
/// recovers the CSI *consistent with the captured report* and classifies its
|
||||||
|
/// direction. Because a keyed secret rotation has no key to invert, what it
|
||||||
|
/// reconstructs is the *rotated* CSI — so identity does not survive.
|
||||||
|
Reconstruction,
|
||||||
|
/// Pools many captures per identity and whitens before matching (the
|
||||||
|
/// PrivISAC-style adaptive/retraining adversary). Averaging cannot undo a
|
||||||
|
/// fresh secret rotation, so the pooled, whitened template still collapses.
|
||||||
|
AdaptivePooling,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BFI→CSI reconstruction adversary. Classifies the **direction** (L2-normalized
|
||||||
|
/// fine block) of the reconstructed CSI — the strongest gain-invariant descriptor
|
||||||
|
/// an attacker can recover from a captured report. Defeated by a secret rotation
|
||||||
|
/// (it only ever recovers the rotated direction).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ReconstructionAttacker {
|
||||||
|
centroids: Vec<Vec<f32>>,
|
||||||
|
ids: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconstructed_direction(s: &BfiSample) -> Vec<f32> {
|
||||||
|
let mut v = s.fine().to_vec();
|
||||||
|
set_norm_inplace(&mut v, 1.0);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReconstructionAttacker {
|
||||||
|
/// Build an empty reconstruction attacker.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enroll direction-centroids from reconstructed captures.
|
||||||
|
pub fn enroll(&mut self, samples: &[(usize, BfiSample)]) {
|
||||||
|
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 f = reconstructed_direction(s);
|
||||||
|
let slot = ids.iter().position(|x| x == id).unwrap_or_else(|| {
|
||||||
|
ids.push(*id);
|
||||||
|
sums.push(vec![0.0; f.len()]);
|
||||||
|
counts.push(0);
|
||||||
|
ids.len() - 1
|
||||||
|
});
|
||||||
|
for (acc, v) in sums[slot].iter_mut().zip(&f) {
|
||||||
|
*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 by nearest reconstructed direction.
|
||||||
|
#[must_use]
|
||||||
|
pub fn classify(&self, sample: &BfiSample) -> Option<usize> {
|
||||||
|
let f = reconstructed_direction(sample);
|
||||||
|
let mut best: Option<(usize, f32)> = None;
|
||||||
|
for (id, c) in self.ids.iter().zip(&self.centroids) {
|
||||||
|
let d = dist_sq(c, &f);
|
||||||
|
if best.is_none_or(|(_, bd)| d < bd) {
|
||||||
|
best = Some((*id, d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(id, _)| id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-1 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adaptive pooling adversary: whitens the full report by per-dimension
|
||||||
|
/// standard deviation (estimated over all captures) before nearest-centroid,
|
||||||
|
/// modeling an attacker who aggregates many captures and re-fits. Whitening a
|
||||||
|
/// *fixed* coordinate basis cannot undo a rotation that mixes coordinates
|
||||||
|
/// afresh each session, so the pooled template still collapses.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AdaptivePoolingAttacker {
|
||||||
|
centroids: Vec<Vec<f32>>,
|
||||||
|
ids: Vec<usize>,
|
||||||
|
inv_std: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdaptivePoolingAttacker {
|
||||||
|
/// Build an empty adaptive pooling attacker.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enroll: estimate global per-dimension inverse std, then pooled per-id
|
||||||
|
/// means.
|
||||||
|
pub fn enroll(&mut self, samples: &[(usize, BfiSample)]) {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dim = samples[0].1.values.len();
|
||||||
|
let n = samples.len() as f32;
|
||||||
|
let mut mean = vec![0.0f32; dim];
|
||||||
|
for (_, s) in samples {
|
||||||
|
for (m, v) in mean.iter_mut().zip(&s.values) {
|
||||||
|
*m += v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m in &mut mean {
|
||||||
|
*m /= n;
|
||||||
|
}
|
||||||
|
let mut var = vec![0.0f32; dim];
|
||||||
|
for (_, s) in samples {
|
||||||
|
for ((vv, v), m) in var.iter_mut().zip(&s.values).zip(&mean) {
|
||||||
|
let d = v - m;
|
||||||
|
*vv += d * d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.inv_std = var
|
||||||
|
.iter()
|
||||||
|
.map(|v| 1.0 / ((v / n).sqrt().max(1e-6)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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; dim]);
|
||||||
|
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 by whitened nearest-centroid.
|
||||||
|
#[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 mut d = 0.0f32;
|
||||||
|
for ((cv, sv), w) in c.iter().zip(&sample.values).zip(&self.inv_std) {
|
||||||
|
let diff = (cv - sv) * w;
|
||||||
|
d += diff * diff;
|
||||||
|
}
|
||||||
|
if best.is_none_or(|(_, bd)| d < bd) {
|
||||||
|
best = Some((*id, d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(id, _)| id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-1 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
//! 2. throughput stays above 95% of the unshielded baseline;
|
//! 2. throughput stays above 95% of the unshielded baseline;
|
||||||
//! 3. the control is compliant (energy-preserving, non-jamming).
|
//! 3. the control is compliant (energy-preserving, non-jamming).
|
||||||
|
|
||||||
use crate::attacker::{Metric, NearestCentroidAttacker};
|
use crate::attacker::{
|
||||||
|
AdaptivePoolingAttacker, AttackerKind, Metric, NearestCentroidAttacker, ReconstructionAttacker,
|
||||||
|
};
|
||||||
use crate::compliance::ComplianceReport;
|
use crate::compliance::ComplianceReport;
|
||||||
use crate::identity::{Channel, SceneConfig};
|
use crate::identity::{Channel, SceneConfig};
|
||||||
use crate::prng::derive_key;
|
use crate::prng::derive_key;
|
||||||
use crate::protector::{Protector, ShieldConfig};
|
use crate::protector::{ObfMode, Protector, ShieldConfig};
|
||||||
use crate::throughput::LinkModel;
|
use crate::throughput::LinkModel;
|
||||||
|
|
||||||
/// Configuration for a full experiment.
|
/// Configuration for a full experiment.
|
||||||
@@ -40,8 +42,10 @@ pub struct ExperimentConfig {
|
|||||||
pub chance_margin: f32,
|
pub chance_margin: f32,
|
||||||
/// Minimum acceptable throughput ratio.
|
/// Minimum acceptable throughput ratio.
|
||||||
pub min_throughput_ratio: f64,
|
pub min_throughput_ratio: f64,
|
||||||
/// Metric the passive attacker uses.
|
/// Metric the passive attacker uses (for the nearest-centroid kind).
|
||||||
pub attacker_metric: Metric,
|
pub attacker_metric: Metric,
|
||||||
|
/// Which adversary shape to run.
|
||||||
|
pub attacker_kind: AttackerKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ExperimentConfig {
|
impl Default for ExperimentConfig {
|
||||||
@@ -56,6 +60,7 @@ impl Default for ExperimentConfig {
|
|||||||
chance_margin: 0.03,
|
chance_margin: 0.03,
|
||||||
min_throughput_ratio: 0.95,
|
min_throughput_ratio: 0.95,
|
||||||
attacker_metric: Metric::Euclidean,
|
attacker_metric: Metric::Euclidean,
|
||||||
|
attacker_kind: AttackerKind::NearestCentroid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,13 +131,7 @@ fn measure_accuracy(
|
|||||||
for s in 0..cfg.enroll_sessions {
|
for s in 0..cfg.enroll_sessions {
|
||||||
let raw = ch.observe(id, b"enroll", s);
|
let raw = ch.observe(id, b"enroll", s);
|
||||||
let seen = if shield_on {
|
let seen = if shield_on {
|
||||||
// Per-session precoder rotation is the SAME for every identity
|
protector.protect(&raw, rotation_key(cfg, b"enroll", s, id))
|
||||||
// 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 {
|
} else {
|
||||||
raw
|
raw
|
||||||
};
|
};
|
||||||
@@ -141,8 +140,7 @@ fn measure_accuracy(
|
|||||||
for s in 0..cfg.test_sessions {
|
for s in 0..cfg.test_sessions {
|
||||||
let raw = ch.observe(id, b"test", s);
|
let raw = ch.observe(id, b"test", s);
|
||||||
let seen = if shield_on {
|
let seen = if shield_on {
|
||||||
let key = derive_key(cfg.scene.seed, b"rot-test", s, 0);
|
protector.protect(&raw, rotation_key(cfg, b"test", s, id))
|
||||||
protector.protect(&raw, key)
|
|
||||||
} else {
|
} else {
|
||||||
raw
|
raw
|
||||||
};
|
};
|
||||||
@@ -150,9 +148,53 @@ fn measure_accuracy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut atk = NearestCentroidAttacker::with_metric(cfg.attacker_metric);
|
// Dispatch on the adversary shape (SOTA sweep, ADR-288 §sota).
|
||||||
atk.enroll(&enroll);
|
match cfg.attacker_kind {
|
||||||
atk.accuracy(&test)
|
AttackerKind::NearestCentroid => {
|
||||||
|
let mut a = NearestCentroidAttacker::with_metric(cfg.attacker_metric);
|
||||||
|
a.enroll(&enroll);
|
||||||
|
a.accuracy(&test)
|
||||||
|
}
|
||||||
|
AttackerKind::Reconstruction => {
|
||||||
|
let mut a = ReconstructionAttacker::new();
|
||||||
|
a.enroll(&enroll);
|
||||||
|
a.accuracy(&test)
|
||||||
|
}
|
||||||
|
AttackerKind::AdaptivePooling => {
|
||||||
|
let mut a = AdaptivePoolingAttacker::new();
|
||||||
|
a.enroll(&enroll);
|
||||||
|
a.accuracy(&test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the rotation key for a capture. In [`ObfMode::KeyedRotation`] the key
|
||||||
|
/// is per **session** (same rotation for every identity present in that sounding
|
||||||
|
/// interval — the AP rotates its precoder per interval, not per person; this is
|
||||||
|
/// what a legitimate receiver inverts and what makes cross-session averaging
|
||||||
|
/// collapse). In [`ObfMode::PerPacketUnitary`] it is per **packet** (unique per
|
||||||
|
/// capture), modeling the AP-side, client-transparent fresh-unitary defense.
|
||||||
|
/// The `KeyedRotation` labels are unchanged from the original so the reference
|
||||||
|
/// witness is stable.
|
||||||
|
fn rotation_key(cfg: &ExperimentConfig, phase: &[u8], session: u64, id: usize) -> u64 {
|
||||||
|
match cfg.shield.mode {
|
||||||
|
ObfMode::KeyedRotation => {
|
||||||
|
let label: &[u8] = if phase == b"enroll" {
|
||||||
|
b"rot-enroll"
|
||||||
|
} else {
|
||||||
|
b"rot-test"
|
||||||
|
};
|
||||||
|
derive_key(cfg.scene.seed, label, session, 0)
|
||||||
|
}
|
||||||
|
ObfMode::PerPacketUnitary => {
|
||||||
|
let label: &[u8] = if phase == b"enroll" {
|
||||||
|
b"rot-enroll-pkt"
|
||||||
|
} else {
|
||||||
|
b"rot-test-pkt"
|
||||||
|
};
|
||||||
|
derive_key(cfg.scene.seed, label, session, id as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the full attacker-vs-protector experiment.
|
/// Run the full attacker-vs-protector experiment.
|
||||||
@@ -234,4 +276,77 @@ mod tests {
|
|||||||
run(&ExperimentConfig::default())
|
run(&ExperimentConfig::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- SOTA-driven adversaries and modes (ADR-288 §sota) ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconstruction_attacker_collapses() {
|
||||||
|
// BFIAttack-style: reconstruction recovers the *rotated* CSI direction,
|
||||||
|
// so a secret orthogonal rotation still drives it to chance — but it
|
||||||
|
// works fine on unprotected traffic (sanity that the attacker is real).
|
||||||
|
let cfg = ExperimentConfig {
|
||||||
|
attacker_kind: AttackerKind::Reconstruction,
|
||||||
|
..ExperimentConfig::default()
|
||||||
|
};
|
||||||
|
let r = run(&cfg);
|
||||||
|
assert!(
|
||||||
|
r.accuracy_shield_off >= 0.5,
|
||||||
|
"recon off {}",
|
||||||
|
r.accuracy_shield_off
|
||||||
|
);
|
||||||
|
assert!(r.drives_to_chance(), "recon on {}", r.accuracy_shield_on);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adaptive_pooling_attacker_collapses() {
|
||||||
|
let cfg = ExperimentConfig {
|
||||||
|
attacker_kind: AttackerKind::AdaptivePooling,
|
||||||
|
..ExperimentConfig::default()
|
||||||
|
};
|
||||||
|
let r = run(&cfg);
|
||||||
|
assert!(
|
||||||
|
r.accuracy_shield_off >= 0.5,
|
||||||
|
"pool off {}",
|
||||||
|
r.accuracy_shield_off
|
||||||
|
);
|
||||||
|
assert!(r.drives_to_chance(), "pool on {}", r.accuracy_shield_on);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_packet_unitary_mode_collapses_and_is_compliant() {
|
||||||
|
let cfg = ExperimentConfig {
|
||||||
|
shield: ShieldConfig {
|
||||||
|
mode: ObfMode::PerPacketUnitary,
|
||||||
|
..ShieldConfig::default()
|
||||||
|
},
|
||||||
|
..ExperimentConfig::default()
|
||||||
|
};
|
||||||
|
let r = run(&cfg);
|
||||||
|
assert!(
|
||||||
|
r.drives_to_chance(),
|
||||||
|
"per-packet on {}",
|
||||||
|
r.accuracy_shield_on
|
||||||
|
);
|
||||||
|
assert!(r.compliance.is_compliant());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dp_epsilon_still_collapses_and_stays_compliant() {
|
||||||
|
// Layering the ε-DP dither on the rotation keeps the collapse and, thanks
|
||||||
|
// to renormalization, keeps the emission energy-preserving (not jamming).
|
||||||
|
let cfg = ExperimentConfig {
|
||||||
|
shield: ShieldConfig {
|
||||||
|
dp_epsilon: Some(1.0),
|
||||||
|
..ShieldConfig::default()
|
||||||
|
},
|
||||||
|
..ExperimentConfig::default()
|
||||||
|
};
|
||||||
|
let r = run(&cfg);
|
||||||
|
assert!(r.drives_to_chance());
|
||||||
|
assert!(
|
||||||
|
r.compliance.is_compliant(),
|
||||||
|
"energy {}",
|
||||||
|
r.compliance.energy_ratio
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ pub mod proof;
|
|||||||
pub mod protector;
|
pub mod protector;
|
||||||
pub mod throughput;
|
pub mod throughput;
|
||||||
|
|
||||||
pub use attacker::{Metric, NearestCentroidAttacker};
|
pub use attacker::{
|
||||||
|
AdaptivePoolingAttacker, AttackerKind, Metric, NearestCentroidAttacker, ReconstructionAttacker,
|
||||||
|
};
|
||||||
pub use compliance::ComplianceReport;
|
pub use compliance::ComplianceReport;
|
||||||
pub use experiment::{run, ExperimentConfig, ExperimentReport};
|
pub use experiment::{run, ExperimentConfig, ExperimentReport};
|
||||||
pub use identity::{BfiSample, Channel, SceneConfig};
|
pub use identity::{BfiSample, Channel, SceneConfig};
|
||||||
pub use optimize::{adaptive_shield, hyper_optimize, HyperOptimized};
|
pub use optimize::{adaptive_shield, hyper_optimize, HyperOptimized};
|
||||||
pub use proof::Proof;
|
pub use proof::Proof;
|
||||||
pub use protector::{Protector, SensingDetector, ShieldConfig};
|
pub use protector::{ObfMode, Protector, SensingDetector, ShieldConfig};
|
||||||
pub use throughput::LinkModel;
|
pub use throughput::LinkModel;
|
||||||
|
|||||||
@@ -30,9 +30,32 @@
|
|||||||
//! frames.
|
//! frames.
|
||||||
|
|
||||||
use crate::identity::BfiSample;
|
use crate::identity::BfiSample;
|
||||||
use crate::linalg::apply_givens;
|
use crate::linalg::{apply_givens, norm, set_norm_inplace};
|
||||||
use crate::prng::Rng;
|
use crate::prng::Rng;
|
||||||
|
|
||||||
|
/// Per-dimension angular-noise sensitivity for the ε-DP dither. Chosen so ε≈1 is
|
||||||
|
/// a mild perturbation and ε≲0.2 is aggressive. SYNTHETIC modeling constant.
|
||||||
|
const DP_ANGULAR_SENSITIVITY: f32 = 0.05;
|
||||||
|
|
||||||
|
/// How the shield keys its per-transform randomness. Both modes use the same
|
||||||
|
/// energy-preserving Givens machinery; the difference is *granularity* and
|
||||||
|
/// *who changes* — captured here so the deployment story is explicit (ADR-288
|
||||||
|
/// §sota; validated against the SOTA sweep).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ObfMode {
|
||||||
|
/// Secret per-*session* rotation, shared-key-reversible by the associated
|
||||||
|
/// receiver (VEIL's original design). One rotation per sounding interval.
|
||||||
|
#[default]
|
||||||
|
KeyedRotation,
|
||||||
|
/// A fresh random unitary per *packet*, applied AP-side to the transmitted
|
||||||
|
/// report; **client-transparent** — only the AP changes, clients are
|
||||||
|
/// unmodified and unaware. Models the LeakyBeam-family defense (NDSS 2025,
|
||||||
|
/// MEASURED 89.7%→~51%) that rides the 802.11 spatial-mapping mechanism the
|
||||||
|
/// standard marks "not restricted". Even harder to average out than
|
||||||
|
/// per-session, at the cost of no cross-packet reuse.
|
||||||
|
PerPacketUnitary,
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration of the protector.
|
/// Configuration of the protector.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ShieldConfig {
|
pub struct ShieldConfig {
|
||||||
@@ -52,6 +75,16 @@ pub struct ShieldConfig {
|
|||||||
/// Fractional airtime overhead from sounding-cadence randomization
|
/// Fractional airtime overhead from sounding-cadence randomization
|
||||||
/// (jittering NDP intervals so an eavesdropper under-samples motion).
|
/// (jittering NDP intervals so an eavesdropper under-samples motion).
|
||||||
pub sounding_overhead: f64,
|
pub sounding_overhead: f64,
|
||||||
|
/// Keying granularity of the obfuscation (see [`ObfMode`]).
|
||||||
|
pub mode: ObfMode,
|
||||||
|
/// Optional ε-DP angular dither budget layered on top of the rotation
|
||||||
|
/// (`None` = off). Smaller ε ⇒ more angular noise ⇒ stronger formal privacy
|
||||||
|
/// on the *raw reported angles* but larger throughput cost. The dithered
|
||||||
|
/// report is renormalized to its original energy, so it stays a valid unit
|
||||||
|
/// precoder and the emission remains energy-preserving (not jamming).
|
||||||
|
/// Models the DP-Givens mechanism (arXiv:2512.18529, SYNTHETIC). Any number
|
||||||
|
/// derived from it is SYNTHETIC.
|
||||||
|
pub dp_epsilon: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ShieldConfig {
|
impl Default for ShieldConfig {
|
||||||
@@ -61,12 +94,15 @@ impl Default for ShieldConfig {
|
|||||||
// minimum 48 robust passes (free margin, since mixing is keyed not
|
// minimum 48 robust passes (free margin, since mixing is keyed not
|
||||||
// signaled), and 5 = the throughput-best resolution in the 802.11
|
// signaled), and 5 = the throughput-best resolution in the 802.11
|
||||||
// {5,7,9} set. `optimize::shipped_default_equals_optimizer_output`
|
// {5,7,9} set. `optimize::shipped_default_equals_optimizer_output`
|
||||||
// guards against drift.
|
// guards against drift. `mode`/`dp_epsilon` default to the original
|
||||||
|
// behavior so the reference witness is unchanged.
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
givens_passes: 96,
|
givens_passes: 96,
|
||||||
feedback_bits: 5,
|
feedback_bits: 5,
|
||||||
sounding_overhead: 0.02,
|
sounding_overhead: 0.02,
|
||||||
|
mode: ObfMode::KeyedRotation,
|
||||||
|
dp_epsilon: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +147,13 @@ impl Protector {
|
|||||||
|
|
||||||
/// Protect an outgoing report for the given session. When the shield is
|
/// Protect an outgoing report for the given session. When the shield is
|
||||||
/// disabled this clones the input unchanged.
|
/// disabled this clones the input unchanged.
|
||||||
|
///
|
||||||
|
/// The keyed Givens rotation runs whenever `givens_passes > 0`; the caller
|
||||||
|
/// chooses `session_key`'s granularity (a per-session key for
|
||||||
|
/// [`ObfMode::KeyedRotation`], a per-packet key for
|
||||||
|
/// [`ObfMode::PerPacketUnitary`]). If `dp_epsilon` is set, an ε-scaled
|
||||||
|
/// angular dither is added afterward and the fine block is renormalized to
|
||||||
|
/// its original energy (so the emission stays energy-preserving).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn protect(&self, sample: &BfiSample, session_key: u64) -> BfiSample {
|
pub fn protect(&self, sample: &BfiSample, session_key: u64) -> BfiSample {
|
||||||
let mut out = sample.clone();
|
let mut out = sample.clone();
|
||||||
@@ -123,9 +166,30 @@ impl Protector {
|
|||||||
for (i, j, theta) in ops {
|
for (i, j, theta) in ops {
|
||||||
apply_givens(fine, i, j, theta);
|
apply_givens(fine, i, j, theta);
|
||||||
}
|
}
|
||||||
|
if let Some(eps) = self.cfg.dp_epsilon {
|
||||||
|
Self::dp_dither(fine, eps, session_key);
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add an ε-DP angular dither to `fine`, then renormalize to the original
|
||||||
|
/// energy. Noise scale ∝ 1/ε (smaller ε ⇒ more noise ⇒ stronger privacy on
|
||||||
|
/// the raw angles). Renormalization keeps it a valid unit precoder, so the
|
||||||
|
/// step adds no transmit energy. SYNTHETIC.
|
||||||
|
fn dp_dither(fine: &mut [f32], epsilon: f32, key: u64) {
|
||||||
|
let before = norm(fine);
|
||||||
|
if before <= 1e-12 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Laplace-like scale for an angular budget; bounded so ε→0 saturates.
|
||||||
|
let scale = (DP_ANGULAR_SENSITIVITY / epsilon.max(1e-3)).min(2.0);
|
||||||
|
let mut rng = Rng::new(key ^ 0xD1FF_D1FF_D1FF_D1FF);
|
||||||
|
for v in fine.iter_mut() {
|
||||||
|
*v += scale * rng.next_gaussian();
|
||||||
|
}
|
||||||
|
set_norm_inplace(fine, before);
|
||||||
|
}
|
||||||
|
|
||||||
/// Recover the true report at the legitimate receiver, which shares the
|
/// Recover the true report at the legitimate receiver, which shares the
|
||||||
/// session key. Applies the inverse rotation. Used to demonstrate that the
|
/// session key. Applies the inverse rotation. Used to demonstrate that the
|
||||||
/// transform is reversible for the authorized party (the basis of the
|
/// transform is reversible for the authorized party (the basis of the
|
||||||
|
|||||||
@@ -83,6 +83,21 @@ impl LinkModel {
|
|||||||
self.feedback_overhead_per_bit * f64::from(shield.feedback_bits)
|
self.feedback_overhead_per_bit * f64::from(shield.feedback_bits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beamforming-gain residual from the ε-DP angular dither, if enabled.
|
||||||
|
/// Unlike the keyed rotation (which the receiver undoes), the DP noise is
|
||||||
|
/// **not** removed, so it costs gain directly and grows as ε shrinks —
|
||||||
|
/// this is the tunable privacy↔throughput knob. SYNTHETIC.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dp_residual(shield: &ShieldConfig) -> f64 {
|
||||||
|
match shield.dp_epsilon {
|
||||||
|
Some(eps) if shield.enabled => {
|
||||||
|
let e = f64::from(eps).max(1e-3);
|
||||||
|
(DP_GAIN_COST / (e * e)).min(0.5)
|
||||||
|
}
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Throughput ratio of the protected link versus the unshielded baseline,
|
/// Throughput ratio of the protected link versus the unshielded baseline,
|
||||||
/// in `[0, 1]`.
|
/// in `[0, 1]`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -90,7 +105,7 @@ impl LinkModel {
|
|||||||
if !shield.enabled {
|
if !shield.enabled {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
let rho = Self::beamforming_residual(shield);
|
let rho = (Self::beamforming_residual(shield) + Self::dp_residual(shield)).min(0.9);
|
||||||
let snr = self.snr_linear();
|
let snr = self.snr_linear();
|
||||||
let capacity_ratio = (1.0 + snr * (1.0 - rho)).log2() / self.baseline_capacity();
|
let capacity_ratio = (1.0 + snr * (1.0 - rho)).log2() / self.baseline_capacity();
|
||||||
let airtime = shield.sounding_overhead + self.feedback_airtime(shield);
|
let airtime = shield.sounding_overhead + self.feedback_airtime(shield);
|
||||||
@@ -98,6 +113,10 @@ impl LinkModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gain-cost coefficient for the ε-DP dither: residual ≈ `DP_GAIN_COST / ε²`.
|
||||||
|
/// Tuned so ε≈1 costs a few points of gain and ε≲0.3 costs a lot. SYNTHETIC.
|
||||||
|
const DP_GAIN_COST: f64 = 0.004;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -118,6 +137,23 @@ mod tests {
|
|||||||
assert!(ratio < 1.0);
|
assert!(ratio < 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dp_epsilon_lowers_throughput_as_it_tightens() {
|
||||||
|
// The ε-DP dither is a real, tunable privacy↔throughput knob: smaller ε
|
||||||
|
// (more noise) costs more gain. None (off) is the cheapest.
|
||||||
|
let link = LinkModel::default();
|
||||||
|
let at = |eps: Option<f32>| {
|
||||||
|
link.throughput_ratio(&ShieldConfig {
|
||||||
|
dp_epsilon: eps,
|
||||||
|
..ShieldConfig::default()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let off = at(None);
|
||||||
|
let loose = at(Some(2.0));
|
||||||
|
let tight = at(Some(0.3));
|
||||||
|
assert!(off >= loose && loose > tight, "{off} {loose} {tight}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn throughput_has_interior_optimum_in_bits() {
|
fn throughput_has_interior_optimum_in_bits() {
|
||||||
// Very low resolution pays the residual; very high resolution pays
|
// Very low resolution pays the residual; very high resolution pays
|
||||||
|
|||||||
Reference in New Issue
Block a user