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,
|
||||
`[docs]` = documentation, `[hw]` = hardware path.
|
||||
|
||||
1. **`[code]` Reconstruction-aware attacker (decisive).** Add a BFIAttack-style
|
||||
adversary that attempts to invert/reconstruct before classifying, and a test
|
||||
asserting the keyed *orthogonal secret* rotation leaves it at chance (no key →
|
||||
no closed-form). This turns "should resist reconstruction" from assumption
|
||||
into a checked property. *(BFIAttack, MEASURED)*
|
||||
2. **`[code]` Adaptive, multi-capture attacker as baseline.** Pool captures
|
||||
across sessions/locations and re-fit (the PrivISAC adaptive attacker). We
|
||||
already model cross-session averaging; make it a first-class attacker variant
|
||||
and assert collapse holds. *(PrivISAC, MEASURED)*
|
||||
3. **`[code]` Per-packet random-unitary spatial-mapping mode.** Add the
|
||||
LeakyBeam-style AP-side, client-transparent `Q_obf` control as a first-class
|
||||
compliant mechanism alongside the keyed fine-subspace rotation, with the
|
||||
802.11 "spatial mapping not restricted" citation as its compliance basis.
|
||||
*(LeakyBeam defense, MEASURED)*
|
||||
4. **`[code]` DP-Givens knob.** Add an ε-DP quantization mode with a closed-form
|
||||
angular sensitivity bound, exposing ε as a configurable operating point.
|
||||
Label outputs `SYNTHETIC`. *(DP-Givens, SYNTHETIC)*
|
||||
1. **`[code]` ✅ implemented — Reconstruction-aware attacker (decisive).** A
|
||||
BFIAttack-style adversary (`attacker::ReconstructionAttacker`,
|
||||
`AttackerKind::Reconstruction`) recovers the direction of the CSI consistent
|
||||
with the *captured* report and classifies it; the test
|
||||
`reconstruction_attacker_collapses` confirms the keyed *orthogonal secret*
|
||||
rotation leaves it at chance (no key → it only ever recovers the rotated
|
||||
direction) while it still wins on unprotected traffic. *(BFIAttack, MEASURED)*
|
||||
2. **`[code]` ✅ implemented — Adaptive, multi-capture attacker.**
|
||||
`attacker::AdaptivePoolingAttacker` (`AttackerKind::AdaptivePooling`) pools all
|
||||
captures per identity and whitens by per-dimension std before matching (the
|
||||
PrivISAC adaptive/retraining adversary); `adaptive_pooling_attacker_collapses`
|
||||
confirms collapse still holds. *(PrivISAC, MEASURED)*
|
||||
3. **`[code]` ✅ implemented — Per-packet random-unitary mode.**
|
||||
`protector::ObfMode::PerPacketUnitary` applies a fresh unitary per packet,
|
||||
AP-side and **client-transparent** (LeakyBeam family; 802.11 spatial mapping
|
||||
"not restricted" as the compliance basis);
|
||||
`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
|
||||
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).
|
||||
|
||||
@@ -151,9 +151,9 @@ in any browser; no build, no network). `veil` is the terminal-native version.
|
||||
| `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 (Euclidean + Cosine metrics) |
|
||||
| `throughput` | Link-throughput model (residual + feedback-airtime + sounding) |
|
||||
| `protector` | The compliant waveform controls: keyed rotation, per-packet unitary (`ObfMode`), ε-DP dither (`dp_epsilon`) |
|
||||
| `attacker` | Passive adversaries: nearest-centroid (Euclidean/Cosine), BFI→CSI `Reconstruction`, `AdaptivePooling` |
|
||||
| `throughput` | Link-throughput model (quantization residual + feedback-airtime + sounding + ε-DP cost) |
|
||||
| `compliance` | Machine-checkable "not jamming" audit |
|
||||
| `experiment` | Attacker-vs-protector head-to-head |
|
||||
| `optimize` | Finds the optimal shield config (feedback bits, min passes, Pareto frontier) |
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! strength is not the lever; signature stability is.
|
||||
|
||||
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.
|
||||
///
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
//! 2. throughput stays above 95% of the unshielded baseline;
|
||||
//! 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::identity::{Channel, SceneConfig};
|
||||
use crate::prng::derive_key;
|
||||
use crate::protector::{Protector, ShieldConfig};
|
||||
use crate::protector::{ObfMode, Protector, ShieldConfig};
|
||||
use crate::throughput::LinkModel;
|
||||
|
||||
/// Configuration for a full experiment.
|
||||
@@ -40,8 +42,10 @@ pub struct ExperimentConfig {
|
||||
pub chance_margin: f32,
|
||||
/// Minimum acceptable throughput ratio.
|
||||
pub min_throughput_ratio: f64,
|
||||
/// Metric the passive attacker uses.
|
||||
/// Metric the passive attacker uses (for the nearest-centroid kind).
|
||||
pub attacker_metric: Metric,
|
||||
/// Which adversary shape to run.
|
||||
pub attacker_kind: AttackerKind,
|
||||
}
|
||||
|
||||
impl Default for ExperimentConfig {
|
||||
@@ -56,6 +60,7 @@ impl Default for ExperimentConfig {
|
||||
chance_margin: 0.03,
|
||||
min_throughput_ratio: 0.95,
|
||||
attacker_metric: Metric::Euclidean,
|
||||
attacker_kind: AttackerKind::NearestCentroid,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,13 +131,7 @@ fn measure_accuracy(
|
||||
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)
|
||||
protector.protect(&raw, rotation_key(cfg, b"enroll", s, id))
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
@@ -141,8 +140,7 @@ fn measure_accuracy(
|
||||
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)
|
||||
protector.protect(&raw, rotation_key(cfg, b"test", s, id))
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
@@ -150,9 +148,53 @@ fn measure_accuracy(
|
||||
}
|
||||
}
|
||||
|
||||
let mut atk = NearestCentroidAttacker::with_metric(cfg.attacker_metric);
|
||||
atk.enroll(&enroll);
|
||||
atk.accuracy(&test)
|
||||
// Dispatch on the adversary shape (SOTA sweep, ADR-288 §sota).
|
||||
match cfg.attacker_kind {
|
||||
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.
|
||||
@@ -234,4 +276,77 @@ mod tests {
|
||||
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 throughput;
|
||||
|
||||
pub use attacker::{Metric, NearestCentroidAttacker};
|
||||
pub use attacker::{
|
||||
AdaptivePoolingAttacker, AttackerKind, Metric, NearestCentroidAttacker, ReconstructionAttacker,
|
||||
};
|
||||
pub use compliance::ComplianceReport;
|
||||
pub use experiment::{run, ExperimentConfig, ExperimentReport};
|
||||
pub use identity::{BfiSample, Channel, SceneConfig};
|
||||
pub use optimize::{adaptive_shield, hyper_optimize, HyperOptimized};
|
||||
pub use proof::Proof;
|
||||
pub use protector::{Protector, SensingDetector, ShieldConfig};
|
||||
pub use protector::{ObfMode, Protector, SensingDetector, ShieldConfig};
|
||||
pub use throughput::LinkModel;
|
||||
|
||||
@@ -30,9 +30,32 @@
|
||||
//! frames.
|
||||
|
||||
use crate::identity::BfiSample;
|
||||
use crate::linalg::apply_givens;
|
||||
use crate::linalg::{apply_givens, norm, set_norm_inplace};
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShieldConfig {
|
||||
@@ -52,6 +75,16 @@ pub struct ShieldConfig {
|
||||
/// Fractional airtime overhead from sounding-cadence randomization
|
||||
/// (jittering NDP intervals so an eavesdropper under-samples motion).
|
||||
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 {
|
||||
@@ -61,12 +94,15 @@ impl Default for ShieldConfig {
|
||||
// minimum 48 robust passes (free margin, since mixing is keyed not
|
||||
// signaled), and 5 = the throughput-best resolution in the 802.11
|
||||
// {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 {
|
||||
enabled: true,
|
||||
givens_passes: 96,
|
||||
feedback_bits: 5,
|
||||
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
|
||||
/// 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]
|
||||
pub fn protect(&self, sample: &BfiSample, session_key: u64) -> BfiSample {
|
||||
let mut out = sample.clone();
|
||||
@@ -123,9 +166,30 @@ impl Protector {
|
||||
for (i, j, theta) in ops {
|
||||
apply_givens(fine, i, j, theta);
|
||||
}
|
||||
if let Some(eps) = self.cfg.dp_epsilon {
|
||||
Self::dp_dither(fine, eps, session_key);
|
||||
}
|
||||
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
|
||||
/// session key. Applies the inverse rotation. Used to demonstrate that 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)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// in `[0, 1]`.
|
||||
#[must_use]
|
||||
@@ -90,7 +105,7 @@ impl LinkModel {
|
||||
if !shield.enabled {
|
||||
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 capacity_ratio = (1.0 + snr * (1.0 - rho)).log2() / self.baseline_capacity();
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -118,6 +137,23 @@ mod tests {
|
||||
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]
|
||||
fn throughput_has_interior_optimum_in_bits() {
|
||||
// Very low resolution pays the residual; very high resolution pays
|
||||
|
||||
Reference in New Issue
Block a user