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:
Claude
2026-08-09 16:20:18 +00:00
parent c63b26034b
commit 192ed2a236
7 changed files with 475 additions and 41 deletions
@@ -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 14 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]` Privacythroughput *frontier*, not binary claims.** Report 5. **`[code/docs]` Privacythroughput *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 20252026 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