diff --git a/docs/research/privacy-shield/09-sota-update-2026.md b/docs/research/privacy-shield/09-sota-update-2026.md index c3532e26..f10503cf 100644 --- a/docs/research/privacy-shield/09-sota-update-2026.md +++ b/docs/research/privacy-shield/09-sota-update-2026.md @@ -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). diff --git a/v2/crates/wifi-densepose-privshield/README.md b/v2/crates/wifi-densepose-privshield/README.md index dbb54894..42af51bc 100644 --- a/v2/crates/wifi-densepose-privshield/README.md +++ b/v2/crates/wifi-densepose-privshield/README.md @@ -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) | diff --git a/v2/crates/wifi-densepose-privshield/src/attacker.rs b/v2/crates/wifi-densepose-privshield/src/attacker.rs index 5ab56e78..0144eb63 100644 --- a/v2/crates/wifi-densepose-privshield/src/attacker.rs +++ b/v2/crates/wifi-densepose-privshield/src/attacker.rs @@ -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>, + ids: Vec, +} + +fn reconstructed_direction(s: &BfiSample) -> Vec { + 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 = Vec::new(); + let mut sums: Vec> = Vec::new(); + let mut counts: Vec = 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 { + 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>, + ids: Vec, + inv_std: Vec, +} + +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 = Vec::new(); + let mut sums: Vec> = Vec::new(); + let mut counts: Vec = 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 { + 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::*; diff --git a/v2/crates/wifi-densepose-privshield/src/experiment.rs b/v2/crates/wifi-densepose-privshield/src/experiment.rs index 2ea06467..b2a2cdf4 100644 --- a/v2/crates/wifi-densepose-privshield/src/experiment.rs +++ b/v2/crates/wifi-densepose-privshield/src/experiment.rs @@ -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 + ); + } } diff --git a/v2/crates/wifi-densepose-privshield/src/lib.rs b/v2/crates/wifi-densepose-privshield/src/lib.rs index 75b832ae..61395813 100644 --- a/v2/crates/wifi-densepose-privshield/src/lib.rs +++ b/v2/crates/wifi-densepose-privshield/src/lib.rs @@ -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; diff --git a/v2/crates/wifi-densepose-privshield/src/protector.rs b/v2/crates/wifi-densepose-privshield/src/protector.rs index 96a3a6e7..c17bdce9 100644 --- a/v2/crates/wifi-densepose-privshield/src/protector.rs +++ b/v2/crates/wifi-densepose-privshield/src/protector.rs @@ -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, } 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 diff --git a/v2/crates/wifi-densepose-privshield/src/throughput.rs b/v2/crates/wifi-densepose-privshield/src/throughput.rs index ddd1837c..6685f599 100644 --- a/v2/crates/wifi-densepose-privshield/src/throughput.rs +++ b/v2/crates/wifi-densepose-privshield/src/throughput.rs @@ -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| { + 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