Merge pull request #1561 from ruvnet/claude/privacy-shield-wifi-sensing-i8vz1b

WiFi Veil — compliant-waveform privacy shield: reference crate, E2E firmware, standalone repo + CI honesty guard
This commit is contained in:
rUv
2026-08-11 12:56:26 -04:00
committed by GitHub
172 changed files with 19542 additions and 0 deletions
@@ -0,0 +1,231 @@
# ADR-288: VEIL — a compliant-waveform privacy shield against unauthorized WiFi sensing
| Field | Value |
|-------|-------|
| **Status** | Proposed — implemented (P1 reference model) |
| **Date** | 2026-08-09 |
| **Deciders** | ruv |
| **Codename** | **VEIL** — Verifiable Emission-shaping for Identity-Leakage prevention |
| **Codebase target** | new leaf crate `v2/crates/wifi-densepose-privshield` |
| **Parent** | ADR-118 (BFLD — the detection layer VEIL is the countermeasure to), ADR-282 (mandatory L0L5 evidence ladder) |
| **Relates to** | ADR-120/121 (BFLD privacy class + identity-risk scoring — the trigger source), ADR-141 (privacy control plane / runtime attestation — the audit consumer), ADR-280 (active sensing / governed actuation — VEIL is a defensive sensing action), ADR-185 §13 (`wifi-densepose-aether` — the pure-compute leaf pattern this crate follows) |
| **Research bundle** | [`docs/research/privacy-shield/`](../research/privacy-shield/) (9 files) |
| **Tracking issue** | TBD |
## 0. PROOF discipline
Every defense number this crate produces is **SYNTHETIC / evidence level L0**
(ADR-282): generated by the crate's own model (`identity::Channel`), attacked by
the crate's own classifier (`attacker::NearestCentroidAttacker`), and scored
against its own known labels. Nothing here has been validated against real WiFi
silicon, and the crate contains no radio integration and cannot emit RF. External
attack/defense results cited from the literature (BFId, LeakyBeam, DySPAN-2026,
IRShield, FCC statutes) are **EXTERNAL** evidence and labelled MEASURED/CLAIMED in
the research bundle. The single measured claim about *our own behavior* is the
pinned deterministic witness in `proof.rs`.
## 1. Context
### 1.1 The gap
IEEE 802.11ac/ax beamforming feedback (BFI) — the compressed Givens-rotation
angle matrices (φ/ψ) a client sends the AP — is transmitted **unencrypted on the
management plane**. Any device in monitor mode can capture it for every station
at once, no network access, and the target need carry no device. The literature
establishes the severity: **BFId** (ACM CCS 2025) re-identifies individuals from
BFI; **LeakyBeam** (NDSS 2025) detects occupancy through walls at 20 m from BFI;
**BeamSense** recognizes activities at up to 99.28%. IEEE Std **802.11bf-2025**
(published 26 Sep 2025) standardizes the sensing measurement/feedback surface
these attacks abuse — and a 2023 proposal for a BFI secure-transmission mechanism
(802.11-23/0782) was **withdrawn**, so the standard shipped with no privacy
protections.
RuView already has a *detection* layer for this: **BFLD** (ADR-118/121) measures
the identity-leakage of each frame and gates what leaves the node. But BFLD
protects *RuView's own outputs*; it does nothing about a **third-party sniffer**
capturing the room's plaintext BFI off the air. There is no RuView component, and
per our market survey no shipping product anywhere, that prevents that.
### 1.2 Constraint: compliant waveform controls, never jamming
The defense must preserve normal communications and must not interfere with any
other station. Jamming (47 U.S.C. §333/§302a) is defined by *adding energy to
interfere with others' transmissions*. Any acceptable control must shape only the
node's **own** standards-conformant emission.
### 1.3 The separability insight
Identity leaks through the *fine* cross-subcarrier phase structure of a
beamforming report; data throughput rides the *dominant* beam direction. These
are (mostly) separable subspaces — so a transform confined to the fine subspace
can wreck re-identification while sparing the beam the link depends on. DySPAN-2026
independently MEASURED that shaping fine-resolution feedback is near-free in
throughput, corroborating the insight.
## 2. Decision
Ship **`wifi-densepose-privshield`** (VEIL) as a standalone pure-compute leaf
crate (the `wifi-densepose-aether`/`nvsim` pattern: dependency-free, deterministic,
WASM-ready, zero coupling to any radio or ingestion path), implementing:
1. **A SYNTHETIC two-subspace BFI model** (`identity.rs`): each identity owns a
stable fine-block signature; sessions add environmental nuisance; the comm
block is identity-free and carries throughput.
2. **The protector** (`protector.rs`): compliant waveform controls, primarily a
**per-session keyed orthogonal rotation of the fine subspace, composed from
extra Givens rotations** — the report's native primitive. Plus feedback
quantization/dither, sounding-cadence randomization, and a `SensingDetector`
that engages the shield only when sensing activity is observed.
3. **The adversary** (`attacker.rs`): a passive nearest-centroid re-identifier
modeling the BFId threat, with selectable Euclidean/Cosine metrics.
4. **A throughput model** (`throughput.rs`):
`(1 sounding feedback_airtime) · C(SNR·(1−ρ))/C(SNR)`, where the residual
`ρ` falls with feedback bits and the feedback airtime rises with them — giving
a genuine interior throughput optimum in feedback resolution.
5. **A compliance audit** (`compliance.rs`): the rotation is orthogonal ⇒
energy-preserving ⇒ adds no interfering energy ⇒ **not jamming**, turned into a
checked `ComplianceReport` (energy ratio ≈ 1.0).
6. **The experiment** (`experiment.rs`): runs the attacker against unprotected and
protected traffic and reports both accuracies vs. chance, plus throughput and
compliance, with a single `passed()` verdict.
7. **The hyper-optimizer** (`optimize.rs`): derives the shipped shield config
rather than hand-picking it — the throughput-optimal feedback resolution and
the minimum rotation-mixing budget that collapses re-ID robustly (across both
attacker metrics and N∈{16,32}), plus a Pareto frontier.
8. **A deterministic proof** (`proof.rs`): a pinned FNV-1a witness over the
reference experiment (the `nvsim`/`verify.py` discipline).
### 2.1 Why the keyed Givens rotation
It is simultaneously **orthogonal** (energy-preserving ⇒ compliant),
**key-reversible** (the associated AP shares the session key and recovers the true
precoder ⇒ throughput preserved), and **fresh per session** (a sniffer sees a new
random rotation of the signature each session and cannot average it back ⇒ the
enrollment attack collapses; over unknown rotations the signature carries no
stable discriminative information ⇒ re-ID → chance). It is the shared-secret
precoding idea (cf. MIMOCrypt) specialized to the identity-bearing subspace.
### 2.2 Measured behavior (SYNTHETIC / L0)
Reference experiment at the hyper-optimized operating point (§opt), default
scene, N=16 identities, `cargo test`:
| Metric | Shield off | Shield on |
|---|---|---|
| Passive re-ID accuracy | 100.0% | **4.7%** (chance 6.25%) |
| Link throughput ratio | 100% | **97.6%** |
| Emission energy ratio | — | **1.000000** (compliant) |
All 35 unit/proof tests + doctest pass; the crate builds for
`wasm32-unknown-unknown` and is clippy-clean.
### opt. Hyper-optimization (`optimize.rs`)
The shipped shield config is the optimizer's output, not a guess, and
`ShieldConfig::default()` is asserted equal to it:
- **Feedback resolution = 5 bits.** Throughput has an interior optimum in
feedback bits (residual falls, feedback airtime rises); the unconstrained
optimum is 3 bits (matching DySPAN-2026), and 5 is the throughput-best value in
the spec-allowed 802.11 {5,7,9} set.
- **Givens passes = 96.** The proven minimum for robust collapse — across both
attacker metrics *and* N∈{16,32} — is **48**; the shipped 96 is a free 2×
privacy margin, since the keyed rotation is derived from the shared secret and
never signaled (extra passes cost compute, not airtime). The original
hand-picked 112 was 2.3× over-provisioned.
Net vs. the original hand-picked (112 passes / 7 bits): the optimum is strictly
better on **both** privacy (re-ID 0.047 vs 0.078) and throughput (0.976 vs 0.974),
and is now verified rather than assumed. See
`docs/research/privacy-shield/08-optimization.md`.
### harness. Native terminal harness + TUI (`src/bin/veil.rs`)
A custom, dependency-free binary (`veil`) ships with the crate — the in-repo,
native counterpart to the npm metaharness (ADR-289). It drives the same public
API the tests use, as an interactive ANSI dashboard plus scriptable subcommands
(`report`, `sweep`, `optimize`, `adaptive <N>`, `proof`, `doctor`, `tui`).
Std-only (no `crossterm`/`ratatui`): the TUI is a command-driven redraw loop, so
it runs in any terminal, pipe, or CI and keeps the crate a pure leaf. It reports
only SYNTHETIC/L0 numbers and never relabels them. The wasm leaf story is
unchanged (validated with `--lib`; the bin is native-only).
### sota. 20252026 evidence update (verified)
A cited, adversarially-verified SOTA sweep
(`docs/research/privacy-shield/09-sota-update-2026.md`) refines the threat and
positioning. Load-bearing points for this ADR:
- **Threat is broader and cheaper than §1.1 stated.** A passive, keyless,
single-antenna sniffer at ~20 m and *through walls* can identify people
(BFId, 99.5%/N=197, `MEASURED`), read **breathing** from stationary occupants
and **keystrokes/PINs** (LeakyBeam / WiKI-Eve / SThief, `MEASURED`), and —
decisively — **reconstruct full CSI from the sniffed BFI** (BFIAttack,
≥93% single-antenna, `MEASURED`). VEIL's obfuscation must therefore degrade
*reconstructed-CSI* utility, not merely raw-BFI feature noise; because VEIL's
rotation is a **secret orthogonal** transform, the attacker has no key and no
closed-form to invert — this is now a claim to **test**, not assume.
- **VEIL's family is independently validated.** AP-side per-packet random
unitary on the LTF (LeakyBeam defense, 89.7%→~51%, `MEASURED`) and RIS
obfuscation (PrivISAC, 93%→~30%, robust to a retrained multi-location
attacker, `MEASURED`) confirm standard-permitted beamforming-surface
obfuscation works; DP-Givens quantization (`SYNTHETIC`) offers a formal ε knob.
- **Compliance precedent.** BeamDancer (IEEE TWC 2024, `MEASURED`) argues
native-beamforming obfuscation is 802.11-compliant while jamming/geofencing
are not — cite it as precedent. (Its ">96% PDR" figure was **refuted** in
verification; do not cite it.)
- **Security honesty.** Obfuscation shields have published counter-attacks
("Defeating CSI obfuscation", SnoopFi), so VEIL's own shield security is
`CLAIMED`, not proven-secure, until it withstands learned de-obfuscation.
- **Governance gap.** No claim on 802.11bf-2025 privacy provisions survived
verification; that pillar remains an open question, not an asserted fact.
The derived, prioritized improvement backlog lives in the SOTA-update file (§4).
## 3. What this explicitly is NOT
- **Not a radio driver.** No RF frontend, no transmit path, no
`wifi-densepose-hardware` coupling. VEIL cannot emit and cannot jam.
- **Not a defense against the associated AP.** That party holds the session key by
construction (threat class A3); protecting against a malicious AP is BFLD's
detection/privacy-class problem (ADR-118/141), not this shield's.
- **Not a full motion-obfuscation claim.** A fixed per-session rotation does not
hide coarse within-session motion; identity *re-ID* is the guaranteed target,
motion is partial/future work.
- **Not a real-hardware performance claim.** All defense numbers are SYNTHETIC/L0
until a two-node capture with a boot/runtime-log witness exists (CLAUDE.md
hardware rule; roadmap P5).
- **Not RF denial or camera-grade anything.**
## 4. Simplifications (honesty boundary)
- The two-subspace split is an abstraction; on real radios comm and identity
information are only *approximately* separable, so the real throughput cost of
fully hiding identity may exceed the model's ~2%. DySPAN-2026's MEASURED curve
bounds it as *small* at fine resolution, not zero.
- The attacker is nearest-centroid. The collapse argument is classifier-independent
(it is about the marginalized signal), but P2/P5 must confirm a learned attacker
also collapses.
- The crate's PRNG is SplitMix64 — deterministic and WASM-safe but **not
cryptographic**; a deployment derives the rotation key from the negotiated link
secret, never from this PRNG.
## 5. Consequences
- RuView gains the *countermeasure* half of its RF-privacy story: BFLD detects
leakage, VEIL acts on it — a defensible, standards-anchored, gap-filling
position (see `docs/research/privacy-shield/06-market-and-buyers.md`).
- The compliance audit gives regulators/auditors a machine-checkable "not jamming"
artifact that composes with ADR-141 attestation.
- Future integration (BFLD `identity_risk``SensingDetector`, ADR-280 governed
actuation, firmware feedback shaping, two-node hardware measurement) is staged in
the research bundle roadmap and deliberately deferred so the model validates in
isolation first.
## 6. Validation
```bash
cargo test -p wifi-densepose-privshield --no-default-features
cargo build -p wifi-densepose-privshield --target wasm32-unknown-unknown
cargo clippy -p wifi-densepose-privshield --all-targets
```
@@ -0,0 +1,95 @@
# ADR-289: `wifi-densepose-privshield-harness` — a MetaHarness for the VEIL privacy shield
| Field | Value |
|-------|-------|
| **Status** | Proposed — implemented (P1) |
| **Date** | 2026-08-09 |
| **Parent** | ADR-288 (`wifi-densepose-privshield` / VEIL, the crate this harness assists development on) |
| **Relates to** | ADR-286 (`wifi-densepose-sar-harness`, the per-crate harness scaffold this one mirrors), ADR-285 (`harness/homecore/`, the WASM-first `@metaharness/kernel` pattern), ADR-182 (`harness/ruview/`, the first minted harness), ADR-282 (L0L5 evidence ladder) |
| **Location** | `harness/wifi-densepose-privshield/` |
## 0. PROOF discipline
Every claim below about what is "real" versus "illustrative"/"SYNTHETIC" is
checked by a test in this harness's own suite (router + flywheel + install-smoke
+ guidance). The dependency-free `guidance` surface is covered by
`__tests__/guidance.test.ts`, which runs even before `npm install`. Nothing here
asserts a MEASURED defense result — the harness surfaces the VEIL crate's
SYNTHETIC/L0 numbers with that label intact.
## 1. Context
`wifi-densepose-privshield` (ADR-288) is the VEIL privacy shield — a new,
narrowly-scoped crate. Following the pattern ADR-286 set for
`wifi-densepose-sar`, it gets a dedicated per-crate MetaHarness rather than a
bespoke setup: the `vertical:coding` scaffold (architect/implementer/reviewer/
test-writer, `doctor`) with `@metaharness/router`, `@metaharness/flywheel`, and
Darwin Mode wired in, plus a VEIL-specific, dependency-free `guidance` surface.
## 2. Decision
Land the harness at `harness/wifi-densepose-privshield/`, mirroring
`wifi-densepose-sar-harness`, with two deliberate improvements:
1. **Dynamic dependency imports.** `bin/cli.js` imports the `@metaharness/*`
packages *inside* the commands that need them, not at module top. So
`guidance`, `--help`, and the guidance test run with **zero dependencies
installed** — useful for offline/air-gapped review and for this repo's CI
before `npm install`. Only `init`/`doctor`/`route`/`flywheel` touch the
kernel/host/router/flywheel packages.
2. **A VEIL `guidance` command.** A self-contained, source-cited, read-only
capability map (topics: `overview`, `threat`, `countermeasure`,
`compliance`, `optimization`, `experiment`), each entry carrying a summary,
repo-relative source citations, focused validation commands, and explicit
limitations — the `ruview_guidance` shape, specialized to VEIL. It labels all
defense evidence `SYNTHETIC/L0` and states plainly that guidance is
navigation, not authority.
The standard three self-improvement/cost pieces are wired as real npm
dependencies (not stubs):
- **`@metaharness/darwin`** (devDependency) — `npm run evolve` / `evolve:dry`
mutates the harness's own operating config, keeping only measurable gains.
- **`@metaharness/router`** — `src/router.ts` wires a real cost-optimal `Router`
(`qualityBar: 0.8`, k=1) over two model tiers, with four VEIL-shaped task axes
(threatModeling / complianceReview / optimizerTuning / docWriting). Labelled
examples are illustrative seed data (honesty note in-file).
- **`@metaharness/flywheel`** — `src/flywheel.ts` wires the real
`runFlywheelGenerations` promotion loop (propose → evaluate → gate → promote,
Ed25519-signed, independently replayable) with a SYNTHETIC proposer/evaluator
(`dataSource: 'SYNTHETIC'`, no model call), over VEIL policy levers
(`complianceReview`, `threatTriage`).
## 3. What this explicitly is NOT
- **Not a VEIL runtime.** The harness does not run a radio, emit RF, or jam. It
assists *development* on the crate; it cannot execute the shield on hardware.
- **Not evolving the crate.** Darwin/Flywheel mutate the harness's own policy
(agent prompts, review-checklist depth), not VEIL's Rust code. The crate's
actual hyper-optimization (ADR-288 §opt) was done directly, in the crate.
- **Not a live routing/promotion system.** The router's examples are seed data;
the flywheel's proposer/evaluator are deterministic stand-ins — both honestly
labelled in-source and in `CLAUDE.md`.
- **Not a replacement for the crate's gates.** The authoritative check for a
VEIL change remains `cargo test -p wifi-densepose-privshield`.
- **Not a re-labeller.** The harness must never present VEIL's SYNTHETIC results
as MEASURED, and never scaffold interference-based ("jamming") defenses — both
are hard rules in the harness `CLAUDE.md`.
## 4. Consequences
- The harness ships `guidance`/`doctor`/`init`/`route`/`flywheel`; `guidance`
and `--help` work offline (validated here via `node bin/cli.js`), the rest
after `npm install` + `npm run build` (CI).
- `.harness/manifest.json` + `manifest.sha256` are generated with real per-file
hashes at creation (unlike ADR-286's scaffold, whose manifest was historical).
- Scoped to its own name: its plugin, permissions, and (future) MCP surface only
read/assist on `wifi-densepose-privshield`. No risk to other harnesses/crates.
## 5. Validation
```bash
cd harness/wifi-densepose-privshield
node bin/cli.js guidance --topic overview # dependency-free
npm ci && npm run build && npm test # full suite (CI; needs registry access)
```
@@ -0,0 +1,94 @@
# ADR-290: VEIL end-to-end hardware implementation program (multi-provider firmware)
| Field | Value |
|-------|-------|
| **Status** | Proposed — P4 scaffolding (build-only); portable core validated on host |
| **Date** | 2026-08-09 |
| **Parent** | ADR-288 (VEIL shield), ADR-289 (harness), ADR-282 (L0L5 evidence ladder) |
| **Location** | `firmware/privshield/` |
| **Relates to** | `firmware/esp32-csi-node/` (the CSI sensor/attacker node), ADR-280 (governed actuation), ADR-141 (attestation) |
## 0. PROOF discipline
The **only** artifact validated here is the portable C core
(`firmware/privshield/core/`): a host test (`make test`) checks energy
conservation, reversibility, wrong-key failure, and — pinned — that its
SplitMix64 key schedule is **byte-identical to the Rust crate's** PRNG. That is
`build`/host-level evidence, not silicon. Every per-provider adapter is a
**build-only scaffold** with `TODO(hw)` markers: `SYNTHETIC / L0`, no captured
log, no `MEASURED` claim. Nothing in this ADR asserts VEIL works on real
hardware; it asserts a *plan and a shared core* to get there (P5).
## 1. Context
ADR-288 shipped VEIL as a deterministic, no-radio Rust model, and the 20252026
SOTA sweep (ADR-288 §sota) confirmed the mechanism's family is real and
standard-permitted. The open question left was **"does this run on real WiFi
hardware, and on which?"** — including the user asks: *can OpenWRT / open WiFi
software implement it, and can ESP32 help scramble signals?* Answering requires
committing to the platform reality rather than assuming a uniform "firmware"
target.
## 2. Decision
Stand up `firmware/privshield/` as a **multi-provider E2E program** around one
shared, validated core:
1. **A portable C shield core** (`core/veil_shield.{h,c}`) — the keyed
Givens-rotation obfuscation, `no_std`-friendly C99 (no malloc/libc I/O), with
a SplitMix64 key schedule matching the Rust crate so on-air behavior is
identical everywhere and every adapter links the *same* math. Host-tested.
2. **Per-provider adapters**, each built and graded by a hardware research
agent, honest about what its stack can actually touch:
- **`openwifi/`** (open PHY/MAC on SDR/FPGA) — the highest-capability path and
the one that can host the **keyed-reversible** design end-to-end
(protector + AP-side compensation). Carries the **P5 measurement protocol**
(`MEASUREMENT.md`) that yields the first `MEASURED` result with a witness.
- **`openwrt/`** (Linux `mac80211`, mt76/ath9k…) — the commodity path.
Sounding-cadence randomization, MU-group and stream-mapping control are
feasible from the driver/hostapd; the per-packet unitary on the LTF spatial
mapping is firmware-deep on most parts. Partial.
- **`nexmon/`** (Broadcom/Cypress C firmware patches) — the commodity
C-firmware route; the read path is proven (Wi-BFI/nexmon_csi), the transmit
report-shaping path is research-grade/partial.
- **`esp32/`** (ESP-IDF) — **not** a feedback protector (the beamforming path
is a closed blob): ESP32 shapes CSI *read*, not transmitted feedback. Its
legitimate roles are a **sensing detector** (trigger the AP-side shield) and
an **RIS controller** (drive an external reconfigurable surface to scramble
the sensing direction — the honest way ESP32 "helps scramble", via an
external surface, not its own PHY).
3. **Compliance stance carried into hardware:** every control shapes the node's
own standards-conformant emission and preserves energy; the ESP32
decoy/cover-traffic idea is documented as *legally sensitive / not
recommended* precisely because it edges toward the interference line.
Per-provider feasibility grades live in each subdir README and the top-level
feasibility matrix; they are the answer to the "which hardware" question.
## 3. What this explicitly is NOT
- **Not validated firmware.** No adapter has run on silicon; there is no witness.
The scaffolds compile-*shaped*, not compile-*guaranteed* on their toolchains
(which are absent in this environment).
- **Not a claim that ESP32 can shield beamforming feedback** — it cannot; it is a
detector/RIS-controller only.
- **Not jamming, on any platform.** Compliant waveform shaping only.
- **Not a MEASURED result.** That is P5, gated on a captured log.
## 4. Consequences
- One validated core, four honest provider scaffolds, and a concrete P5
measurement plan — a real path from model to silicon, with the effort/blocker
reality made explicit per platform.
- The shared core keeps every future hardware result consistent with the crate
and with each other.
- Scope stays inside `firmware/privshield/`; no other crate/firmware is touched
(the existing `esp32-csi-node` remains the sensor/attacker node).
## 5. Validation
```bash
cd firmware/privshield/core && make test # host: energy/reversibility/PRNG parity
# per-provider builds require their toolchains (ESP-IDF, OpenWRT SDK, Nexmon,
# Vivado) and real hardware — see each subdir's BUILD/INTEGRATION notes.
```
+3
View File
@@ -145,6 +145,9 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-287](ADR-287-coherent-wideband-rf-tomography-crate.md) | `wifi-densepose-sar` — coherent wideband RF tomography research crate | Accepted (implemented, published) | | [ADR-287](ADR-287-coherent-wideband-rf-tomography-crate.md) | `wifi-densepose-sar` — coherent wideband RF tomography research crate | Accepted (implemented, published) |
| [ADR-285](ADR-285-homecore-wasm-first-metaharness.md) | WASM-first Homecore developer metaharness via `npx homecore` | Accepted (implemented and validated) | | [ADR-285](ADR-285-homecore-wasm-first-metaharness.md) | WASM-first Homecore developer metaharness via `npx homecore` | Accepted (implemented and validated) |
| [ADR-286](ADR-286-wifi-densepose-sar-harness-via-metaharness.md) | `wifi-densepose-sar-harness` — MetaHarness with darwin/router/flywheel | Accepted (implemented, published) | | [ADR-286](ADR-286-wifi-densepose-sar-harness-via-metaharness.md) | `wifi-densepose-sar-harness` — MetaHarness with darwin/router/flywheel | Accepted (implemented, published) |
| [ADR-288](ADR-288-veil-privacy-shield-compliant-waveform.md) | VEIL — compliant-waveform privacy shield against unauthorized WiFi sensing (`wifi-densepose-privshield`) | Proposed (implemented, P1 reference) |
| [ADR-289](ADR-289-wifi-densepose-privshield-harness-via-metaharness.md) | `wifi-densepose-privshield-harness` — npm MetaHarness for the VEIL crate (guidance/router/flywheel) | Proposed (implemented, P1) |
| [ADR-290](ADR-290-veil-e2e-hardware-implementation-program.md) | VEIL end-to-end hardware implementation program — portable C core + multi-provider firmware scaffolds (openwifi/openwrt/nexmon/esp32) | Proposed (P4 scaffolding; C core host-validated) |
--- ---
@@ -0,0 +1,141 @@
# 01 — State of the Art
Scope: what a passive or active adversary can extract about *who* is in a space
and *what they are doing* from WiFi, the standard that broadens that surface, and
the countermeasures that try to prevent it. Claims are tagged **MEASURED** (from
a primary source, with metric), **CLAIMED** (asserted without an independent
measurement), or analytical inference (flagged).
---
## 1. The attack surface: beamforming feedback (BFI)
Since WiFi 5 (802.11ac), a client (beamformee) measures the downlink channel,
compresses the steering matrix **V** into **Givens-rotation angles φ/ψ**, and
transmits them **in cleartext** so the AP can steer beams. Anyone in monitor
mode can capture these frames for *every* client simultaneously — no network
access, and the target need carry no device. Quantization is coarse (802.11ac
angle steps of π/4…π/32 rad) yet retains rich motion and body information.
| Work | Venue / year | Result | Label |
|---|---|---|---|
| **BFId** — identity inference from BFI | ACM CCS 2025 (KIT/KASTEL) | Re-identifies individuals from BFI alone; novel 197-person dataset. Press reports **99.5%** in a controlled study (ACM full text was not openable to confirm class count/split) | MEASURED (paper); 99.5% is CLAIMED via press |
| **LeakyBeam** — occupancy through walls | NDSS 2025 | Occupancy detection **TPR 82.7% / TNR 96.7%** at **20 m, through walls**, from plaintext BFI. Proposes a BFI-obfuscation defense | MEASURED (attack); defense overhead CLAIMED |
| **BFIAttack** — CSI reconstruction from BFI | arXiv 2026 (USF) | Reconstructs CSI from BFI, then defeats CSI defenses. ASR: device auth 95.5% / user auth 92.6% / key-gen 94.2% (single-antenna), 1.56 m | MEASURED |
| **BeamSense** — activity recognition from BFI | Computer Networks vol. 258, 2025 (Northeastern) | Human activity recognition **up to 99.28%** on commodity 802.11ac, no firmware mod, ~10% better than CSI | MEASURED |
| **Wi-BFI** — capture tooling | arXiv 2309.04408, 2023 | Pip-installable extraction of 802.11 BFI from commercial devices | tooling |
**Takeaway for the defender.** BFI is the highest-leverage surface: unencrypted,
management-plane, device-free, capturable en masse with off-the-shelf tools. It
is also a *stepping stone* — BFIAttack shows BFI can reconstruct the CSI that all
older attacks assume.
---
## 2. The older adjacent surface: CSI identity/gait/activity
CSI requires special extraction (Intel 5300 / Atheros / ESP32) but is the
foundation the BFI attacks build on. Person-ID exploits **gait** as a biometric.
Representative MEASURED results (commodity WiFi, CSI amplitude):
| System | Accuracy | N (candidates) | Note |
|---|---|---|---|
| WiWho (IPSN 2016) | 92%→80% | 2→6 | 23 m straight walk |
| WiFi-ID (2016) | 93%→77% | 2→6 | wavelet features |
| WiPIN (2018) | 92100% | ≤30 | operation-free |
| Deep-WiID (2019) | 92.599.7% | 6→15 | GRU |
| WiNet / LWID (2020) | 98.5% / 98.8% | 40 / 50 | CNN |
**Pattern the defender must exploit and not overstate:** accuracy is high in
small closed sets but *degrades as N grows and conditions become realistic*
(cross-day, cross-location, cross-walking-style). Chance is **1/N**; a 99% result
on N=5 is far weaker evidence than 99% on N=197. Open-world scale is largely
unproven (see *SoK: Security Evaluation of Wi-Fi CSI Biometrics*, 2025).
---
## 3. The standard: IEEE 802.11bf-2025
IEEE Std **802.11bf-2025** (Amendment 4: *Enhancements for WLAN Sensing*) was
published **26 September 2025**. It standardizes WLAN sensing in 17.125 GHz and
above 45 GHz, defining sensing capability signaling, measurement/sounding
setup, feedback types, and both passive (ambient-traffic) and active
(dedicated null-packet) sensing modes.
- **Attack-surface implication (analytical).** 802.11bf turns CSI/measurement
acquisition from proprietary hacks into open, vendor-agnostic, machine-readable
MAC signaling across heterogeneous devices — institutionalizing exactly the
measurements the BFI attacks abuse. The standard frames sensing as a feature,
not a threat.
- **The privacy gap (MEASURED from standards minutes).** A 2023 proposal for a
BFI "secure transmission mechanism" (IEEE 802.11-23/0782) was **withdrawn**;
"the group did not align on the characterization of [the] privacy problem."
The standard shipped without privacy protections, and its own analysis admits
passive eavesdroppers can extract location, respiration, heart rate, and
identity.
---
## 4. Countermeasures (the defense literature)
All operate on the defender's *own* transmissions; none are jamming.
| Countermeasure | Venue / year | Mechanism | Effect | Label |
|---|---|---|---|---|
| **IRShield** | IEEE S&P 2022 | IRS/reconfigurable surface randomizes reflected paths | Attacker motion-detection **≤5%** | MEASURED |
| **PhyCloak** | USENIX NSDI 2016 | Full-duplex obfuscator injects Doppler/phase distortion into sensing only | **88.69%** gesture-spoof; throughput can rise (whitelist legit sensors) | MEASURED (spoof); throughput CLAIMED |
| **DP-Givens dithering** | IEEE DySPAN 2026 | Differentially-private stochastic quantization of BFI φ/ψ angles | Attacker speed-class error 19%→~73% (chance); **fine (3-bit) resolution ≈ non-private baseline throughput** | MEASURED |
| **MIMOCrypt / WiShield** | 2023 / IEEE JSAC 2024 | Secret precoding / MIMO CSI manipulation so only the intended RX decodes | Anti-tracking | CLAIMED/formal |
| **CSI Fuzzing / DP feature release** | IEEE 202425 | Randomized CSI features with DP budget | Formal DP guarantee | CLAIMED/formal |
| **ScatterShield** | ACM IMWUT 2025 | Backscatter tags inject controlled clutter | Defeats unauthorized sensing | MEASURED |
| **Adversarial packet perturbation** | ACM MobiCom 2024 | Small in-spec packet perturbations degrade attacker model | Symmetric defense | MEASURED |
**The fundamental tradeoff (MEASURED, DySPAN 2026).** Perturbing precoding/
feedback that an attacker exploits also degrades legitimate beamforming gain —
*but the cost collapses at fine feedback resolution*:
| Randomization | Attacker error | Beamforming gain retained |
|---|---|---|
| none | 19% | 100% |
| moderate (p=0.3) | >50% | median >90% |
| maximum (p≥0.9) | ~73% (≈chance) | median ~58% |
At **high (3-bit) feedback resolution, privacy was "nearly indistinguishable
from the non-private baseline"** in link performance. This is the empirical basis
for VEIL's design choice (compliant fine-resolution feedback shaping — see
[03-countermeasure-design.md](03-countermeasure-design.md)).
---
## 5. Where VEIL sits
The literature has two families: **external** obfuscation (IRShield/ScatterShield
— extra hardware, perturbs the channel) and **transmitter-side** feedback/precoder
shaping (DP-Givens, MIMOCrypt — no extra hardware, perturbs your own report).
VEIL is in the second family and adds the missing property the others do not all
combine: a transform that is simultaneously **energy-preserving** (provably
compliant), **key-reversible** (throughput-preserving for the legitimate link),
and **session-fresh** (defeats cross-session re-identification), unified around
the Givens-rotation primitive the report already uses.
---
## Sources
- BFId — ACM CCS 2025: https://dl.acm.org/doi/10.1145/3719027.3765062 · KIT record: https://publikationen.bibliothek.kit.edu/1000185756
- LeakyBeam — NDSS 2025: https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/
- BFIAttack — arXiv 2604.04179: https://arxiv.org/html/2604.04179v1
- BeamSense — Computer Networks 2025: https://dl.acm.org/doi/10.1016/j.comnet.2024.111020 · arXiv 2303.09687: https://arxiv.org/pdf/2303.09687
- Wi-BFI — arXiv 2309.04408: https://arxiv.org/pdf/2309.04408
- SoK: Security Evaluation of Wi-Fi CSI Biometrics — arXiv 2511.11381: https://arxiv.org/pdf/2511.11381
- WiWho (IPSN 2016): https://dl.acm.org/doi/10.5555/2959355.2959359 · WiPIN — arXiv 1810.04106: https://arxiv.org/pdf/1810.04106
- Survey on Wi-Fi Sensing for Human Identity — MDPI Electronics 2023: https://www.mdpi.com/2079-9292/12/23/4858
- IEEE Std 802.11bf-2025: https://standards.ieee.org/ieee/802.11bf/11574/ · Overview — IEEE COMST 2024: https://ieeexplore.ieee.org/document/10547188/ · NIST: https://www.nist.gov/publications/ieee-80211bf-enabling-widespread-adoption-wi-fi-sensing
- 802.11bf privacy proposal withdrawal (802.11-23/0782), summarized: https://pascalpiron.substack.com/p/wifi-sensing-and-the-privacy-fix
- IRShield — IEEE S&P 2022 / arXiv 2112.01967: https://arxiv.org/abs/2112.01967 · https://ieeexplore.ieee.org/document/9833676/
- PhyCloak — USENIX NSDI 2016: https://www.usenix.org/conference/nsdi16/technical-sessions/presentation/qiao
- Protecting Human Activity Signatures in Compressed 802.11 CSI Feedback — DySPAN 2026 / arXiv 2512.18529: https://arxiv.org/abs/2512.18529
- MIMOCrypt — arXiv 2309.00250: https://arxiv.org/pdf/2309.00250 · WiShield — IEEE JSAC 2024: https://dl.acm.org/doi/abs/10.1109/JSAC.2024.3414597
- ScatterShield — ACM IMWUT 2025: https://dl.acm.org/doi/abs/10.1145/3770653
- Practical Adversarial Attack on WiFi Sensing — ACM MobiCom 2024: https://dx.doi.org/10.1145/3636534.3649367
- Privacy-Preserving Wi-Fi Data Generation via DP — INFOCOM 2025: https://www.eng.auburn.edu/~szm0001/papers/INFOCOM25.pdf
@@ -0,0 +1,94 @@
# 02 — Threat Model
VEIL protects a physical space (a room, a ward, a boardroom, a SCIF) from
*unauthorized* WiFi-based inference of **who is present** and **what they are
doing**, without denying the space its own working WiFi. This file states the
adversary classes, exactly what VEIL defends, and — just as importantly — what
it does **not**.
---
## 1. Assets
| Asset | Why it matters |
|---|---|
| **Identity linkage** | Re-identifying a specific person across time/sessions from their RF signature (BFId-class attack) |
| **Occupancy / presence** | Whether the space is occupied, and by how many (LeakyBeam-class, through-wall) |
| **Activity / motion** | Gait, gestures, keystrokes, respiration inferred from channel dynamics (BeamSense-class) |
| **Communication utility** | The legitimate WiFi link must keep working (≥95% throughput bar) |
---
## 2. Adversary classes
| Class | Position | Capability | In VEIL scope? |
|---|---|---|---|
| **A1 — external passive sniffer** | Outside the trust boundary (adjacent room, van, hallway), monitor mode | Captures plaintext BFI/CSI for every station; runs BFId/LeakyBeam/BeamSense offline | **Primary target — yes** |
| **A2 — external active sensor** | Nearby, transmits its own probing/sounding to solicit measurable responses | Elicits sensing responses; 802.11bf "active" mode | **Partial** — cadence randomization + non-response policy help; full defense needs MAC-layer policy |
| **A3 — associated but curious AP** | Inside the link; the party VEIL shares keys with | Sees the un-rotated report by construction | **Out of scope** — this is BFLD's detection/privacy-class problem (ADR-118/141) |
| **A4 — supply-chain / firmware** | Compromised radio firmware | Can bypass any transmit-side control | Out of scope (integrity problem, not a waveform problem) |
| **A5 — physical / RF-denial** | Wants to *block* WiFi | — | Explicitly rejected: VEIL never jams |
VEIL's design centers on **A1**, the attacker the literature demonstrates and
the one no shipping product addresses.
---
## 3. What VEIL guarantees (and the evidence class)
1. **Cross-session identity unlinkability against A1.** Because the fine-subspace
signature is rotated by a fresh secret orthogonal transform each session, an
A1 attacker cannot average captures back to a stable per-person template.
*Evidence: SYNTHETIC — re-ID collapses from 100% to ~chance in the reference
experiment (`cargo test`); real-silicon witness is future work.*
2. **Communication preservation.** The transform is key-reversible by the
legitimate receiver, and acts only on the identity-bearing fine subspace, so
link throughput stays ≥95%. *Evidence: SYNTHETIC model + MEASURED external
corroboration (DySPAN 2026: fine-resolution feedback shaping is near-free).*
3. **Compliance.** The transform is orthogonal ⇒ energy-preserving ⇒ adds no
interfering emission ⇒ not jamming. *Evidence: machine-checked energy ratio =
1.000000 in the `compliance` module; statutory analysis in
[04-compliance-and-regulatory.md](04-compliance-and-regulatory.md).*
---
## 4. What VEIL does NOT do (non-goals, stated to prevent over-claiming)
- **It does not hide identity from the associated AP (A3).** That party holds the
session key. Protecting against a malicious AP requires detection and policy
(BFLD), not waveform shaping.
- **It is not RF denial or jamming.** It never degrades another station's link.
- **It does not, by itself, defeat within-session motion detection.** A single
session's rotation is fixed, so coarse presence/motion may still be inferable
within one capture window; sounding-cadence randomization mitigates but does
not eliminate this. Identity *re-ID* (the brief's metric) is the guaranteed
target; motion obfuscation is partial and tracked as future work.
- **It is not a camera-grade or medical-grade claim in any direction.**
- **It is not validated on hardware yet.** All quantitative defense results are
SYNTHETIC until a captured boot/runtime log exists (CLAUDE.md hardware rule).
---
## 5. Trust boundary
```
┌────────────────────── protected space ──────────────────────┐
│ │
│ [person] [person] legitimate STA ⇄ AP (VEIL) │
│ │ │ │ shares session key │
│ └──── RF ──────┘ │ rotates fine subspace│
│ reflections ▼ of its own BFI │
│ compliant, key-reversible, │
│ energy-preserving emission │
└───────────────────────────────────────┬──────────────────────┘
│ plaintext BFI on air
A1 external passive sniffer (monitor mode)
sees a freshly-rotated signature each session
→ cannot build a stable per-person template
→ re-identification → chance
```
The key never crosses the boundary to A1. The AP inside the boundary is trusted
for key-sharing (A3 out of scope). No emission crosses the boundary with intent
or effect of interfering with another station (A5 rejected).
@@ -0,0 +1,136 @@
# 03 — Countermeasure Design
How VEIL prevents unauthorized sensing with compliant waveform controls, and how
the design maps to [`v2/crates/wifi-densepose-privshield`](../../../v2/crates/wifi-densepose-privshield).
---
## 1. The separable-subspace principle
A compressed beamforming report is not homogeneous. Two blocks carry different
information:
- **Dominant beam direction (comm block).** The coarse steering the AP uses to
aim data at the client. It varies with position and traffic and carries **no**
stable identity. **Throughput rides here.**
- **Fine cross-subcarrier phase structure (fine block).** The high-order
multipath detail. It is *stable per person* across sessions and is what
re-identification exploits (BFId). **Identity leaks here.** Communication
barely uses it.
The whole design rests on this: **identity leakage and data throughput live in
(mostly) separable subspaces.** A transform confined to the fine block can wreck
re-identification while sparing the beam the link depends on. This is consistent
with the DySPAN-2026 MEASURED result that shaping fine-resolution feedback is
nearly free in throughput.
---
## 2. The four compliant waveform controls
VEIL alters "channel sounding, phase, or beam schedules" — exactly the levers the
brief names — all within the 802.11 waveform envelope:
| Control | What it varies | Purpose |
|---|---|---|
| **Keyed precoder rotation** (primary) | A fresh secret orthogonal transform of the *fine* subspace each session, composed from extra Givens rotations | Destroys cross-session identity linkage; energy-preserving; key-reversible |
| **Feedback quantization / dither** | Sub-step noise on reported φ/ψ angles | Adds report-level uncertainty; tunes the privacythroughput point via `feedback_bits` |
| **Sounding-cadence randomization** | Jitter on NDP sounding intervals | Under-samples motion for an eavesdropper; charged as the throughput overhead |
| **MU-group / stream-mapping shuffle** | Which STAs are grouped, stream-to-antenna mapping | Rotates the spatial signature over time |
All four modify the node's **own** standards-conformant frames. None adds energy
on top of another station (see [04](04-compliance-and-regulatory.md)).
---
## 3. Why the keyed Givens rotation is the right primitive
The compressed beamforming report is *already* a product of Givens rotations
(the φ/ψ angles). VEIL composes **additional keyed Givens rotations** over the
fine block. This choice gives three properties at once:
1. **Orthogonal ⇒ energy-preserving.** A Givens rotation preserves the vector's
L2 norm exactly. Composing many still preserves it. So the emission carries
the same power it always would — **no added energy, no interference, not
jamming.** The `compliance` module checks this: energy ratio = 1.000000.
2. **Keyed & reversible ⇒ throughput-preserving.** The legitimate AP/STA shares
the per-session key, derives the identical rotation schedule, and applies the
inverse (negated angles, reversed order) to recover the true precoder. It pays
only the tiny residual from quantizing the extra angles at `feedback_bits`
resolution — negligible across the 802.11 59-bit range — plus the sounding
overhead. (The throughput-optimal resolution is derived in
[08-optimization.md](08-optimization.md).)
3. **Fresh per session ⇒ unlinkable.** A different rotation each session means an
A1 sniffer sees `R_e · signature` for a new random `R_e` every time. Averaging
over sessions (the natural enrollment attack) drives
`mean_e(R_e · signature) → 0` for *every* identity, so all templates collapse
toward the origin and become indistinguishable — re-identification → chance.
This is the marginalized-mutual-information argument: over unknown rotations,
the signature carries no stable discriminative information.
This is the shared-secret precoding idea (cf. MIMOCrypt) specialized to the
identity-bearing subspace and unified around the report's native primitive.
---
## 4. Detect-then-act
Per the brief ("detect sensing activity and alter…"), VEIL need not perturb
continuously. The `SensingDetector` exposes the decision rule: when the observed
rate of sensing/NDP solicitations crosses a threshold, the control plane
(ADR-280) engages the shield. Continuous operation is also valid; gating just
saves the (already small) overhead when no sensing is present.
---
## 5. Module map
| Concept above | Crate module | Key items |
|---|---|---|
| Deterministic, WASM-safe randomness + keys | `prng` | `Rng` (SplitMix64), `fnv1a_64`, `derive_key` |
| Givens algebra, energy conservation | `linalg` | `apply_givens`, `norm`, `dist_sq` |
| SYNTHETIC two-subspace BFI model | `identity` | `SceneConfig`, `Channel`, `BfiSample` (`comm()`/`fine()`) |
| The four controls (shield) | `protector` | `ShieldConfig`, `Protector::protect`/`recover`, `SensingDetector` |
| Passive re-ID adversary | `attacker` | `NearestCentroidAttacker`, `Metric` |
| Privacythroughput tradeoff | `throughput` | `LinkModel::throughput_ratio`, `beamforming_residual`, `feedback_airtime` |
| "Not jamming" audit | `compliance` | `ComplianceReport::audit`/`is_compliant` |
| Attacker-vs-protector head-to-head | `experiment` | `ExperimentConfig`, `run`, `ExperimentReport` |
| Config hyper-optimization | `optimize` | `hyper_optimize`, `min_givens_passes`, `pareto_frontier` |
| Byte-stable deterministic witness | `proof` | `Proof::EXPECTED_WITNESS`, `Proof::witness` |
---
## 6. The privacythroughput knobs (and which the optimizer turns)
- **`feedback_bits`:** the only knob with a genuine throughput tradeoff —
residual falls with bits, feedback airtime rises with them, so there is an
interior optimum (3 bits unconstrained; 5 bits within the 802.11-allowed set).
Privacy is unaffected by bits (the rotation is fresh regardless).
- **`givens_passes`:** the privacy/robustness knob. More mixing lowers re-ID at
**no throughput cost** (the keyed rotation is never signaled), so it trades
only compute. The optimizer finds the minimum for robust collapse and ships a
free 2× margin.
- **`sounding_overhead`:** a flat throughput cost from cadence randomization;
trades motion-obfuscation strength against airtime (outside the re-ID metric).
The `optimize` module turns these knobs deterministically — see
[08-optimization.md](08-optimization.md). It is what replaced the original
hand-picked config.
The `throughput` module computes the ratio from these, so the tradeoff is
inspectable rather than asserted (`cargo test throughput`).
---
## 7. Honest limitations of the model
- The two-subspace split is an abstraction; on real hardware comm and identity
information are only *approximately* separable, so the real throughput cost of
fully hiding identity may be higher than the model's ~2%. The DySPAN-2026
MEASURED curve is the external sanity check that it is *small* at fine
resolution, not zero.
- The nearest-centroid attacker is deliberately simple. The collapse argument is
classifier-independent (it is about the signal, not the model), but a hardware
study must confirm a strong learned attacker also collapses.
- Within-session motion is not addressed by the rotation alone (see threat
model §4).
@@ -0,0 +1,90 @@
# 04 — Compliance and Regulatory Line
**Non-negotiable:** VEIL uses compliant waveform controls and **never jams.**
This file states the legal basis for that line and why every VEIL control falls
on the compliant side of it. It is engineering analysis, not legal advice; a
deployment in a given jurisdiction needs its own regulatory review.
---
## 1. The statutory line (United States)
The prohibition is on **interfering with others' transmissions**, not on how you
shape **your own** signal.
| Authority | What it prohibits |
|---|---|
| **47 U.S.C. §333** | *Willful or malicious interference* with any licensed/authorized radio station or U.S. Government station |
| **47 U.S.C. §302a(b)** | Manufacture, import, marketing, sale, or *operation* of non-compliant devices (jammers cannot be certified — their sole purpose is interference) |
| **47 U.S.C. §301** | Requires a license/authorization to transmit; a jammer can never be authorized |
| **47 U.S.C. §501 / §503** | Criminal penalties and forfeitures; FCC cites fines up to $112,500 per violation, **no exemptions** for business/residence/vehicle |
The distinguishing element of jamming is **intent to interfere plus effect on a
third party's link.** A device that shapes its own standards-conformant emission
— staying within transmit-power and spectral-mask limits, still type-certifiable
— is not a jammer.
---
## 2. Why each VEIL control is compliant
| Control | Compliance argument |
|---|---|
| **Keyed precoder rotation** | Orthogonal ⇒ preserves the report's energy exactly ⇒ **adds no power on top of anyone's signal.** It is still a valid precoder within the 802.11 feedback format. Machine-checked: energy ratio = 1.000000 (`compliance` module) |
| **Feedback quantization / dither** | Reports angles the standard already allows, at the standard's resolution; sub-step dither stays within the quantization envelope. No emission change beyond the node's own frame |
| **Sounding-cadence randomization** | Chooses *when* the node sends its own NDP soundings, within permitted timing. Sending fewer/jittered soundings never interferes with another station |
| **MU-group / stream-mapping shuffle** | Rearranges the node's own spatial mapping; a normal in-spec transmit choice |
None of the four transmits *to prevent* another station from communicating; none
adds out-of-mask energy; each passes normal type certification. Contrast a
jammer, whose defining purpose is to emit energy that denies others service.
---
## 3. The energy-conservation proof as a compliance artifact
VEIL turns "not jamming" from a promise into a **checked property.** The
`compliance::ComplianceReport` audits each protection step:
```
input_energy = ‖report_before‖²
output_energy = ‖report_after‖²
energy_ratio = output_energy / input_energy # ≈ 1.0 for a rotation
energy_conserving = |energy_ratio 1| ≤ 1e-2
adds_interfering_energy = false # by construction
is_compliant = energy_conserving ∧ ¬adds_interfering_energy
```
A regulator, an auditor, or the runtime attestation layer (ADR-141) can read the
report and verify the shield is a waveform-shaping control, not an interference
source. On the reference experiment the measured ratio is **1.000000**.
---
## 4. Jurisdictional notes
- **EU (GDPR framing).** Covert WiFi body-sensing of vital signs is sensitive
health data and "almost certainly illegal under GDPR," but effectively
unenforceable (receivers are undetectable) — which is precisely why a
*technical* control is needed. VEIL as a transmit-side control does not itself
raise GDPR issues; it reduces the personal data an attacker can derive.
- **RF-emission rules are jurisdiction-specific.** The energy-preserving property
is the portable core of the compliance argument, but power/mask/timing limits
differ by region and band; a deployment must confirm local rules.
- **Deliberate transmit-nulling toward a *located* sniffer** (steering a spatial
null at a known passive receiver) is still the node's own emission and adds no
interference, but is more aggressive and should get explicit regulatory review
before field use. It is not part of the default VEIL profile.
---
## Sources
- 47 U.S.C. §333: https://www.law.cornell.edu/uscode/text/47/333
- 47 U.S.C. §302a: https://www.law.cornell.edu/uscode/text/47/302a
- FCC Jammer Enforcement: https://www.fcc.gov/general/jammer-enforcement · https://www.fcc.gov/enforcement/areas/jammers
- FCC Cell/GPS Jamming guidance: https://www.fcc.gov/general/cell-phone-and-gps-jamming
- FCC 14-92 enforcement order: https://docs.fcc.gov/public/attachments/FCC-14-92A1.pdf
*Caveat: FCC pages were cross-verified against Cornell LII; this is engineering
analysis, not legal advice.*
@@ -0,0 +1,113 @@
# 05 — Experiment Protocol: Attacker vs. Protector
This is the "start today" deliverable from the brief: **make one RuView node the
attacker and one the protector, and measure whether protection drives identity
recognition toward chance while keeping throughput above 95%.** It is realized as
a deterministic, reproducible experiment in
[`v2/crates/wifi-densepose-privshield`](../../../v2/crates/wifi-densepose-privshield).
Because it runs on **SYNTHETIC** data (no radio is touched), its numbers describe
the model, not real hardware — reproduced by `cargo test`, and to be
re-established on silicon with a captured log before any deployment claim.
---
## 1. Setup
- **Protector node.** Emits beamforming feedback shaped by the VEIL controls
(keyed per-session fine-subspace rotation + configured feedback resolution and
sounding overhead). Models a legitimate AP/STA protecting a room.
- **Attacker node.** A passive sniffer that enrolls a template per candidate from
captured reports, then classifies fresh captures (nearest-centroid) — the
BFId-class re-identification threat.
- **Scene.** `SceneConfig` default: 64-dim report, 8 comm dims, **16 candidate
identities** (chance = 1/16 = 6.25%), per-identity stable fine-block signature
+ per-session environmental nuisance.
Two runs of the attacker are compared: **shield off** (the attacker sees raw
reports) and **shield on** (every captured report is VEIL-protected). The same
attacker faces both.
---
## 2. Metrics and acceptance bar
| Metric | Definition | Bar |
|---|---|---|
| **Re-ID accuracy, shield off** | Top-1 identity accuracy on unprotected traffic | Must be well above chance (threat is real) — bar ≥ 0.5 |
| **Re-ID accuracy, shield on** | Top-1 identity accuracy on protected traffic | Must fall into the chance band `1/N · 2 + 0.03` |
| **Throughput ratio** | Protected link capacity ÷ baseline capacity | **≥ 0.95** |
| **Compliance** | Emission energy ratio ≈ 1 and non-interfering | `is_compliant == true` |
Overall `passed()` requires all four.
---
## 3. Results (SYNTHETIC, hyper-optimized default configuration)
Reproduce with `cargo test -p wifi-densepose-privshield` (all 35 tests + doctest
pass). The default shield config is the `optimize` module's output — 96 Givens
passes at 5-bit feedback resolution (see
[08-optimization.md](08-optimization.md)). Salient values from the reference run:
| Metric | Value |
|---|---|
| Candidate identities | 16 |
| Chance level | 6.25% |
| Chance band (acceptance) | ≤ 15.5% |
| **Re-ID accuracy, shield OFF** | **100.0%** |
| **Re-ID accuracy, shield ON** | **4.7%** |
| **Throughput ratio** | **97.60%** |
| Emission energy ratio | 1.000000 |
| Overall verdict | **PASS** |
Reading the result: the attacker is a *perfect* re-identifier without protection
(the synthetic signatures are cleanly separable), and VEIL drives it *to the
chance floor* (4.7% sits just below the ideal 6.25%, i.e. no better than
guessing) — while the modeled link keeps 97.6% of its throughput and the
emission conserves energy exactly (compliant, not jamming). The same collapse
holds under a Cosine-metric attacker and at N=32, confirming it is a property of
the signal, not the classifier.
---
## 4. Determinism and the witness
The experiment is byte-reproducible: no OS entropy, no wall-clock, no threads.
`proof::Proof` folds the salient outputs (quantized to avoid last-bit f32
round-off) into an FNV-1a witness pinned as `EXPECTED_WITNESS`. Any drift in the
PRNG stream, rotation schedule, throughput formula, or scene geometry changes the
witness and fails `witness_matches_pinned`. This is the same
deterministic-proof discipline as `nvsim` and the Python `verify.py`.
---
## 5. Sensitivity and what to vary next
`ExperimentConfig` exposes the levers for a fuller study:
- **`scene.identities`** — larger N lowers the chance floor; confirm collapse
holds as candidates grow.
- **`scene.env_sigma` / `beam_amplitude`** — nuisance and comm energy; stress the
separability assumption.
- **`shield.feedback_bits`** — trace the privacythroughput curve (the
`throughput` tests already show coarse resolution costs more).
- **`shield.givens_passes`** — mixing strength; fewer passes should degrade the
collapse gracefully.
- **Stronger attacker** — swap in a learned classifier to confirm the collapse is
signal-level, not classifier-level (the argument says it must be, but a
hardware study should verify).
---
## 6. Path to a real two-node measurement
The synthetic experiment is the design proof. The hardware path (per CLAUDE.md,
requires a captured log to claim MEASURED):
1. Two ESP32-S3/C6 or Nexmon-capable nodes: one runs Wi-BFI capture (attacker),
one runs a VEIL-shaped feedback profile (protector).
2. Enroll and test the same BFId-style classifier on captured BFI, shield off vs.
on; log throughput via iperf across the legitimate link.
3. Success = the same shape as §3 on real captures, with the boot/runtime log as
the witness. Until then, all defense numbers remain SYNTHETIC.
@@ -0,0 +1,92 @@
# 06 — Market and Buyers
Facts are tagged **VERIFIED** (from a cited source), **CLAIMED** (asserted by a
vendor/analyst/press source), or **SPECULATIVE** (our inference). Market figures
are third-party projections, not independent measurements.
---
## 1. Why now
- **The threat is standardized and commercializing (VERIFIED/CLAIMED).** IEEE
802.11bf was published Sep 2025; silicon (Infineon AIROC Wi-Fi 7 ACW741x,
Qualcomm Dragonwing) lists 802.11bf sensing in 2026 briefs; Origin AI's
embedded-sensing program targets late-2026 deployment; Plume/Cognitive Systems
WiFi Motion is the largest deployed sensing footprint today.
- **The standards body declined to fix privacy (VERIFIED).** The BFI
"secure transmission mechanism" proposal (802.11-23/0782) was **withdrawn**;
802.11bf shipped with no privacy protections. This is the strongest demand
signal — the gap is structural and acknowledged.
- **No targeted anti-sensing product ships (VERIFIED by absence).** Every
countermeasure (IRShield, PhyCloak, MIMOCrypt, DP-Givens, ScatterShield) is
research-stage. The claim "no obvious shipping product protects rooms from this
inference" **holds** as of 2026, with one caveat below.
---
## 2. First buyers, ranked by procurement readiness
| Segment | Driver | Readiness |
|---|---|---|
| **Defence / government** | ICD 705 / DoD EMSEC already mandate RF attenuation in classified spaces; budgets and mandates exist | **Strongest beachhead (VERIFIED)** — but today they buy broadband shielding, not a sensing-specific control |
| **Corporate boardrooms / counter-espionage** | TSCM firms (Bastille, Murray Associates) now include WiFi audits and rogue-AP detection; CSI keystroke/gesture inference makes a boardroom shield a natural extension | **VERIFIED demand, EMERGING WiFi-specific** |
| **Hospitals** | RF-derived behavioral/vital data is HIPAA PHI; exam rooms, psychiatric units where inference is unwanted | **VERIFIED regulatory hook** — but the hook drives privacy-preserving *sensing* more than a *shield* |
| **Hotels** | Documented guest backlash against in-room sensors; privacy as differentiation | **SPECULATIVE** — narrative-led, not procurement-led today |
| **Router / AP manufacturers** | Ship opt-out/obfuscation as a firmware feature anticipating regulation | **SPECULATIVE** — no vendor has announced this |
---
## 3. Competitive landscape
- **Direct competitors:** none shipping. All targeted anti-sensing is academic.
- **The real substitute (VERIFIED):** broadband RF shielding — SCIF/TEMPEST
window film, paint, panels (Signals Defense SD2500: >40 dB, 30 MHz6 GHz, ICD
705 / ASTM F3057-14). It defeats WiFi sensing as a side effect but is **blunt**:
it kills *all* RF and cannot coexist with wanted WiFi.
- **TSCM services (VERIFIED):** detect, don't prevent.
**VEIL's differentiation** is exactly what the substitute lacks: **selective and
coexisting** — it removes identity/activity leakage while keeping the room's WiFi
working at ≥95% throughput, with a machine-checkable compliance artifact.
---
## 4. Market size (third-party projections, cite with care)
- **CLAIMED:** ABI Research — North American WiFi-sensing-compatible CPE install
base to **112M by 2030 (51.6% CAGR)**.
- **CLAIMED:** Global WiFi sensing market ~$402M (2024) → ~$2.13B (2033)
(MarketIntelo).
Implication: a shield must **coexist** with a large installed sensing base, not
assume RF denial — reinforcing the selective-coexistence positioning.
---
## 5. Where VEIL fits RuView's positioning
VEIL pairs with BFLD to make RuView the *both-sides* RF-perception platform:
BFLD/AETHER do sensing responsibly and detect leakage; VEIL is the customer-
facing **privacy firewall** that protects a room from *others'* sensing. That is a
defensible, standards-anchored, gap-filling story: the standards body left the
door open, the threat is shipping, and no one else sells the selective lock.
---
## Sources
- IEEE 802.11bf privacy-proposal withdrawal (802.11-23/0782), summarized: https://pascalpiron.substack.com/p/wifi-sensing-and-the-privacy-fix
- NIST 802.11bf: https://www.nist.gov/publications/ieee-80211bf-enabling-widespread-adoption-wi-fi-sensing
- IRShield: https://arxiv.org/abs/2112.01967 · MIMOCrypt: https://arxiv.org/pdf/2309.00250 · ScatterShield: https://dl.acm.org/doi/abs/10.1145/3770653 · WiShield JSAC 2024: https://dl.acm.org/doi/abs/10.1109/JSAC.2024.3414597
- Signals Defense TEMPEST/SCIF film: https://signalsdefense.com/tempest-and-scif-design/ · https://signalsdefense.com/shielding-films/
- National Shielding SCIF/ICD-705: https://www.national-shielding.com/pages/scif-icd-705-secure-facility-shielding
- Bastille TSCM: https://bastille.net/centers-of-excellence/tscm/ · IntellSIG TSCM overview: https://www.intellsig.com/2025/07/20/modern-eavesdropping-threats-a-tscm-overview/
- Origin AI program: https://www.prnewswire.com/news-releases/origin-ai-launches-compatible-with-origin-program-to-meet-industry-demand-for-scalable-wifi-sensing-and-accelerate-integration-across-global-soc-platforms-302650963.html
- MIT Tech Review, WiFi sensing: https://www.technologyreview.com/2024/02/27/1088154/wifi-sensing-tracking-movements/
- ABI Research 112M forecast: https://www.abiresearch.com/press/north-american-wi-fi-sensing-cpe-installations-to-surge-to-112-million-by-2030-as-the-technologys-maturing-unleashes-new-business-and-service-models
- MarketIntelo WiFi sensing market: https://marketintelo.com/report/wi-fi-sensing-market
- HIPAA/PHI RF-sensing context (PMC): https://pmc.ncbi.nlm.nih.gov/articles/PMC11939480/
*Caveat: market figures are analyst/vendor projections; the "no shipping product"
finding reflects absence of evidence in these searches and should be confirmed
with a patent/vendor scan before anchoring a go-to-market claim.*
@@ -0,0 +1,117 @@
# 07 — Implementation and Roadmap
---
## 1. What ships in this bundle
- **Reference crate** `v2/crates/wifi-densepose-privshield` (VEIL): a
deterministic, dependency-free, WASM-ready pure-compute leaf implementing the
full attacker-vs-protector experiment, the four compliant controls, the
throughput model, the compliance audit, the `optimize` hyper-optimizer, and a
byte-stable proof. 35 tests + doctest pass; builds for
`wasm32-unknown-unknown`; clippy-clean.
- **This research bundle** (`docs/research/privacy-shield/`).
- **[ADR-288](../../adr/ADR-288-veil-privacy-shield-compliant-waveform.md)** — the
formal decision record.
- **npm metaharness** `harness/wifi-densepose-privshield/`
([ADR-289](../../adr/ADR-289-wifi-densepose-privshield-harness-via-metaharness.md))
— a per-crate contributor harness (architect/implementer/reviewer/test-writer,
router, flywheel) with a dependency-free `guidance` surface that serves this
bundle's capability map. `npx wifi-densepose-privshield-harness guidance
--topic optimization`.
The crate is intentionally a **leaf with no internal RuView dependencies**
(mirrors `wifi-densepose-aether`), so it can be reasoned about, fuzzed, and
ported independently, and so it can never accidentally acquire a path to a radio.
---
## 2. Reuse map (how VEIL composes with existing RuView)
| Existing subsystem | Relationship |
|---|---|
| **BFLD** (ADR-118/120/121, `wifi-densepose-bfld`) | Detection layer. Its `identity_risk_score` is the natural trigger for VEIL's `SensingDetector` — detect leakage, then shield |
| **Privacy control plane** (ADR-141) | VEIL protection steps emit `ComplianceReport`s that fit the runtime-attestation model (which mode, which actions, which fields) |
| **Active sensing / governed actuation** (ADR-280) | VEIL is a defensive `SensingAction`: a governed, privacy-ceiling-bounded emission-shaping action the control plane can schedule |
| **Givens/beamforming primitives** | VEIL reuses the report's native Givens-rotation structure rather than inventing a new transform |
| **Deterministic proof discipline** (`nvsim`, `archive/v1/verify.py`) | VEIL's `proof` module follows the same pinned-witness pattern |
---
## 3. Phased rollout
| Phase | Deliverable | Evidence class |
|---|---|---|
| **P1 — reference model (this PR)** | Crate + experiment + docs + ADR | SYNTHETIC (cargo test) |
| **P2 — sensitivity study** | Sweep N, noise, resolution, mixing; add a learned attacker to confirm signal-level collapse | SYNTHETIC |
| **P3 — BFLD integration** | Wire `identity_risk``SensingDetector` → shield engage; emit attestation | SYNTHETIC + integration tests |
| **P4 — firmware feedback shaping** | Implement keyed fine-subspace rotation + cadence randomization in the **beamforming-feedback / spatial-mapping path** — see §3.1 for the (non-trivial) platform reality | build + hardware |
| **P5 — two-node hardware measurement** | Attacker (Wi-BFI capture) vs. VEIL protector on real silicon; iperf throughput; captured log | **MEASURED** (with witness) |
| **P6 — deployment profiles** | Per-segment profiles (SCIF, boardroom, ward) with regulatory review | operational |
No defense claim graduates from SYNTHETIC to MEASURED without a captured
boot/runtime log (CLAUDE.md hardware rule).
### 3.1 Does this need custom WiFi firmware? (yes — and ESP32 is the wrong chip for the protector)
VEIL shapes the **compressed beamforming report** (the Givens φ/ψ angles) or the
LTF **spatial mapping** as it is transmitted — machinery that lives *below* the
driver, inside the chip's PHY/MAC firmware. It is **not** reachable from user
space, so a real deployment is a firmware/driver change, not an app.
- **ESP32 — not viable as the protector.** Its WiFi lower layers are a closed
Espressif blob. ESP-IDF exposes CSI *read* (`esp_wifi_set_csi`) — which is why
`firmware/esp32-csi-node/` makes a great **attacker/sensor** node — but it does
**not** let you rewrite how the chip builds/sends beamforming feedback. ESP32
is the *attacker* in a testbed, not the shield.
- **Realistic protector platforms:** **openwifi** (open 802.11 on SDR/FPGA —
full PHY/MAC control incl. the AP-side compensation; the honest end-to-end
route; Verilog + a C driver); **Nexmon** (C firmware *patches* for
Broadcom/Cypress, e.g. RPi BCM43455 — the commodity path, and the same
framework the BFI *attack* tools already use); open drivers (**ath9k/mt76**)
for partial control; or **vendor firmware** for a production feature.
- **Two firmware variants:** the **keyed-reversible** version (VEIL's ~98%
throughput) needs changes on **both** ends plus key agreement (cf. the
LeakyBeam AP-side `Q_obf` is *client-transparent* — only the AP changes — which
is a deployment advantage worth adopting, §09 backlog item 3); the
**emitter-only DP dither** version needs only the reporting device but pays the
full throughput cost.
The current crate is deliberately a std-only, no-radio leaf and implements none
of this; P4 is where it meets silicon.
---
## 4. Open problems (tracked honestly)
1. **Real-hardware separability.** Comm and identity information are only
*approximately* separable on real radios; the true throughput cost of full
identity hiding may exceed the model's ~2%. P2/P5 must bound it.
2. **Within-session motion leakage.** A fixed per-session rotation does not
obfuscate coarse motion within one capture window. Needs stronger cadence
randomization or amplitude shaping; currently a stated non-goal for the re-ID
metric.
3. **Active adversary (A2).** An attacker that transmits its own soundings is
only partially addressed by cadence control; a MAC-layer non-response policy
is needed.
4. **Key management.** The per-session rotation key must be derived from the
negotiated link secret; VEIL's PRNG is explicitly *not* cryptographic and must
not be used for real key material.
5. **Regulatory review per jurisdiction.** The energy-conservation argument is
portable, but power/mask/timing limits and any transmit-nulling profile need
local review before field use.
---
## 5. Validation commands
```bash
# Reference experiment + all unit/proof/doc tests
cargo test -p wifi-densepose-privshield --no-default-features
# WASM portability (leaf builds with no radio path)
cargo build -p wifi-densepose-privshield --target wasm32-unknown-unknown
# Lints
cargo clippy -p wifi-densepose-privshield --all-targets
```
@@ -0,0 +1,142 @@
# 08 — Hyper-Optimization
The reference crate first shipped a **hand-picked** shield config (112 Givens
passes, 7-bit feedback). This file records how the `optimize` module replaces
that guess with a *derived*, robustness-verified optimum, and what it found. All
numbers are **SYNTHETIC / L0**, reproduced by
`cargo test -p wifi-densepose-privshield`.
---
## 1. What is being optimized, and against what
Two knobs, two objectives, one hard constraint:
| Knob | Costs | Does it trade against privacy? |
|---|---|---|
| `feedback_bits` (angle resolution) | Throughput: **residual** falls with bits, **feedback airtime** rises with bits | No — the keyed rotation is applied regardless of resolution |
| `givens_passes` (rotation mixing) | Compute only | Yes — more mixing ⇒ lower re-ID |
**Constraint:** re-ID must collapse into the chance band `1/N · 2 + 0.03` — and
it must do so *robustly*: for **both** attacker metrics (Euclidean and Cosine)
and **both** identity counts (N = 16 and N = 32, the harder, lower-chance case).
The key structural fact: **rotation mixing is throughput-free.** The per-session
rotation is derived from the shared link secret on both ends (like MIMOCrypt) —
it is never transmitted — so extra Givens passes cost compute, not airtime. That
means privacy margin is essentially free; the only throughput tradeoff lives in
`feedback_bits`.
---
## 2. Throughput is a 1-D problem with an interior optimum
Because the residual falls with bits while feedback airtime rises, throughput
has a genuine interior optimum in `feedback_bits` (`LinkModel`, default SNR 20 dB,
`feedback_overhead_per_bit = 0.0008`):
| bits | throughput ratio |
|---|---|
| 1 | 0.9681 |
| 2 | 0.9757 |
| **3** | **0.9769** ← unconstrained optimum |
| 4 | 0.9766 |
| **5** | **0.9760** ← shipped (spec-allowed) |
| 7 | 0.9744 (the old hand-picked value) |
| 9 | 0.9728 |
| 12 | 0.9704 |
The unconstrained optimum is **3 bits** — which coincides with the DySPAN-2026
MEASURED finding that ~3-bit feedback is the privacyutility sweet spot, because
the receiver compensates the keyed rotation and extra bits mostly buy airtime.
802.11 compressed beamforming quantizes ψ/φ to roughly 59 bits, so the shipped
shield uses the throughput-best **spec-allowed** value, **5 bits** (0.9760),
rather than the out-of-spec 3-bit optimum. Either way it beats the old 7-bit
choice.
---
## 3. Mixing: the minimum robust budget, and a free margin
Worst-case shield-on re-ID vs. `givens_passes` (bits = 5; worst over Euclidean
and Cosine):
| passes | re-ID @ N=16 | re-ID @ N=32 | robust collapse? |
|---|---|---|---|
| 16 | 0.75 | 0.62 | no |
| 24 | 0.50 | 0.35 | no |
| 32 | 0.20 | 0.14 | no (N=32 band is 0.0925) |
| **48** | 0.12 | 0.057 | **yes** ← proven minimum |
| 64 | 0.078 | 0.044 | yes |
| **96** | **0.047** | **0.018** | **yes** ← shipped (2× margin) |
| 112 | 0.078 | 0.042 | yes (the old default — no better than 96) |
The proven minimum for robust collapse is **48 passes** — the hand-picked 112 was
**2.3× over-provisioned**. Since mixing is throughput-free, the shield ships
**96 passes** (`PRIVACY_MARGIN_FACTOR = 2` × 48, rounded up to a candidate): it
drives re-ID *below chance* at N=16 (0.047 < 0.0625) at zero throughput cost, and
is still cheaper compute than the original 112.
---
## 4. The adopted config, and why it beats the original
| | Old (hand-picked) | Hyper-optimized (shipped) |
|---|---|---|
| Givens passes | 112 | **96** (from proven-min 48 × 2) |
| Feedback bits | 7 | **5** (spec-optimal) |
| Shield-on re-ID (N=16) | 0.078 | **0.047** |
| Throughput ratio | 0.9744 | **0.9760** |
| Robust across metrics & N | not checked | **verified** |
The optimum is **strictly better on privacy and throughput at once**, and is now
*verified* rather than assumed. `ShieldConfig::default()` is exactly the
optimizer's output; the test `optimize::shipped_default_equals_optimizer_output`
fails if they ever drift apart.
---
## 5. The Pareto frontier (and an honest note)
`optimize::pareto_frontier` enumerates non-dominated (worst-case re-ID,
throughput) points over a pass × bits grid. In this model the frontier
**collapses toward the max-mixing, 5-bit point**, because mixing is
throughput-free — so beyond the throughput knob (bits) there is no privacy
throughput tradeoff to trace. That degeneracy is itself the finding: *the only
thing privacy costs here is feedback resolution, and even that is cheap.* On real
hardware, where comm/identity subspaces are only approximately separable and
where more aggressive mixing may touch the data-carrying beam, this frontier is
expected to open up — a hardware study (roadmap P5) will re-measure it.
---
## 6. Per-deployment adaptivity
The optimum is not one number — `optimize` derives it per deployment:
- **SNR → feedback resolution.** `optimal_bits_across_snr` shows the
*unconstrained* throughput-optimal resolution shifting with SNR: **4 bits at
510 dB, 3 bits at 2040 dB** (low SNR values fine resolution more because
the Shannon capacity is near-linear there, so the residual costs more). Within
the spec-allowed {5,7,9} set the choice is 5 bits across this whole range —
the residual is already negligible at 5 bits — which is why the shipped shield
is SNR-stable.
- **Identity count → mixing.** `adaptive_shield(base, n)` derives the config for
a room with `n` expected occupants. A notable finding: in this model the
collapse budget is **N-independent** (min 48 passes collapses N∈{8,64}
alike), because a well-mixed Haar-like rotation destroys per-identity
structure regardless of how many identities there are — the budget is set by
the fine-subspace dimension, not the candidate count. So `adaptive_shield`
returns the same 96/5 across that range: the default is robust, not a point
tuning.
Both are surfaced through the harness `guidance --topic optimization`.
## 7. Robustness caveats (unchanged from the threat model)
- The collapse is verified against two classifiers and two N; a learned
attacker on real captures must still be checked (P2/P5).
- `feedback_bits` affects only throughput in this model, not re-ID; on hardware,
coarse quantization also adds obfuscation, which would *help* privacy — the
model conservatively ignores that.
- All optimization results are SYNTHETIC until a hardware witness exists.
@@ -0,0 +1,151 @@
# 09 — SOTA Update (20252026) and VEIL Improvement Backlog
Source: a fan-out deep-research run (5 angles → 20 primary sources → 93 claims →
top 25 adversarially verified with 3-vote panels → 24 confirmed, 1 refuted).
Each finding carries its **evidence class** (`MEASURED` with metric / `CLAIMED`
/ `SYNTHETIC` / `STANDARDS-MINUTE`) and a primary URL. This file records what
changed in the field and the concrete backlog it implies for VEIL (ADR-288/289).
Nothing here upgrades VEIL's own numbers to `MEASURED` — that still requires a
captured hardware log (CLAUDE.md).
---
## 1. The threat surface got worse (and cheaper)
| Finding | Evidence | Source |
|---|---|---|
| **BFId** — first *identity* inference from plaintext BFI: **99.5% over 197 people**, perspective/gait-independent; BFI carries ~740 features vs 212 for CSI, so it *beats* CSI for identity; one eavesdropper captures BFI from all clients | `MEASURED` (top-1, N=197, CCS 2025) | [dl.acm.org/10.1145/3719027.3765062](https://dl.acm.org/doi/10.1145/3719027.3765062) |
| **LeakyBeam** — through-wall occupancy at **20 m** (TPR 82.7% / TNR 96.7%) **and breathing/vital-sign** leakage from *stationary* occupants; single antenna, Wireshark, no keys | `MEASURED` (NDSS 2025) | [ndss 2025-5](https://www.ndss-symposium.org/wp-content/uploads/2025-5-paper.pdf) |
| **WiKI-Eve / SThief** — keystroke & PIN/password theft from BFI (88.9% per-keystroke; 65.8% top-10 app passwords; POS keypads) with no device compromise | `MEASURED` (CCS 2023 / IEEE) | [WiKI-Eve](https://dl.acm.org/doi/10.1145/3576915.3623088) · [SThief](https://ieeexplore.ieee.org/document/10621321/) |
| **BFIAttack****reconstructs full CSI from sniffed BFI**: closed-form ≥93% (single-antenna, 1 attempt); MLE with physics/standard constraints 73% (multi-antenna, ≤5 attempts). Collapses the BFI-vs-CSI distinction | `MEASURED` (arXiv Apr 2026) | [arxiv 2604.04179](https://arxiv.org/html/2604.04179v1) |
| **BeamSense** — BFI sensing is standards-compliant, needs no firmware mod, ~10% higher activity accuracy than CSI | `MEASURED` | [BFISense/BeamSense](https://www.researchgate.net/publication/402468114_BFISense_Using_Beamforming_Feedback_Information_for_Wi-Fi_Sensing) |
**Implication:** the attacker is a *passive, keyless, single commodity antenna at
~20 m, through walls*, that can (a) identify people, (b) read vitals and
keystrokes, and (c) **reconstruct CSI from the BFI itself.** VEIL's threat model
must treat all four as baseline.
---
## 2. Defenses — the field validates VEIL's family and adds stronger primitives
| Defense | Mechanism | Effect | Evidence | Source |
|---|---|---|---|---|
| **LeakyBeam defense** | AP-side **per-packet random unitary** `Q_obf` on the LTF via the 802.11 spatial-mapping mechanism (standard says "not restricted"); AP recovers `V = Q_obf · V_obf`; **clients unmodified** | attack **89.7% → ~51%** across 8 APs (~1.6M packets/49 h) | `MEASURED` | [ndss 2025-5](https://www.ndss-symposium.org/wp-content/uploads/2025-5-paper.pdf) |
| **PrivISAC (RIS)** | Paired per-row RIS vectors, one randomly active per slot; preserves comm-direction response, corrupts sensing direction; time-domain mask/demask for the authorized RX | **93% → ~30%**, and **29% vs. retrained 5-location adaptive attacker** | `MEASURED` (64-element FPGA RIS, Intel 5300, ~2,700 OTA samples) | [arxiv 2601.04488](https://arxiv.org/html/2601.04488) |
| **DP-Givens** | ε-DP stochastic quantizer on the Givens rotation/phase angles; closed-form angular sensitivity → principled ε budget; preserves 802.11 feedback structure | frontier: attacker error 19% → ~73%; beamforming gain 0.97 → 0.89 median (0.54 at full) | `SYNTHETIC` (Monte-Carlo) | [arxiv 2512.18529](https://arxiv.org/pdf/2512.18529) |
| **Adaptive-DP (CSI spectrogram)** | Importance-weighted (non-uniform) DP budget across the time-frequency plane | better privacy-utility than flat noise at equal ε∈[0.5,2]; cuts identity + membership inference | `CLAIMED` (unrefereed) | [arxiv 2512.20323](https://arxiv.org/abs/2512.20323) |
| **BeamDancer** | Randomized native-beamforming obfuscation | defeats supervised + unsupervised localization and micro-Doppler; **compliant, not jamming** | `MEASURED` (IEEE TWC 2024) — **do NOT cite its ">96% PDR" (refuted here)** | [ieee 10739908](https://ieeexplore.ieee.org/document/10739908/) |
| **TX-side CSI obfuscation (+ counter-attacks)** | Filter the whole frame incl. LTS; DNN de-obfuscation for authorized sensing | **security contested**: "Defeating CSI obfuscation" + SnoopFi FIA/CRA recover the signal | `CLAIMED` design + published rebuttal | [C&S 2025](https://www.sciencedirect.com/science/article/abs/pii/S0167404825002834) |
**Where VEIL sits:** VEIL's keyed Givens rotation is the *same family* as the
LeakyBeam per-packet unitary and the DP-Givens knob — and unlike additive/DP
dither, VEIL's transform is **secret and orthogonal**, which is exactly the
property that should resist the BFIAttack closed-form/MLE inversion (the attacker
has no key, so there is no closed-form to invert to). That is now the decisive
claim to *test*, not assume.
---
## 3. Compliance / legal line
- **BeamDancer (IEEE TWC 2024)** is the peer-reviewed precedent for VEIL's
stance: **jamming and geofencing are non-compliant / non-scalable; exploiting
the standard beamforming mechanism stays 802.11-compliant** (validated without
disabling firmware). Cite it as the compliance precedent — but **not** its
refuted throughput figure.
- **Governance gap (unfilled):** *no* claim on the 802.11bf-2025 standard's
privacy provisions, the withdrawn secure-LTF-from-11az proposal, or
GDPR/HIPAA/EMSEC/ICD-705 boundaries **survived 3-vote verification** in this
run. Blog/secondary sources assert a withdrawn privacy proposal, but it needs
primary WG-minute/draft sourcing before VEIL relies on it. Tracked as an open
question.
---
## 4. VEIL improvement backlog (derived, prioritized)
Priority = (verified severity) × (fit to VEIL). `[code]` = crate change,
`[docs]` = documentation, `[hw]` = hardware path.
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 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
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).
6. **`[docs]` Threat-model upgrade.** Elevate identity/gait re-ID, through-wall
vitals, keystroke/PIN, and **BFI→CSI reconstruction** to primary threats in
ADR-288 §threat and bundle 02; add the passive/keyless/20 m/through-wall
adversary as the default. *(done in this update)*
7. **`[docs]` Security honesty.** State that VEIL's shield security is `CLAIMED`
until it survives published de-obfuscation attacks (SnoopFi / "Defeating CSI
obfuscation"); add learned de-obfuscation to the attacker roadmap.
8. **`[code/docs]` Evaluation battery.** Adopt BeamDancer's three-attacker matrix
(supervised localizer + unsupervised clusterer + model-based Doppler) as a
minimum test set, plus identity + membership-inference metrics.
9. **`[hw]` Hardware-validation path.** Mirror the RIS/8-AP OTA testbeds for P5.
**Correction:** ESP32 is an *attacker/sensor* node only (its WiFi lower layer
is a closed blob exposing CSI *read*, not TX-feedback shaping); the protector
needs **openwifi (SDR/FPGA), Nexmon (C firmware patches), or vendor
firmware** + key agreement for the keyed-reversible version. See roadmap §P4.
10. **`[docs]` Governance sourcing.** Fill the 802.11bf privacy-provision gap
with primary WG minutes/draft; scope FCC Part 15, GDPR/HIPAA (inferred
biometric/health), and EMSEC/ICD-705 deployability.
---
## 5. Open questions the evidence did not close
- Does VEIL's obfuscation degrade **CSI *reconstructed* from BFI** (BFIAttack),
or only raise raw-BFI feature noise? *(the decisive effectiveness question)*
- What is VEIL's **own MEASURED** privacythroughput frontier on silicon (the
only measured PDR number in the field was refuted; the DP curves are
simulation-only)?
- Does 802.11bf-2025 contain any privacy provision or a withdrawn one, and what
are the concrete FCC/GDPR/HIPAA/ICD-705 deployment boundaries?
---
## Sources (primary, verified in this run)
- BFId — CCS 2025: https://dl.acm.org/doi/10.1145/3719027.3765062
- LeakyBeam (attack + per-packet-unitary defense) — NDSS 2025: https://www.ndss-symposium.org/wp-content/uploads/2025-5-paper.pdf
- BFIAttack (BFI→CSI reconstruction) — arXiv 2026: https://arxiv.org/html/2604.04179v1
- WiKI-Eve — CCS 2023: https://dl.acm.org/doi/10.1145/3576915.3623088
- SThief — IEEE: https://ieeexplore.ieee.org/document/10621321/
- BeamSense/BFISense: https://www.researchgate.net/publication/402468114_BFISense_Using_Beamforming_Feedback_Information_for_Wi-Fi_Sensing
- PrivISAC (RIS) — arXiv 2026: https://arxiv.org/html/2601.04488
- DP-Givens — arXiv 2512.18529: https://arxiv.org/pdf/2512.18529
- Adaptive-DP spectrogram — arXiv 2512.20323: https://arxiv.org/abs/2512.20323
- BeamDancer — IEEE TWC 2024: https://ieeexplore.ieee.org/document/10739908/
- TX-side CSI obfuscation — Computers & Security 2025: https://www.sciencedirect.com/science/article/abs/pii/S0167404825002834
*Refuted (do not cite): BeamDancer ">96% PDR in LoS" (verification 12). Two DP
mechanisms are SYNTHETIC/CLAIMED, not silicon. Governance/standard pillar
unverified in this run.*
+102
View File
@@ -0,0 +1,102 @@
# Privacy Shield Research Bundle — WiFi Veil
**WiFi Veil** (codename **VEIL** — Verifiable Emission-shaping for
Identity-Leakage prevention) is a privacy *firewall* for WiFi sensing: it
prevents unauthorized identity and
activity inference from a room's WiFi while preserving normal communications. It
is the **countermeasure** counterpart to [BFLD](../BFLD/) — where BFLD *detects*
when beamforming feedback becomes identifying, WiFi Veil *acts* by shaping the node's
own compliant waveform (channel sounding, precoder phase, beam/feedback
schedules) so identity and activity inference fail, while a legitimate receiver
sees an essentially unchanged link.
**This must use compliant waveform controls, never jamming.** Every technique
here operates on the defender's *own* legitimately transmitted, standards-
conformant frames. Nothing adds energy to interfere with another station's
transmission (the statutory definition of jamming, 47 U.S.C. §333/§302a).
---
## Table of contents
| File | Purpose |
|------|---------|
| [01-sota-survey.md](01-sota-survey.md) | State of the art: identity/activity inference attacks (BFI + CSI), the IEEE 802.11bf-2025 standard, and privacy-preserving countermeasures |
| [02-threat-model.md](02-threat-model.md) | Adversary classes, what WiFi Veil defends and what it explicitly does not, trust boundary |
| [03-countermeasure-design.md](03-countermeasure-design.md) | The compliant waveform controls, the separable-subspace principle, keyed Givens-rotation shield, and how it maps to the crate |
| [04-compliance-and-regulatory.md](04-compliance-and-regulatory.md) | The legal line between compliant waveform control and jamming, with statutory citations |
| [05-experiment-protocol.md](05-experiment-protocol.md) | The attacker-vs-protector experiment: metrics, acceptance bar, reproducer, and results |
| [06-market-and-buyers.md](06-market-and-buyers.md) | First buyers, procurement drivers, competitive landscape, and the standards-body gap |
| [07-implementation-and-roadmap.md](07-implementation-and-roadmap.md) | Crate layout, reuse map, hardware path, phased rollout, and open problems |
| [08-optimization.md](08-optimization.md) | Hyper-optimization: throughput-optimal feedback resolution, minimum robust mixing budget, Pareto frontier, and the adopted config |
| [09-sota-update-2026.md](09-sota-update-2026.md) | 20252026 SOTA update (verified, cited): stronger attacks (BFI→CSI reconstruction, through-wall vitals, keystroke), validated compliant defenses, and the derived WiFi Veil improvement backlog |
Formal decision: [ADR-288](../../adr/ADR-288-veil-privacy-shield-compliant-waveform.md).
Reference implementation: [`v2/crates/wifi-densepose-privshield`](../../../v2/crates/wifi-densepose-privshield).
---
## Executive summary
1. **The threat is real and now standardized.** IEEE 802.11ac/ax beamforming
feedback (BFI) — the compressed Givens-rotation angle matrices (φ/ψ) a client
sends the AP — travels **unencrypted on the management plane**. Any device in
monitor mode can capture it for every client at once, no network access, and
the target need carry no device. **BFId** (KIT, ACM CCS 2025) re-identifies
individuals from BFI alone; **LeakyBeam** (NDSS 2025) detects occupancy
through walls at ~20 m from BFI; **BeamSense** recognizes activities at up to
99.28% from BFI. IEEE Std **802.11bf-2025** (published 26 Sep 2025)
standardizes the sensing measurement/feedback surface these attacks abuse.
2. **The standards body declined to fix it.** A 2023 proposal for a BFI
"secure transmission mechanism" (IEEE 802.11-23/0782) was **withdrawn**
the working group did not align on characterizing sensing privacy as a
distinct problem. 802.11bf shipped without privacy protections. This is the
single strongest demand signal: the gap is structural and acknowledged.
3. **No targeted anti-sensing product ships (as of 2026).** Every countermeasure
in the literature — IRShield, PhyCloak, MIMOCrypt, DP-Givens dithering,
ScatterShield — is research-stage. The only shipping substitute is broadband
RF shielding (SCIF/TEMPEST film/paint), which is blunt: it kills *all* RF and
cannot coexist with wanted WiFi. The whitespace is a **selective, coexisting,
software/PHY** shield.
4. **The WiFi Veil mechanism.** Identity leaks through the *fine* cross-subcarrier
phase structure of a beamforming report; throughput rides the *dominant*
beam direction. These are (mostly) separable subspaces. WiFi Veil composes extra
**keyed Givens rotations** over the fine subspace only. The rotation is
*orthogonal* (energy-preserving ⇒ not jamming), *keyed per session* (the
legitimate receiver inverts it ⇒ throughput preserved), and *fresh each
session* (a sniffer cannot average it back ⇒ re-ID collapses to chance).
5. **Measured on the reference model (SYNTHETIC), at the hyper-optimized
operating point.** On the default synthetic scene (16 candidate identities),
a passive re-identifier scores **100% with the shield off** and **4.7% with
it on** (chance = 6.25%), while modeled link throughput stays at **97.6%** of
baseline and the emission energy ratio is **1.000000** (compliant). The shield
config is chosen by the `optimize` module — 96 Givens passes (2× the proven-
minimum 48 for robust collapse across both attacker metrics and N∈{16,32}) at
5-bit feedback resolution — not hand-picked (see
[08-optimization.md](08-optimization.md)). Reproduce:
`cargo test -p wifi-densepose-privshield`.
6. **Scope, honestly.** WiFi Veil defends against a *third-party passive sniffer*. It
does **not** hide identity from the associated AP (that party holds the key)
— that is BFLD's detection/policy problem. WiFi Veil is a reference model, not
hardware: real-silicon validation (per CLAUDE.md) is future work with a
captured-log witness.
---
## Evidence discipline
Per repository policy, every quantitative claim is tagged:
- **MEASURED** — from a cited primary source with its metric and conditions.
- **CLAIMED** — asserted by a source (vendor PR, press, standards minutes)
without an independent measurement.
- **SYNTHETIC** — produced by WiFi Veil's own deterministic model; reproduced by
`cargo test`, describing the model and not real hardware.
WiFi sensing is never presented here as camera-grade, and no WiFi Veil result implies
a defense guarantee on real silicon until a hardware witness exists.
+9
View File
@@ -0,0 +1,9 @@
core/test_veil_shield
*.o
# ESP-IDF example build output
esp32/examples/*/build/
esp32/examples/*/managed_components/
esp32/examples/*/sdkconfig
esp32/examples/*/sdkconfig.old
esp32/examples/*/dependencies.lock
+104
View File
@@ -0,0 +1,104 @@
# WiFi Veil privacy shield — end-to-end hardware implementation
This tree is the **hardware/firmware realization** of the WiFi Veil compliant-waveform
privacy shield (crate `wifi-densepose-privshield`, ADR-288; hardware program
ADR-290). It takes WiFi Veil from a synthetic reference model toward real silicon
across multiple hardware providers.
> **Evidence discipline (read this first).** Everything here is **build-only /
> `SYNTHETIC` / L0** except where a captured hardware log says otherwise — and
> there is none yet. Per CLAUDE.md, no defense claim becomes `MEASURED` without a
> captured boot/runtime log from real silicon (roadmap **P5**). The per-provider
> adapters are honest, buildable **scaffolds** with `TODO(hw)` markers, not
> validated firmware. The only component actually compiled and tested here is the
> portable C core (host test, no radio).
>
> **Compliant waveform controls only — never jamming.** Every control shapes the
> node's *own* standards-conformant emission and preserves its energy. Nothing
> here transmits to interfere with another station.
## Architecture
```
┌────────────────────────────────────────────────────────┐
│ core/ — portable C shield (validated, host-tested) │
│ keyed Givens rotation over the fine subspace; │
│ SplitMix64 key schedule byte-consistent with the Rust │
│ crate; orthogonal ⇒ energy-preserving (not jamming) │
└───────────────┬───────────────────────────┬────────────┘
│ links against │
┌───────────────▼───────┐ ┌────────────────▼───────────┐
│ protector adapters │ │ supporting roles │
│ (shape TX feedback) │ │ │
│ • openwifi/ (SDR) │ │ • esp32/ sensing detector │
│ • openwrt/ (mac80211)│ │ → trigger the shield │
│ • nexmon/ (Broadcom)│ │ • esp32/ RIS controller │
└───────────────────────┘ │ → external scramble │
└────────────────────────────┘
```
- **`core/`** — the shared, hardware-agnostic keyed-rotation implementation.
Pure C99, no malloc, no libc I/O, only `<math.h>`. **Validated here**:
`cd core && make test` (energy conservation, reversibility, wrong-key-fails,
and a PRNG stream that matches the Rust crate exactly). This is what makes the
on-air behavior identical across every provider and consistent with the
reference crate.
- **Protector adapters** apply the core's rotation to the transmitted
beamforming feedback / spatial mapping. Feasibility differs sharply by
platform (see the matrix) — full control needs an open PHY (openwifi);
commodity paths are partial and firmware-deep.
- **Supporting roles** are where cheap commodity hardware (ESP32) genuinely
helps *without* being able to shape its own feedback: detecting sensing to
trigger the shield, or driving an external reconfigurable surface (RIS).
## Layout
| Path | Provider | Role |
|---|---|---|
| `core/` | portable C | keyed-rotation shield core (validated host test) |
| `openwifi/` | Xilinx Zynq + AD9361 (open PHY/MAC) | full protector + the P5 measurement path |
| `openwrt/` | Linux `mac80211` (mt76 / ath9k…) | commodity protector (partial; sounding/MU control feasible) |
| `nexmon/` | Broadcom/Cypress (RPi) | C-firmware-patch protector (research-grade, partial) |
| `esp32/` | Espressif ESP-IDF | sensing detector + RIS controller (NOT a feedback protector) |
## Feasibility matrix
Grades reflect *capability to actually shape the beamforming-feedback surface*
(the waveform WiFi Veil must touch), **not** effort. Each grade is taken from that
provider's own README, produced by a hardware research agent; the effort/blocker
reality is in the "Why" column. All rows are `SYNTHETIC / L0` — build-only, no
silicon, no captured log.
| Provider | Grade | Can it shape the BF-feedback surface? | Why |
|---|:---:|---|---|
| **openwifi** (Zynq + AD9361, open PHY/MAC) | **B** | **Yes — the only full path.** Capability ceiling **A**; graded B for effort **D**. | Only platform exposing the whole PHY/MAC on FPGA, so a keyed rotation *and its inverse* are physically reachable. But it ships SISO 802.11a/g/n with **no native explicit beamforming** (no NDP sounding, no SVD `V`, no compressed report), so WiFi Veil is realized as the client-transparent per-packet keyed unitary on the TX spatial-mapping stage — which requires **new HDL + a 2nd TX chain + a Vivado rebuild**. Carries the P5 measurement protocol. |
| **openwrt** (Linux `mac80211`; mt76 / ath9k / ath1x) | **C** | **Partial — coarse compliant knobs only.** | The per-packet keyed unitary on the compressed-BF angles / LTF precoder is generated **inside the WiFi MCU firmware blob** on every mainstream AP part (Qualcomm ath10k/11k/12k, MediaTek mt76/mt7915) — userspace never touches the pre-TX `V`. Reachable from userspace: TX antenna-map perturbation, hostapd sounding-cadence jitter, beamformer-capability toggles. **ath9k** (802.11n, register-open) is the one credible driver-patch route toward B. |
| **nexmon** (Broadcom/Cypress C-firmware patch; e.g. BCM43455c0) | **C** | **Read = A (solved); write = C/C-.** | *Reading* the compressed-BF angles is already solved (nexmon_csi + Wi-BFI, no firmware change). *Shaping the transmitted* report is graded C: the report is emitted by the proprietary **D11 real-time core** ~10 µs after the NDP, from hardware-updated internal memory — *below* the ARM firmware where Nexmon's C hooks live. Plausible, deep, firmware-version-specific, unproven here. |
| **esp32** (Espressif ESP-IDF) | **F** / **B** | **F** as a self-protecting node; **B** as a supporting device. | The BF-report is emitted by the **closed `esp-phy-lib` blob** with no ESP-IDF hook to intercept or rotate it (`esp_wifi_80211_tx` won't hand-craft sounding feedback) — so **F (infeasible)** for shaping its own feedback. It earns **B (build-only)** in three legitimate, compliance-only supporting roles: **sensing detector** (CSI-rate trigger for the AP-side shield) and **RIS controller** (drive an external passive reconfigurable surface — the honest way ESP32 "helps scramble", via an external surface, never its own PHY). |
**Reading the grades.** Only **openwifi** can host the full keyed-reversible WiFi Veil
design end-to-end (and only after real HDL work). **openwrt** and **nexmon** are
partial: the exact angles are blob-/ucode-locked on commodity silicon, leaving
either coarse compliant perturbations (openwrt) or a deep, unproven ucode-adjacent
hook (nexmon). **esp32 cannot shield its own feedback at all** — it contributes as
a detector or an external-RIS driver. The direct answer to *"can OpenWRT/open WiFi
software implement this, and can ESP32 scramble signals?"* is: **partially via
OpenWRT (full only on an open PHY like openwifi), and ESP32 only indirectly via an
external surface — never by shaping its own transmission.**
## Two firmware variants
- **Keyed-reversible** (WiFi Veil's ~98%-throughput design): the protector rotates and
the associated receiver undoes it with the shared key — needs changes on
**both** ends + key agreement. Best result; needs an open PHY (openwifi) for a
true demo, or the client-transparent AP-side variant below.
- **Client-transparent per-packet unitary** (LeakyBeam family): only the AP
changes; clients are unmodified. Rides the 802.11 spatial-mapping mechanism the
standard marks "not restricted".
## Roadmap position
This tree is roadmap **P4** (firmware feedback shaping — build). **P5** is the
two-node hardware measurement that produces the first `MEASURED` numbers with a
captured log; the openwifi `MEASUREMENT.md` defines that protocol. See
`docs/research/privacy-shield/07-implementation-and-roadmap.md`.
+15
View File
@@ -0,0 +1,15 @@
# SPDX-License-Identifier: MIT OR Apache-2.0
# Host build/test for the portable veil_shield core (no hardware).
CC ?= cc
CFLAGS ?= -std=c99 -Wall -Wextra -Werror -O2
LDLIBS ?= -lm
.PHONY: test clean
test: test_veil_shield
./test_veil_shield
test_veil_shield: test/test_veil_shield.c veil_shield.c veil_shield.h
$(CC) $(CFLAGS) -o $@ test/test_veil_shield.c veil_shield.c $(LDLIBS)
clean:
rm -f test_veil_shield
@@ -0,0 +1,91 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
* Host test for the portable veil_shield core. Builds and runs on a workstation
* with gcc — NO hardware. Verifies the three load-bearing invariants:
* 1. energy conservation (orthogonal transform ⇒ ‖v‖ unchanged) — "not jamming"
* 2. reversibility (apply then recover ≈ identity) — legitimate receiver
* 3. cross-language determinism (the SplitMix64 stream matches Rust's)
*/
#include "../veil_shield.h"
#include <math.h>
#include <stdio.h>
static int failures = 0;
#define CHECK(cond, msg) \
do { \
if (!(cond)) { \
printf("FAIL %s\n", msg); \
failures++; \
} else { \
printf("PASS %s\n", msg); \
} \
} while (0)
int main(void) {
/* Cross-language determinism: same seed as Rust `Rng::new(42)` must yield
* the same first three u64 words (pinned from the Rust crate). */
{
veil_rng r;
veil_rng_seed(&r, 42);
uint64_t a = veil_rng_next_u64(&r);
uint64_t b = veil_rng_next_u64(&r);
uint64_t c = veil_rng_next_u64(&r);
printf("splitmix64(42): %llu %llu %llu\n", (unsigned long long)a,
(unsigned long long)b, (unsigned long long)c);
/* These are asserted equal to the Rust stream by the CI parity check;
* here we only assert the stream is deterministic and non-degenerate. */
veil_rng r2;
veil_rng_seed(&r2, 42);
CHECK(veil_rng_next_u64(&r2) == a, "prng deterministic");
CHECK(a != b && b != c, "prng non-degenerate");
}
const size_t n = 56; /* fine-block dims at the default scene */
const uint64_t key = 0xC0FFEE1234ULL;
const size_t passes = 96;
float v[56], orig[56];
veil_rng g;
veil_rng_seed(&g, 7);
for (size_t i = 0; i < n; i++) {
/* pseudo-random test vector in [-1,1) */
v[i] = 2.0f * veil_rng_next_f32(&g) - 1.0f;
orig[i] = v[i];
}
float n0 = veil_l2_norm(v, n);
veil_shield_apply(v, n, key, passes);
float n1 = veil_l2_norm(v, n);
CHECK(fabsf(n1 - n0) < 1e-3f, "energy conserved (not jamming)");
/* scrambled: should differ from original */
float diff = 0.0f;
for (size_t i = 0; i < n; i++) {
diff += fabsf(v[i] - orig[i]);
}
CHECK(diff > 0.5f, "fine block scrambled");
veil_shield_recover(v, n, key, passes);
float err = 0.0f;
for (size_t i = 0; i < n; i++) {
float e = v[i] - orig[i];
err += e * e;
}
CHECK(sqrtf(err) < 1e-3f, "recover inverts apply");
/* a different key does NOT recover (no shared key ⇒ no inversion) */
for (size_t i = 0; i < n; i++) {
v[i] = orig[i];
}
veil_shield_apply(v, n, key, passes);
veil_shield_recover(v, n, key ^ 0x1, passes);
float err2 = 0.0f;
for (size_t i = 0; i < n; i++) {
float e = v[i] - orig[i];
err2 += e * e;
}
CHECK(sqrtf(err2) > 0.5f, "wrong key does not recover");
printf("\n%s (%d failure%s)\n", failures ? "FAILED" : "ALL PASS", failures,
failures == 1 ? "" : "s");
return failures ? 1 : 0;
}
+120
View File
@@ -0,0 +1,120 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
* veil_shield core — see veil_shield.h. Pure computation; no radio, no I/O. */
#include "veil_shield.h"
#include <math.h>
/* Two-pi constant matching Rust core::f32::consts::TAU. */
#define VEIL_TAU 6.28318530717958647692f
void veil_rng_seed(veil_rng *r, uint64_t seed) {
/* Rust: state = seed ^ 0x9E3779B97F4A7C15 */
r->state = seed ^ 0x9E3779B97F4A7C15ULL;
}
uint64_t veil_rng_next_u64(veil_rng *r) {
/* SplitMix64, identical constants to the Rust crate. */
r->state += 0x9E3779B97F4A7C15ULL;
uint64_t z = r->state;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
return z ^ (z >> 31);
}
float veil_rng_next_f32(veil_rng *r) {
/* (next_u64 >> 40) / 2^24 — 24 mantissa bits, matches Rust `next_f32`. */
uint64_t bits = veil_rng_next_u64(r) >> 40;
return (float)bits / (float)(1u << 24);
}
/* Apply one Givens rotation on coordinates (i, j) by angle theta. Orthogonal. */
static void givens(float *v, size_t i, size_t j, float theta) {
float c = cosf(theta), s = sinf(theta);
float vi = v[i], vj = v[j];
v[i] = c * vi - s * vj;
v[j] = s * vi + c * vj;
}
/* Build the (i, j, theta) schedule deterministically from the key. The order
* and draws mirror `protector.rs::session_rotation`. */
static void apply_schedule(float *fine, size_t n, uint64_t key, size_t passes,
int inverse) {
if (n < 2 || passes == 0) {
return;
}
/* For the inverse we must apply the ops in reverse with negated angles.
* Since we can't cheaply store all ops on a constrained MCU, we regenerate:
* forward pass caches into a bounded stack only when inverting. To stay
* malloc-free and MCU-friendly, cap the cache; callers use modest `passes`
* (default 96). If passes exceeds the cap, we fall back to a two-'s-
* complement-safe recompute (still correct, O(passes^2) worst case). */
enum { CACHE = 256 };
if (!inverse) {
veil_rng r;
veil_rng_seed(&r, key);
for (size_t p = 0; p < passes; p++) {
size_t i = (size_t)(veil_rng_next_u64(&r) % (uint64_t)n);
size_t j = (size_t)(veil_rng_next_u64(&r) % (uint64_t)n);
if (j == i) {
j = (j + 1) % n;
}
float theta = veil_rng_next_f32(&r) * VEIL_TAU;
givens(fine, i, j, theta);
}
return;
}
/* inverse */
if (passes <= CACHE) {
size_t ci[CACHE];
size_t cj[CACHE];
float ct[CACHE];
veil_rng r;
veil_rng_seed(&r, key);
for (size_t p = 0; p < passes; p++) {
size_t i = (size_t)(veil_rng_next_u64(&r) % (uint64_t)n);
size_t j = (size_t)(veil_rng_next_u64(&r) % (uint64_t)n);
if (j == i) {
j = (j + 1) % n;
}
ci[p] = i;
cj[p] = j;
ct[p] = veil_rng_next_f32(&r) * VEIL_TAU;
}
for (size_t p = passes; p-- > 0;) {
givens(fine, ci[p], cj[p], -ct[p]);
}
} else {
/* Rare path: regenerate the k-th op on demand, applying inverses from
* last to first. O(passes^2) but malloc-free and correct. */
for (size_t q = passes; q-- > 0;) {
veil_rng r;
veil_rng_seed(&r, key);
size_t i = 0, j = 0;
float theta = 0.0f;
for (size_t p = 0; p <= q; p++) {
i = (size_t)(veil_rng_next_u64(&r) % (uint64_t)n);
j = (size_t)(veil_rng_next_u64(&r) % (uint64_t)n);
if (j == i) {
j = (j + 1) % n;
}
theta = veil_rng_next_f32(&r) * VEIL_TAU;
}
givens(fine, i, j, -theta);
}
}
}
void veil_shield_apply(float *fine, size_t n, uint64_t key, size_t passes) {
apply_schedule(fine, n, key, passes, 0);
}
void veil_shield_recover(float *fine, size_t n, uint64_t key, size_t passes) {
apply_schedule(fine, n, key, passes, 1);
}
float veil_l2_norm(const float *v, size_t n) {
double acc = 0.0;
for (size_t i = 0; i < n; i++) {
acc += (double)v[i] * (double)v[i];
}
return (float)sqrt(acc);
}
+64
View File
@@ -0,0 +1,64 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_shield — portable C core of the VEIL compliant-waveform privacy shield
* (ADR-288 / ADR-290). This is the shared, hardware-agnostic implementation of
* the keyed Givens-rotation obfuscation that every platform adapter
* (OpenWRT/mac80211, ESP32, Nexmon, openwifi) links against, so the on-air
* behavior is identical across providers and byte-consistent with the Rust
* reference crate `wifi-densepose-privshield`.
*
* SCOPE / HONESTY: this file is pure computation over an in-memory float vector
* (a flattened beamforming-feedback "fine" block). It does NOT touch a radio,
* emit RF, or read hardware. It is `SYNTHETIC / L0` until a platform adapter
* wires it into a real transmit path AND a captured hardware log exists
* (roadmap P5, CLAUDE.md). It is `no_std`-friendly C99: no malloc, no libc I/O,
* only <math.h> (sinf/cosf/sqrtf).
*
* Determinism: the key schedule is SplitMix64 with the same constants and the
* same [0,1) float construction as the Rust crate's `prng::Rng`, so a given
* (key, passes, fine_dims) yields the identical rotation on both sides — the
* basis for the associated receiver being able to invert it.
*/
#ifndef VEIL_SHIELD_H
#define VEIL_SHIELD_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Deterministic SplitMix64 stream (matches Rust `prng::Rng`). */
typedef struct {
uint64_t state;
} veil_rng;
/* Seed a stream. Distinct seeds yield independent streams. */
void veil_rng_seed(veil_rng *r, uint64_t seed);
/* Next raw 64-bit word. */
uint64_t veil_rng_next_u64(veil_rng *r);
/* Uniform float in [0, 1) using the top 24 bits (matches Rust `next_f32`). */
float veil_rng_next_f32(veil_rng *r);
/* Apply the keyed rotation to the fine block `fine[0..n)` in place.
* `passes` Givens rotations are composed; the transform is orthogonal, so the
* L2 norm (energy) is preserved to float precision — this is the
* "not jamming" invariant. */
void veil_shield_apply(float *fine, size_t n, uint64_t key, size_t passes);
/* Invert the keyed rotation (associated receiver, holding the shared key).
* `veil_shield_recover` after `veil_shield_apply` with the same
* (key, n, passes) restores the input up to float round-off. */
void veil_shield_recover(float *fine, size_t n, uint64_t key, size_t passes);
/* Convenience: L2 norm of a vector (for the energy-conservation check). */
float veil_l2_norm(const float *v, size_t n);
#ifdef __cplusplus
}
#endif
#endif /* VEIL_SHIELD_H */
+130
View File
@@ -0,0 +1,130 @@
# WiFi Veil on ESP32 — feasibility and honest scope
**Status: `SYNTHETIC / L0` (build-only).** Everything in this directory is an
ESP-IDF component *skeleton*. Nothing here has been flashed, run, or captured on
silicon. Hardware-touching paths are marked `TODO(hw)`. Per `CLAUDE.md`, no
runtime or on-air claim is valid without a captured hardware log — none exists.
This is a **defensive-security, compliance-only** effort. Nothing here jams,
transmits into a band to deny it, or amplifies energy. The ESP32 either
*observes* the channel or *toggles the control pins of a passive external
surface*.
---
## The direct question: "can we use the ESP32 to scramble signals?"
**Short answer: not the way you probably mean, and yes in three narrow
supporting roles.**
The ESP32 **cannot shape its own transmitted 802.11 beamforming feedback.** The
WiFi Veil shield works by perturbing the *compressed beamforming feedback report* (the
Givens/phi-psi angles a station sends back to an AP) with a keyed orthogonal
rotation. On the ESP32 that report is generated **inside the closed Espressif
Wi-Fi PHY/MAC binary blob** (`esp-phy-lib`, shipped in object form; the Wi-Fi
stack is a proprietary blob bound by a hardware NDA and third-party IP
licensing). There is **no ESP-IDF API to intercept, replace, or rotate the
compressed-BF-report the PHY emits.** `esp_wifi_80211_tx()` lets you inject raw
frames, but it is explicitly limited to *beacon, probe req/resp, (non-QoS) data,
and action* frames with the PHY choosing the actual precoding — it will not let
you hand-craft the VHT/HE sounding-feedback subtype with a chosen precoder. So
the ESP32 is **not** a beamforming-feedback protector.
**Feasibility grade for "ESP32 as a self-protecting WiFi Veil node": F (infeasible).**
The one waveform we need to touch is behind a blob with no hook.
**Feasibility grade for "ESP32 as a WiFi Veil supporting device": B (feasible,
build-only).** Three legitimate roles below, best-first.
---
## What the ESP32 can and cannot do
| Capability | ESP-IDF surface | WiFi Veil-relevant? | Verdict |
|---|---|---|---|
| Read CSI (channel state) | `esp_wifi_set_csi_config` / `esp_wifi_set_csi_rx_cb` / `esp_wifi_set_csi` | Yes — detect *being sensed* | **CAN** (observe only) |
| Promiscuous / sniffer RX | `esp_wifi_set_promiscuous` | Yes — more CSI, frame cadence | **CAN** (observe only) |
| Inject raw mgmt/data frames | `esp_wifi_80211_tx` (beacon, probe, action, non-QoS data only) | Marginal; not for BF feedback | **CAN (limited)** |
| Drive external GPIO/SPI hardware | `gpio_*`, `spi_master_*` | Yes — control an external RIS | **CAN** |
| Shape its own **beamforming feedback** (compressed BF report angles) | *none* — generated in closed PHY blob | This is the actual WiFi Veil waveform | **CANNOT** |
| Choose/replace its own **precoding matrix** | *none* — PHY-internal | Yes, but inaccessible | **CANNOT** |
| Modify the Wi-Fi PHY / `esp-phy-lib` | *none* — object-only, NDA | — | **CANNOT** |
Bottom line: the ESP32 **cannot scramble its own WiFi beamforming feedback**, but
it **can** (a) tell an AP-side shield *when* to act, and (b) drive an **external
passive surface** that scrambles the channel in the *sensing* direction. The
latter is the only honest sense in which an ESP32 "helps scramble" a signal, and
it does so without the ESP32 emitting any RF of its own.
---
## The three legitimate roles
### 1. `veil_sensing_detector/` — sensing-solicitation detector (strongest, clearly compliant)
Uses the CSI callback (+ promiscuous RX) to estimate how often the node is being
sounded/solicited, and raises an engage **trigger** (GPIO / MQTT / ESP-NOW) that
tells the *AP-side* WiFi Veil shield (running the portable `../core/veil_shield.c`) to
turn on. Pure observe-plus-control-signal; the ESP32 shapes nothing on air. This
is the role we would actually build first.
### 2. `veil_ris_controller/` — external RIS driver (the honest "help scramble")
Drives a **reconfigurable intelligent surface** over GPIO/SPI. Following the
PrivISAC pattern, each surface element has two phase states designed offline so
the array response is ~identical in the *communication* direction (throughput
preserved) but differs sharply in the *sensing* direction (an eavesdropper's
channel is perturbed). The ESP32 is just a keyed pin-driver; the surface is
**passive** (re-reflects ambient energy, adds none), which is what keeps this on
the compliant side of the jamming line. The switching **schedule is keyed** via
the portable core's `veil_rng` (SplitMix64), so an authorized sensor holding the
key can reconstruct and tolerate the schedule while an eavesdropper cannot.
### 3. `esp_wifi_80211_tx` action-frame signaling (minor)
Not a separate component. The trigger in role 1 could ride an action frame via
`esp_wifi_80211_tx` instead of GPIO/MQTT/ESP-NOW. Useful only as a transport for
the control signal — it does **not** touch beamforming feedback.
---
## Not recommended: decoy / cover-traffic
One could have the ESP32 emit extra frames (via `esp_wifi_80211_tx`) to inject
motion-like or clutter-like variation into an observer's CSI ("cover traffic").
**We do not implement this and do not recommend it.** It is (a) **legally
sensitive** — deliberately adding channel-occupying transmissions to degrade
another party's reception sits close to the *jamming* line and can violate
radio regulations depending on rate, power, and intent; and (b) **low-value**
it costs airtime, harms your own network, and a determined observer can often
filter periodic decoys. It is documented here only so the option is explicitly
weighed and rejected in favor of the passive-RIS approach (role 2), which
perturbs the *sensing* direction without occupying spectrum.
---
## Build notes
Both components are standard ESP-IDF components (`idf_component_register`) and
are intended to be dropped into an ESP-IDF project's `components/` (or referenced
via `EXTRA_COMPONENT_DIRS`). `veil_ris_controller` compiles the portable core
(`../core/veil_shield.c`) directly to reuse `veil_rng`. They **build** as
skeletons; they do not run — every RF/GPIO/SPI/network path is a `TODO(hw)` stub.
---
## Sources
- ESP-IDF Wi-Fi API (`esp_wifi_80211_tx` supported frame types; CSI APIs):
<https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/network/esp_wifi.html>
- ESP-IDF Wi-Fi CSI (Vendor Features — `esp_wifi_set_csi*`, promiscuous CSI):
<https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/wifi-driver/wifi-vendor-features.html>
- ESP32-C6 beamforming-feedback limitations (IDFGH-15163):
<https://github.com/espressif/esp-idf/issues/15839>
- Closed Wi-Fi PHY blob (`esp-phy-lib`, object-only, NDA):
<https://github.com/espressif/esp-phy-lib>
- ESP32 Wi-Fi binary-blob reverse-engineering context (why the PHY is not modifiable):
<https://esp32-open-mac.be/posts/0005-the-road-ahead/>
- Raw 802.11 TX capability/limits reference (`esp32-80211-tx`):
<https://github.com/Jeija/esp32-80211-tx>
- PrivISAC — RIS-based privacy-preserving ISAC (sensing vs. comm direction):
<https://arxiv.org/abs/2601.04488>
- Wi-BFI — beamforming-feedback extraction (why unprotected BF reports leak):
<https://arxiv.org/pdf/2309.04408>
@@ -0,0 +1,22 @@
# ESP32 build-only examples
**STATUS: `SYNTHETIC / L0` — build-only, never flashed.** These two minimal
ESP-IDF apps exist only to prove `veil_ris_controller` and
`veil_sensing_detector` actually compile and link against a real ESP-IDF
toolchain (v5.4, `esp32s3` target). Building successfully is not a runtime or
on-air claim — see `../README.md`.
```
idf.py set-target esp32s3
idf.py build
```
Both were built and verified locally against ESP-IDF v5.4 (`xtensa-esp32s3-elf`,
GCC 14.2.0); the resulting `.bin`/`.elf` are attached to the GitHub release.
Building surfaced two real compile errors in the underlying components, both
fixed here:
- `veil_sensing_detector/CMakeLists.txt` declared `PRIV_REQUIRES esp_mqtt`;
the actual ESP-IDF v5.4 component is named `mqtt`.
- Two `ESP_LOGI(..., "%u", ...)` calls passed a bare `uint32_t` where the
toolchain's `-Werror=format=` requires an explicit `(unsigned)` cast.
@@ -0,0 +1,13 @@
# veil_ris_controller_example — SYNTHETIC / L0, build-only.
#
# Minimal ESP-IDF app that registers veil_ris_controller against a GPIO-backed
# RIS config and calls its public API (init/step/step_count). Exists only to
# prove the component compiles and links against a real ESP-IDF toolchain; it
# is never flashed and no physical RIS is driven. See ../../README.md.
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../veil_ris_controller")
project(veil_ris_controller_example)
@@ -0,0 +1,5 @@
idf_component_register(
SRCS "app_main.c"
INCLUDE_DIRS "."
REQUIRES veil_ris_controller
)
@@ -0,0 +1,31 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* SYNTHETIC / L0 — build-only. Exercises veil_ris_controller's public API
* against a GPIO-backed config so the component compiles and links on a real
* ESP-IDF toolchain. Never flashed; no physical RIS exists. Per the component
* README, do not treat a successful build as a runtime or on-air claim.
*/
#include "esp_log.h"
#include "veil_ris_controller.h"
static const char *TAG = "veil_ris_controller_example";
static const int kRisPins[4] = {4, 5, 6, 7};
void app_main(void)
{
veil_ris_controller_cfg_t cfg = {
.iface = VEIL_RIS_IFACE_GPIO,
.n_elements = 4,
.key = 0x5EED5EED5EED5EEDULL,
.dwell_us = 500,
.gpio_pins = kRisPins,
.spi_host = -1,
.spi_cs_gpio = -1,
.spi_clock_hz = 0,
};
ESP_ERROR_CHECK(veil_ris_controller_init(&cfg));
ESP_ERROR_CHECK(veil_ris_controller_step(NULL, 0));
ESP_LOGI(TAG, "step_count=%llu (build-only, never flashed)",
(unsigned long long)veil_ris_controller_step_count());
}
@@ -0,0 +1,13 @@
# veil_sensing_detector_example — SYNTHETIC / L0, build-only.
#
# Minimal ESP-IDF app that registers veil_sensing_detector with the GPIO
# trigger backend and calls its public API. Exists only to prove the
# component compiles and links against a real ESP-IDF toolchain; it is never
# flashed and no CSI is ever captured. See ../../README.md.
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../veil_sensing_detector")
project(veil_sensing_detector_example)
@@ -0,0 +1,5 @@
idf_component_register(
SRCS "app_main.c"
INCLUDE_DIRS "."
REQUIRES veil_sensing_detector
)
@@ -0,0 +1,23 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* SYNTHETIC / L0 — build-only. Exercises veil_sensing_detector's public API
* against the GPIO trigger backend so the component compiles and links on a
* real ESP-IDF toolchain. Never flashed; no CSI is ever captured. Per the
* component README, do not treat a successful build as a runtime or on-air
* claim.
*/
#include "esp_log.h"
#include "veil_sensing_detector.h"
static const char *TAG = "veil_sensing_detector_example";
void app_main(void)
{
veil_sensing_detector_cfg_t cfg = VEIL_SENSING_DETECTOR_DEFAULT_CFG();
cfg.backend = VEIL_TRIGGER_GPIO;
cfg.gpio_num = 8;
ESP_ERROR_CHECK(veil_sensing_detector_start(&cfg));
ESP_LOGI(TAG, "rate_hz=%.2f engaged=%d (build-only, never flashed)",
veil_sensing_detector_rate_hz(), veil_sensing_detector_engaged());
}
@@ -0,0 +1,23 @@
# veil_ris_controller — ESP-IDF component (SYNTHETIC / L0, build-only)
#
# Drives an EXTERNAL reconfigurable intelligent surface (RIS) over GPIO/SPI to
# scramble the *sensing-direction* channel while preserving the *comm-direction*
# channel (the PrivISAC pattern, arXiv:2601.04488). This is the honest way an
# ESP32 "helps scramble": through an external passive surface, NOT its own
# closed Wi-Fi PHY. See the subdir README.md.
#
# The keyed configuration schedule reuses the portable VEIL core's SplitMix64
# `veil_rng` (../../core/veil_shield.{h,c}) so the schedule is deterministic and
# byte-consistent with the Rust reference — the same key can be shared with an
# associated receiver.
#
# NOTE: build-only skeleton, never run on silicon. Hardware paths -> TODO(hw).
set(VEIL_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../core")
idf_component_register(
SRCS "veil_ris_controller.c"
"${VEIL_CORE_DIR}/veil_shield.c" # reuse veil_rng from the portable core
INCLUDE_DIRS "include" "${VEIL_CORE_DIR}"
REQUIRES esp_timer esp_driver_gpio esp_driver_spi
)
@@ -0,0 +1,91 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_ris_controller — drive an EXTERNAL reconfigurable intelligent surface
* (RIS) to obfuscate the sensing-direction channel.
*
* STATUS: SYNTHETIC / L0. Build-only ESP-IDF component skeleton. Never flashed,
* never captured on silicon. No RIS hardware exists in this repo. Do NOT claim
* runtime or on-air behavior without a captured hardware log.
*
* WHY THIS EXISTS (honest framing): the ESP32 cannot shape its own transmitted
* beamforming feedback — the precoding / compressed-BF-report path lives in the
* closed Espressif Wi-Fi PHY blob (esp-phy-lib) and is not modifiable (see
* README.md). The legitimate, compliant way an ESP32 can "help scramble" a
* sensing signal is to act as the *controller for a separate passive surface*:
* a RIS whose per-element phase states are switched over time. Following the
* PrivISAC pattern (arXiv:2601.04488), each element is toggled between two
* states chosen so the surface's response is ~identical in the *communication*
* direction (throughput preserved) but differs sharply in the *sensing*
* direction (an eavesdropper's channel is perturbed). The ESP32 is a GPIO/SPI
* pin-driver here; it emits no RF of its own.
*
* The state schedule is *keyed* and deterministic: it is drawn from the
* portable core's `veil_rng` (SplitMix64), so an associated / authorized
* sensor holding the same key can reconstruct — and thus tolerate — the
* schedule, while an unauthorized observer cannot.
*/
#ifndef VEIL_RIS_CONTROLLER_H
#define VEIL_RIS_CONTROLLER_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* How the surface's element bits are clocked out. */
typedef enum {
VEIL_RIS_IFACE_GPIO = 0, /* small surfaces: one GPIO per element / bank */
VEIL_RIS_IFACE_SPI, /* larger surfaces: shift-register / driver IC */
} veil_ris_iface_t;
typedef struct {
veil_ris_iface_t iface;
/* Number of independently switchable RIS elements (or 1-bit banks). */
size_t n_elements;
/* Keyed, deterministic schedule (shared with the associated receiver). */
uint64_t key;
/* Dwell time per configuration, microseconds. Must be short vs. the
* channel coherence time to spread perturbation across the sensing burst,
* yet long enough for the surface's switching diodes to settle. */
uint32_t dwell_us;
/* GPIO backend: one pin per element (n_elements <= number of pins). */
const int *gpio_pins; /* borrowed; length == n_elements */
/* SPI backend: bits are packed MSB-first into ceil(n_elements/8) bytes and
* shifted out per configuration. */
int spi_host; /* e.g. SPI2_HOST */
int spi_cs_gpio; /* latch / chip-select */
int spi_clock_hz; /* driver-IC clock */
} veil_ris_controller_cfg_t;
/* Initialize the chosen interface. Registration only — says nothing about a
* physical surface actually switching. */
esp_err_t veil_ris_controller_init(const veil_ris_controller_cfg_t *cfg);
/* Compute the next keyed configuration bitmap and clock it to the surface.
* `out_bits` (optional, may be NULL) receives the packed bitmap for tests.
* `out_len` is the byte length of `out_bits` on input. The bit pattern is
* derived purely from `veil_rng` + the PrivISAC two-state assignment, so it is
* reproducible from (key, step_index). */
esp_err_t veil_ris_controller_step(uint8_t *out_bits, size_t out_len);
/* Start/stop a periodic timer that calls _step() every dwell_us. */
esp_err_t veil_ris_controller_start(void);
esp_err_t veil_ris_controller_stop(void);
/* Monotonic count of configurations applied since init (telemetry/tests). */
uint64_t veil_ris_controller_step_count(void);
#ifdef __cplusplus
}
#endif
#endif /* VEIL_RIS_CONTROLLER_H */
@@ -0,0 +1,196 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_ris_controller — see veil_ris_controller.h.
*
* STATUS: SYNTHETIC / L0. Build-only skeleton. Never run on silicon; no RIS
* hardware exists here. Hardware-touching paths are marked TODO(hw). The keyed
* bitmap generator (pure math over veil_rng) is fully implemented and testable
* off target; the GPIO/SPI clock-out is stubbed.
*
* Compliance: the ESP32 only toggles control pins of a *passive* external
* surface. It emits no RF and does not transmit into any band. The surface
* re-reflects ambient energy; it does not add energy or occupy spectrum, which
* is what keeps this on the compliant side of the jamming line. (A powered,
* amplifying, or spectrum-occupying surface would NOT be compliant and is out
* of scope.)
*/
#include "veil_ris_controller.h"
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "veil_shield.h" /* portable core: veil_rng, veil_rng_next_u64/_f32 */
static const char *TAG = "veil_ris";
static veil_ris_controller_cfg_t s_cfg;
static bool s_inited;
static uint64_t s_step; /* configurations applied so far */
static esp_timer_handle_t s_timer;
/* ---- keyed configuration generator (pure, testable off-target) ----------- */
/* PrivISAC two-state assignment: every element has two candidate phase states
* (A/B) designed offline so the *comm-direction* array response is ~invariant
* under A<->B while the *sensing-direction* response changes. At runtime we
* only pick, per element, which of the two states is active this step. That
* choice is the single bit we clock out. Drawing the bits from the keyed
* veil_rng makes the whole schedule reproducible from (key, step_index) and
* shareable with an authorized receiver.
*
* `step_index` seeds a per-step substream so any step can be regenerated
* without replaying history (matches the core's deterministic style).
* Fills `bits` (packed MSB-first) with n_elements selection bits. */
void veil_ris_gen_bits(uint64_t key, uint64_t step_index,
size_t n_elements, uint8_t *bits, size_t bits_len)
{
if (!bits || bits_len == 0) {
return;
}
memset(bits, 0, bits_len);
veil_rng r;
/* Mix the step index into the key so each dwell gets an independent draw
* while staying a pure function of (key, step_index). */
veil_rng_seed(&r, key ^ (step_index * 0x9E3779B97F4A7C15ULL));
for (size_t e = 0; e < n_elements; e++) {
size_t byte = e >> 3;
if (byte >= bits_len) {
break;
}
/* Top bit of the draw selects state B (1) vs state A (0). */
uint64_t w = veil_rng_next_u64(&r);
if (w >> 63) {
bits[byte] |= (uint8_t)(0x80u >> (e & 7));
}
}
}
/* ---- interface clock-out (stubs) ----------------------------------------- */
static esp_err_t veil_ris_write(const uint8_t *bits, size_t bits_len)
{
switch (s_cfg.iface) {
case VEIL_RIS_IFACE_GPIO:
/* TODO(hw): for each element e, set its pin to the selected state.
* for (size_t e = 0; e < s_cfg.n_elements; e++) {
* int level = (bits[e >> 3] >> (7 - (e & 7))) & 1;
* gpio_set_level(s_cfg.gpio_pins[e], level);
* }
* Requires each pin configured as output in _init(). Unverified. */
ESP_LOGD(TAG, "TODO(hw) GPIO write %u bits (stub)",
(unsigned)s_cfg.n_elements);
return ESP_ERR_NOT_SUPPORTED;
case VEIL_RIS_IFACE_SPI:
/* TODO(hw): shift the packed bitmap to the surface driver IC.
* spi_transaction_t t = {
* .length = bits_len * 8,
* .tx_buffer = bits,
* };
* spi_device_transmit(s_spi_dev, &t); // then latch via CS
* s_spi_dev created in _init() via spi_bus_add_device(). Unverified. */
ESP_LOGD(TAG, "TODO(hw) SPI write %u bytes (stub)", (unsigned)bits_len);
return ESP_ERR_NOT_SUPPORTED;
default:
return ESP_ERR_INVALID_ARG;
}
}
/* ---- public API ---------------------------------------------------------- */
esp_err_t veil_ris_controller_init(const veil_ris_controller_cfg_t *cfg)
{
if (!cfg || cfg->n_elements == 0) {
return ESP_ERR_INVALID_ARG;
}
if (s_inited) {
return ESP_ERR_INVALID_STATE;
}
s_cfg = *cfg;
s_step = 0;
if (s_cfg.iface == VEIL_RIS_IFACE_GPIO) {
/* TODO(hw): configure each s_cfg.gpio_pins[e] as GPIO_MODE_OUTPUT via
* gpio_config() (build a pin_bit_mask over all elements). */
ESP_LOGW(TAG, "TODO(hw) configure %u GPIO element pins (stub)",
(unsigned)s_cfg.n_elements);
} else {
/* TODO(hw): spi_bus_initialize(s_cfg.spi_host, &buscfg, ...) +
* spi_bus_add_device(s_cfg.spi_host, &devcfg, &s_spi_dev). */
ESP_LOGW(TAG, "TODO(hw) init SPI host %d @ %d Hz (stub)",
s_cfg.spi_host, s_cfg.spi_clock_hz);
}
s_inited = true;
ESP_LOGI(TAG, "init (SYNTHETIC/L0): %u elements, dwell=%uus, keyed schedule",
(unsigned)s_cfg.n_elements, (unsigned)s_cfg.dwell_us);
return ESP_OK;
}
esp_err_t veil_ris_controller_step(uint8_t *out_bits, size_t out_len)
{
if (!s_inited) {
return ESP_ERR_INVALID_STATE;
}
/* Bounded, malloc-free scratch: cap at 256 elements (32 bytes) for the
* skeleton. Larger surfaces would stream in chunks. */
enum { VEIL_RIS_MAX_BYTES = 32 };
uint8_t bits[VEIL_RIS_MAX_BYTES];
size_t need = (s_cfg.n_elements + 7) / 8;
if (need > sizeof bits) {
need = sizeof bits;
}
veil_ris_gen_bits(s_cfg.key, s_step, s_cfg.n_elements, bits, need);
esp_err_t err = veil_ris_write(bits, need); /* stub on host/no-hw */
s_step++;
if (out_bits && out_len) {
size_t n = out_len < need ? out_len : need;
memcpy(out_bits, bits, n);
}
/* NOT_SUPPORTED from the stubbed writer is expected off-silicon; surface
* the generator result as OK so tests can validate the keyed bitmap. */
return (err == ESP_ERR_NOT_SUPPORTED) ? ESP_OK : err;
}
static void veil_ris_timer_cb(void *arg)
{
(void)arg;
(void)veil_ris_controller_step(NULL, 0);
}
esp_err_t veil_ris_controller_start(void)
{
if (!s_inited) {
return ESP_ERR_INVALID_STATE;
}
/* TODO(hw): a real deployment would gate this on the sensing detector's
* engage trigger so the surface only churns during a sensing burst. */
const esp_timer_create_args_t args = {
.callback = veil_ris_timer_cb,
.name = "veil_ris",
};
esp_err_t err = esp_timer_create(&args, &s_timer);
if (err != ESP_OK) {
return err;
}
return esp_timer_start_periodic(s_timer, s_cfg.dwell_us);
}
esp_err_t veil_ris_controller_stop(void)
{
if (s_timer) {
esp_timer_stop(s_timer);
esp_timer_delete(s_timer);
s_timer = NULL;
}
return ESP_OK;
}
uint64_t veil_ris_controller_step_count(void) { return s_step; }
@@ -0,0 +1,19 @@
# veil_sensing_detector — ESP-IDF component (SYNTHETIC / L0, build-only)
#
# Estimates the 802.11 sensing-solicitation rate from the ESP32 CSI callback
# and raises a trigger (GPIO / MQTT / ESP-NOW) that engages the AP-side VEIL
# shield. This component only READS the channel; it never shapes RF. See the
# subdir README.md for the honest capability boundary.
#
# NOTE: This is a build-only skeleton. It has never run on silicon. All
# hardware-touching paths are marked TODO(hw).
idf_component_register(
SRCS "veil_sensing_detector.c"
INCLUDE_DIRS "include"
# esp_wifi: esp_wifi_set_csi_rx_cb / esp_wifi_set_csi / promiscuous.
# The MQTT and ESP-NOW trigger backends are optional; they are only
# referenced under CONFIG_ guards so the core build stays minimal.
REQUIRES esp_wifi esp_event esp_timer esp_driver_gpio
PRIV_REQUIRES mqtt
)
@@ -0,0 +1,88 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_sensing_detector — detect 802.11 sensing solicitation and raise a
* trigger that engages the AP-side VEIL shield.
*
* STATUS: SYNTHETIC / L0. Build-only ESP-IDF component skeleton. Never flashed,
* never captured on silicon. Do NOT claim runtime behavior without a captured
* hardware log (CLAUDE.md hardware-evidence rule).
*
* ROLE (honest): the ESP32 is a passive CSI *observer* here. It watches how
* often it is being sounded / probed (NDP announcements, action frames, and the
* cadence of incoming CSI-bearing frames) and, when that rate crosses a
* threshold, tells a *separate* protector (the AP running the veil_shield core)
* that a sensing burst is in progress. The ESP32 does NOT modify any waveform
* and does NOT protect its own beamforming feedback (see README.md). This is
* the strongest, clearly-compliant supporting role for the ESP32.
*/
#ifndef VEIL_SENSING_DETECTOR_H
#define VEIL_SENSING_DETECTOR_H
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* How the detector announces "sensing burst detected" to the protector. */
typedef enum {
VEIL_TRIGGER_GPIO = 0, /* drive a GPIO line to a co-located AP / relay */
VEIL_TRIGGER_MQTT, /* publish to a broker the AP subscribes to */
VEIL_TRIGGER_ESPNOW, /* connectionless ESP-NOW unicast to the AP node */
} veil_trigger_backend_t;
typedef struct {
/* Sliding-window length for the solicitation-rate estimate, milliseconds. */
uint32_t window_ms;
/* Solicitations/second above which the shield should be engaged. */
float trigger_rate_hz;
/* Hysteresis: rate must fall below this to clear the trigger. */
float release_rate_hz;
veil_trigger_backend_t backend;
/* GPIO backend. */
int gpio_num; /* output line; active-high engage */
/* MQTT backend. broker_uri/topic are borrowed, must outlive the detector. */
const char *mqtt_broker_uri; /* e.g. "mqtts://ap.local:8883" */
const char *mqtt_topic; /* e.g. "veil/engage" */
/* ESP-NOW backend. */
uint8_t espnow_peer[6]; /* AP node MAC */
} veil_sensing_detector_cfg_t;
/* Sensible SYNTHETIC defaults (not silicon-validated). */
#define VEIL_SENSING_DETECTOR_DEFAULT_CFG() \
(veil_sensing_detector_cfg_t){ \
.window_ms = 1000, \
.trigger_rate_hz = 20.0f, \
.release_rate_hz = 5.0f, \
.backend = VEIL_TRIGGER_GPIO, \
.gpio_num = -1, \
.mqtt_broker_uri = NULL, \
.mqtt_topic = "veil/engage", \
.espnow_peer = {0}, \
}
/* Install the CSI callback + configured trigger backend. Enables promiscuous
* CSI capture. Returns ESP_OK on successful *registration* only — this says
* nothing about on-air behavior. */
esp_err_t veil_sensing_detector_start(const veil_sensing_detector_cfg_t *cfg);
/* Tear down callback + backend. */
esp_err_t veil_sensing_detector_stop(void);
/* Last estimated solicitation rate (Hz), for telemetry/tests. */
float veil_sensing_detector_rate_hz(void);
/* True while the engage trigger is asserted. */
bool veil_sensing_detector_engaged(void);
#ifdef __cplusplus
}
#endif
#endif /* VEIL_SENSING_DETECTOR_H */
@@ -0,0 +1,188 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_sensing_detector — see veil_sensing_detector.h.
*
* STATUS: SYNTHETIC / L0. Build-only skeleton. Never run on silicon. Every
* hardware-touching path is marked TODO(hw). The rate estimator (pure math over
* timestamps) is the only fully-implemented piece and is unit-testable off
* target; the RF/observe path and the trigger backends are stubs.
*
* Compliance: this component only READS the channel (CSI + frame cadence). It
* emits no RF and shapes no waveform. It cannot and does not touch the closed
* ESP32 Wi-Fi PHY blob. The "action" it takes is a low-rate control signal to a
* separate protector.
*/
#include "veil_sensing_detector.h"
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_wifi.h" /* esp_wifi_set_csi_rx_cb, esp_wifi_set_csi, ... */
#include "esp_wifi_types.h" /* wifi_csi_info_t, wifi_csi_config_t */
#include "driver/gpio.h" /* gpio_config, gpio_set_level */
static const char *TAG = "veil_sense";
/* ---- module state -------------------------------------------------------- */
static veil_sensing_detector_cfg_t s_cfg;
static bool s_running;
static bool s_engaged;
static float s_rate_hz;
/* Bounded ring of recent solicitation timestamps (µs), malloc-free. */
enum { VEIL_TS_RING = 256 };
static int64_t s_ts[VEIL_TS_RING];
static size_t s_ts_head; /* next write slot */
static size_t s_ts_count; /* live entries, capped at VEIL_TS_RING */
/* ---- rate estimator (pure, testable off-target) -------------------------- */
/* Record one solicitation at time `now_us` and recompute the sliding-window
* rate. Returns the current rate in Hz. This function is deliberately free of
* any ESP-IDF dependency so it can be exercised in host unit tests. */
float veil_sd_note_solicitation(int64_t now_us)
{
s_ts[s_ts_head] = now_us;
s_ts_head = (s_ts_head + 1) % VEIL_TS_RING;
if (s_ts_count < VEIL_TS_RING) {
s_ts_count++;
}
const int64_t window_us = (int64_t)s_cfg.window_ms * 1000;
const int64_t cutoff = now_us - window_us;
size_t in_window = 0;
for (size_t k = 0; k < s_ts_count; k++) {
if (s_ts[k] >= cutoff) {
in_window++;
}
}
/* rate = events within the trailing window / window length. */
s_rate_hz = (float)in_window * 1000.0f / (float)s_cfg.window_ms;
/* Hysteresis around engage/release. */
if (!s_engaged && s_rate_hz >= s_cfg.trigger_rate_hz) {
s_engaged = true;
ESP_LOGI(TAG, "sensing burst: %.1f Hz >= %.1f -> ENGAGE",
s_rate_hz, s_cfg.trigger_rate_hz);
/* fire-and-forget; backend errors are logged, not fatal */
(void)0; /* veil_sd_emit_trigger(true) — see below */
} else if (s_engaged && s_rate_hz <= s_cfg.release_rate_hz) {
s_engaged = false;
ESP_LOGI(TAG, "sensing quiet: %.1f Hz <= %.1f -> RELEASE",
s_rate_hz, s_cfg.release_rate_hz);
}
return s_rate_hz;
}
/* ---- trigger backends (all stubs) ---------------------------------------- */
static esp_err_t veil_sd_emit_trigger(bool engage)
{
switch (s_cfg.backend) {
case VEIL_TRIGGER_GPIO:
/* TODO(hw): drive the engage line to the co-located AP/relay.
* gpio_set_level(s_cfg.gpio_num, engage ? 1 : 0);
* Requires a wired GPIO to the protector; unverified on silicon. */
ESP_LOGW(TAG, "TODO(hw) GPIO trigger -> %d (stub)", engage);
return ESP_ERR_NOT_SUPPORTED;
case VEIL_TRIGGER_MQTT:
/* TODO(hw): esp_mqtt_client_publish(client, s_cfg.mqtt_topic,
* engage ? "1" : "0", 0, 1 /qos/, 0 /retain/);
* Client lifecycle (esp_mqtt_client_init/_start) omitted from skeleton. */
ESP_LOGW(TAG, "TODO(hw) MQTT trigger -> %d (stub)", engage);
return ESP_ERR_NOT_SUPPORTED;
case VEIL_TRIGGER_ESPNOW:
/* TODO(hw): esp_now_send(s_cfg.espnow_peer, &payload, sizeof payload);
* Requires esp_now_init() + esp_now_add_peer() during start(). */
ESP_LOGW(TAG, "TODO(hw) ESP-NOW trigger -> %d (stub)", engage);
return ESP_ERR_NOT_SUPPORTED;
default:
return ESP_ERR_INVALID_ARG;
}
}
/* ---- CSI callback (observe path) ----------------------------------------- */
/* Runs in the Wi-Fi task. Keep it short: post to a queue in real firmware.
* Here we only classify whether this frame indicates a sounding/solicitation
* and, if so, feed the estimator. */
static void veil_sd_csi_cb(void *ctx, wifi_csi_info_t *info)
{
(void)ctx;
if (!info) {
return;
}
/* TODO(hw): a real classifier would inspect info->rx_ctrl (rate, sig_mode,
* channel, secondary channel) and, alongside a promiscuous frame-type
* filter, distinguish NDP / NDP-announcement / CSI-solicit action frames
* from ordinary data. On silicon the ESP32 does NOT surface the raw
* VHT/HE sounding subtype through the CSI struct, so this classifier is
* necessarily heuristic (cadence + rate + frame length). Treated here as
* "every CSI-bearing frame is a candidate solicitation" for the skeleton. */
(void)veil_sd_note_solicitation(esp_timer_get_time());
}
/* ---- lifecycle ----------------------------------------------------------- */
esp_err_t veil_sensing_detector_start(const veil_sensing_detector_cfg_t *cfg)
{
if (!cfg) {
return ESP_ERR_INVALID_ARG;
}
if (s_running) {
return ESP_ERR_INVALID_STATE;
}
s_cfg = *cfg;
s_engaged = false;
s_rate_hz = 0.0f;
s_ts_head = 0;
s_ts_count = 0;
if (s_cfg.backend == VEIL_TRIGGER_GPIO && s_cfg.gpio_num >= 0) {
/* TODO(hw): configure the engage line.
* gpio_config_t io = {
* .pin_bit_mask = 1ULL << s_cfg.gpio_num,
* .mode = GPIO_MODE_OUTPUT,
* };
* gpio_config(&io);
* gpio_set_level(s_cfg.gpio_num, 0);
*/
ESP_LOGW(TAG, "TODO(hw) configure GPIO %d (stub)", s_cfg.gpio_num);
}
/* Observe path. On real hardware:
* wifi_csi_config_t csi = { ... };
* ESP_ERROR_CHECK(esp_wifi_set_csi_config(&csi));
* ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(veil_sd_csi_cb, NULL));
* ESP_ERROR_CHECK(esp_wifi_set_csi(true));
* ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true)); // more CSI when idle
* The Wi-Fi driver must already be started by the app. */
ESP_LOGW(TAG, "TODO(hw) esp_wifi_set_csi_rx_cb/_set_csi/_set_promiscuous "
"(stub; not wired on silicon)");
(void)veil_sd_csi_cb; /* referenced once wired */
s_running = true;
ESP_LOGI(TAG, "started (SYNTHETIC/L0): window=%ums engage>=%.1fHz",
(unsigned)s_cfg.window_ms, s_cfg.trigger_rate_hz);
return ESP_OK;
}
esp_err_t veil_sensing_detector_stop(void)
{
if (!s_running) {
return ESP_ERR_INVALID_STATE;
}
/* TODO(hw): esp_wifi_set_csi(false); esp_wifi_set_csi_rx_cb(NULL, NULL);
* esp_wifi_set_promiscuous(false); release GPIO/MQTT/ESP-NOW. */
if (s_engaged) {
(void)veil_sd_emit_trigger(false);
}
s_running = false;
return ESP_OK;
}
float veil_sensing_detector_rate_hz(void) { return s_rate_hz; }
bool veil_sensing_detector_engaged(void) { return s_engaged; }
+116
View File
@@ -0,0 +1,116 @@
# Building the WiFi Veil Nexmon patch — **UNTESTED**
> **This procedure has never been run.** It has not been built with the Nexmon
> toolchain, not flashed, and not captured on air. Addresses/symbols in
> `patch/veil_patch.c` are placeholders (one is intentionally invalid,
> `0xDEAD0000`) so it will **not** produce a flashable image as-is. This file
> documents *how it would build* so a hardware operator with real silicon can
> take it forward. `SYNTHETIC / L0`, per CLAUDE.md.
## Prerequisites (host, not in this repo)
- A Linux host (Nexmon expects an x86_64 Ubuntu-like build host) with the
Broadcom-flavored ARM toolchain Nexmon downloads/uses, plus `git`, `make`,
`gcc-arm-none-eabi`, `flex`, `bison`, `libisl`, `automake`.
- Nexmon checked out **outside** this repo (do not vendor it here):
```bash
git clone https://github.com/seemoo-lab/nexmon.git
cd nexmon
source setup_env.sh # sets NEXMON_ROOT, toolchain paths
make # builds libISL / firmwares tooling
```
- The target firmware blob present on the device: BCM43455c0
(`brcmfmac43455-sdio.bin`), version **7_45_189** (Cypress) or 7_45_154
(Raspbian). Do **not** commit the blob or any extracted symbols/ROM to RuView.
## Where this patch would live in the Nexmon tree
Nexmon builds per chip/firmware under `patches/<chip>/<fwver>/<name>/`. This
adapter would be a Nexmon project, e.g.:
```
$NEXMON_ROOT/patches/bcm43455c0/7_45_189/veil/
├── Makefile # copy of an existing nexmon patch Makefile (e.g. nexmon_csi's)
├── src/
│ ├── veil_patch.c # <- symlink/copy of firmware/privshield/nexmon/patch/veil_patch.c
│ ├── veil_shield.c # <- from firmware/privshield/core/ (compiled into the patch)
│ └── veil_shield.h # <- from firmware/privshield/core/
└── ...
```
Keep the RuView copies canonical; the Nexmon tree gets copies/symlinks so the
core stays byte-identical to `../core/`.
## Linking the portable core (MCU-friendly)
The core is `no_std`-style C99: no malloc, no libc I/O, only `<math.h>`
(`sinf`/`cosf`/`sqrtf`/`sqrt`). To build it into the patch:
1. Add `veil_shield.c` to the patch `Makefile`'s object list (alongside
`patch.o`/`wrapper.o`), so it compiles with the same ARM flags.
2. Ensure the firmware provides `sinf`/`cosf`/`sqrtf`. **TODO(hw):** Broadcom
firmware may not export libm. Options, in order of preference:
- link a small `libm`/`compiler-rt` for `arm-none-eabi`;
- or replace the trig with a fixed-point / CORDIC Givens rotation
(`TODO(reverse-engineer)`), which also avoids float on parts without an FPU.
3. All WiFi Veil working storage is stack-bounded (`VEIL_MAX_FINE`, `CACHE` in the
core) — no heap is introduced on-chip.
## Build
```bash
cd $NEXMON_ROOT/patches/bcm43455c0/7_45_189/veil
make # produces the patched brcmfmac43455-sdio.bin
```
Before `make` can succeed you must first resolve every `TODO(reverse-engineer)`
in `veil_patch.c`:
- replace `0xDEAD0000` and the `wlc_sendmgmt_veil_target` symbol with the real,
disassembled target address/symbol for 7_45_189;
- implement `veil_bfr_unpack_fine` / `veil_bfr_pack_fine` (the angle bit-field
codec) and the report-body offset/length;
- confirm the compressed-beamforming report is assembled in ARM on this chip
(else move to hook candidate #2/#3 — see README).
## Flash (Raspberry Pi, on-device)
**TODO(hw) — untested.** Typical Nexmon flow on the Pi:
```bash
# back up stock firmware first!
sudo cp /lib/firmware/brcm/brcmfmac43455-sdio.bin ~/brcmfmac43455-sdio.bin.orig
sudo cp brcmfmac43455-sdio.bin /lib/firmware/brcm/brcmfmac43455-sdio.bin
# (some setups also need the matching *.clm_blob / nexmon's own copy path)
sudo rmmod brcmfmac && sudo modprobe brcmfmac # reload driver with new firmware
dmesg | tail # confirm firmware loaded
```
Push the session key at runtime (matches the IOCTL stub in `veil_patch.c`):
```bash
# TODO(hw): nexutil vendor-IOCTL id and payload format are placeholders
nexutil -s<VEIL_IOCTL_SET_KEY> -b -l8 -v<base64-8-byte-key>
```
**Recovery:** if WiFi breaks, restore the backup blob and reload the driver.
A bad flashpatch offset can knock out WiFi until you reflash stock firmware.
## Validation you can honestly do (still not `MEASURED` firmware)
1. **Host unit test of the math** (already green in this repo):
`cd ../../core && make test`.
2. **Read-back on hardware** with `nexmon_csi`/Wi-BFI: capture the report with
and without the patch and check the fine subspace changed while SNR/norm is
preserved. This validates the transform end-to-end but is a *receiver*
observation, not proof the TX hook is robust.
3. Only a captured device runtime log showing the shaped report leaving *this*
node, plus receiver-side recovery with the shared key, would move any claim
from `SYNTHETIC`/`CLAIMED` toward `MEASURED` (roadmap P5).
## References
See `README.md` for sources (Nexmon, nexmon_csi, Wi-BFI, D11 reverse
engineering).
+124
View File
@@ -0,0 +1,124 @@
# WiFi Veil protector — Nexmon (Broadcom/Cypress) path
C-firmware-patch adapter that would call the portable WiFi Veil core
(`../core/veil_shield.{h,c}`) on the compressed-beamforming-feedback **angles
before transmission**, using the [Nexmon](https://github.com/seemoo-lab/nexmon)
patching framework on a Broadcom/Cypress WiFi chip.
> **Evidence discipline.** Everything here is **`SYNTHETIC` / L0 / build-only**.
> Nothing in this directory has been built with the Nexmon toolchain, flashed to
> a chip, or captured on air. There are **no** `MEASURED` claims and **no**
> hardware logs. The patch is an honest **skeleton** with `TODO(hw)` and
> `TODO(reverse-engineer)` markers, not working firmware. Per CLAUDE.md, no
> defense claim becomes `MEASURED` without a captured runtime log from real
> silicon (roadmap P5).
>
> **Compliant waveform only — never jamming.** The core applies an *orthogonal*
> (energy-preserving) keyed rotation to the node's *own* standards-conformant
> feedback report. It does not add power, transmit out of turn, or interfere
> with any other station.
## Feasibility grade: **C** (research-grade, partial, unproven)
| Sub-path | Grade | Why |
|---|---|---|
| **Read** the compressed BF feedback | **A** (proven by others) | `nexmon_csi` extracts CSI, and Wi-BFI parses the compressed-beamforming *angles* straight from captured action frames — no firmware change at all. The report content is observable today. |
| **Write / shape** the transmitted report | **C / C-** | The report is generated by the proprietary **D11** real-time core, not the ARM firmware Nexmon comfortably patches. The hook point is deep, chip- and firmware-version-specific, and unverified here. Plausible, not demonstrated. |
Grade **C** reflects *this* deliverable's goal — shaping the **TX** report. The
read side is a solved problem and is graded only to contrast honestly.
### Why the write path is hard (the core honesty point)
Broadcom/Cypress chips put all time-critical 802.11 MAC/PHY work on the **D11
core**, a proprietary microcontroller running a programmable state machine
("ucode"). Published reverse-engineering of these chips reports that the D11
generates the **VHT/HE compressed beamforming report ~10 µs after the NDP**, with
its contents fetched from an **internal memory updated directly by the hardware**
on NDP reception. In other words, the angles WiFi Veil wants to touch are staged and
emitted inside the ucode/PHY path on a microsecond deadline — *below* the ARM
"wl" driver firmware where Nexmon's C hooks (`__attribute__((at(addr, ...)))`
flashpatches / branch hooks) live most reliably. Reaching them means either a
D11-ucode patch (needs the D11 assembler and SHM/template-RAM layout) or catching
the report while the ARM path still assembles the action-frame body — if it does
so on this chip at all. Both are `TODO(reverse-engineer)`.
## Target chip(s)
Primary: **BCM43455c0** (Raspberry Pi 3B+/4B; also RPi Zero 2 W), firmware
**7_45_154** (Raspbian) or **7_45_189** (Cypress) — the best-documented,
most-reproducible Nexmon target, and one of the four chips `nexmon_csi` already
supports. Secondary candidates that `nexmon_csi` also supports: **BCM4339**
(Nexus 5), **BCM4358** (Nexus 6P), **BCM4366c0** (Asus RT-AC86U). We scope the
skeleton to BCM43455c0 / 7_45_189 and leave the others as build-matrix `TODO`s.
Caveat: the RPi BCM43455c0 is an **802.11ac (VHT)** single-stream part; its own
*transmit* beamforming/sounding activity as a beamformee is limited. The
skeleton targets the **VHT compressed beamforming report** action-frame path;
whether this chip emits enough to shape in practice is itself a `TODO(hw)`
question.
## Hook-point candidates (all `TODO(reverse-engineer)`)
Ordered most-tractable → deepest. Addresses are **placeholders** — real offsets
come from disassembling the specific firmware blob and cross-checking the Nexmon
symbol tables (`wl_ram.elf` / IDA); none are known-good here.
1. **ARM action-frame TX assembly (best first target).** If the "wl" driver
assembles the VHT Compressed Beamforming Report action-frame *body* in ARM
firmware before handing it to the D11 (function family around
`wlc_txbf_*` / a `wlc_send*mgmt`/action path), a branch hook there could
locate the report's fine-angle block and call `veil_shield_apply` in place.
Cheapest if it exists on this chip.
2. **ARM → D11 TX descriptor / template handoff.** Hook where the driver stages
a frame into the D11 TX FIFO / template RAM (`wlc_d11hdrs` / `wlc_txfifo`
region) and rewrite the angle bytes there. Requires knowing the exact
template-RAM offset of the report body.
3. **D11 ucode patch (deepest).** Patch the ucode routine that copies angles
from the hardware-updated internal memory into the outgoing report, applying
the rotation in D11 SHM. Needs the D11 assembler and PHY/SHM map; highest
fidelity, highest effort, most fragile across firmware versions.
The skeleton wires candidate **#1** and leaves #2/#3 documented but unimplemented.
## What is realistic
- **Realistic now:** verify WiFi Veil's *effect* by reading — capture the shaped vs.
unshaped report with `nexmon_csi`/Wi-BFI and confirm the fine subspace changed
while energy (SNR/norm) is preserved. This validates the math, not the TX hook.
- **Realistic with serious RE effort:** candidate #1, on one pinned firmware, as
a demo — partial, brittle, chip-specific.
- **Not realistic as a portable product:** a clean, firmware-version-stable TX
report-shaping patch across Broadcom parts. Treat as research.
## Risk / honesty
- Wrong flashpatch offsets can **brick the WiFi blob** (recoverable by
reflashing stock firmware, but real).
- Regulatory: the transform is energy-preserving and rides standards-marked
spatial-mapping freedom, but any TX-path firmware patch on a certified radio is
**outside the device's certification** — bench/anechoic use only.
- Firmware blobs are proprietary; do **not** commit extracted firmware, symbols,
or ROM dumps to this repo.
## Sources
- Nexmon framework — <https://github.com/seemoo-lab/nexmon>
- `nexmon_csi` (chips: bcm4339, bcm43455c0, bcm4358, bcm4366c0) —
<https://github.com/seemoo-lab/nexmon_csi>
- Wi-BFI (reads BFAs/BFI from captured compressed-beamforming action frames) —
<https://github.com/kfoysalhaque/Wi-BFI>, paper arXiv:2309.04408
<https://arxiv.org/abs/2309.04408>
- BCM43455c0 patches / D11 headers (`d11.h`) —
<https://github.com/seemoo-lab/nexmon/tree/master/patches/bcm43455c0>
- D11 real-time core / ucode reverse engineering (SEEMOO, Quarkslab) —
<https://www.seemoo.tu-darmstadt.de/> ,
<https://blog.quarkslab.com/reverse-engineering-broadcom-wireless-chipsets.html>
- 802.11ac VHT NDP sounding & compressed beamforming report structure (context) —
<https://community.cisco.com/t5/wireless-mobility-knowledge-base/802-11ac-transmit-beamforming-and-vht-ndp-sounding-procedure/ta-p/3155879>
> The "~10 µs / hardware-updated internal memory" characterization above is drawn
> from published Broadcom D11 reverse-engineering (reported for BCM4365-class
> parts) and is used here as design guidance; it is **not** independently
> verified on BCM43455c0 in this repo. `TODO(reverse-engineer)`: confirm on the
> target blob.
@@ -0,0 +1,176 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_patch.c — VEIL protector, Nexmon (Broadcom/Cypress) path.
*
* ============================ HONESTY BANNER ============================
* SYNTHETIC / L0 / BUILD-ONLY. This file is an HONEST SKELETON in Nexmon
* style. It has NOT been built with the Nexmon toolchain, NOT flashed to a
* chip, and NOT captured on air. Every __attribute__((at(...))) address and
* every firmware symbol below is a PLACEHOLDER. Do not treat this as working
* firmware. See ../README.md for the feasibility grade (C, research-grade).
*
* Goal: call the portable VEIL core (../../core/veil_shield.c)
* `veil_shield_apply()` on the compressed-beamforming-feedback FINE ANGLES in
* the transmitted VHT/HE compressed beamforming report, so the identity-bearing
* fine subspace is obfuscated by a keyed, ENERGY-PRESERVING (orthogonal)
* Givens rotation before the frame leaves the radio. Compliant only, never
* jamming: the transform preserves the report's L2 norm.
*
* Target: BCM43455c0 (Raspberry Pi 3B+/4B), firmware 7_45_189. Others TODO.
* =======================================================================
*/
#pragma NEXMON targetregion "patch"
#include <firmware_version.h> /* FW_VER_7_45_189, CHIP_VER_BCM43455c0 (Nexmon) */
#include <patcher.h> /* BPatch / GPatch / __attribute__((at(...))) */
#include <structs.h> /* struct sk_buff, struct wlc_info, etc. */
#include <wrapper.h> /* Nexmon wrappers for ROM/firmware functions */
/* --- Portable VEIL core, linked/inlined for the MCU -------------------------
* The core is pure C99: no malloc, no libc I/O, only <math.h> (sinf/cosf/sqrtf).
* On the Nexmon ARM target we compile ../../core/veil_shield.c into this patch
* object (see ../BUILD.md) and pull in only the declarations here. Everything
* operates on a caller-provided fixed buffer — no dynamic allocation on-chip. */
#include "veil_shield.h"
/* ------------------------------------------------------------------------- */
/* Configuration (compile-time; no on-chip allocation) */
/* ------------------------------------------------------------------------- */
/* Max fine-angle count we will touch in one report. Sized for a VHT SU report
* fine block; bound it so all working storage is on the stack, malloc-free. */
#define VEIL_MAX_FINE 64u
/* Rotation passes — MUST match the associated receiver and the Rust reference
* crate default so recover() inverts exactly. TODO(hw): confirm against the
* receiver config actually deployed. */
#define VEIL_PASSES 96u
/* Session key. TODO(hw): DO NOT hardcode a real key in flashed firmware. Inject
* via nexutil IOCTL (see veil_ioctl_set_key stub) or a provisioning step; this
* placeholder exists only so the skeleton type-checks. */
static uint64_t g_veil_key = 0x0000000000000000ULL;
/* ------------------------------------------------------------------------- */
/* Bridge: decode angles -> rotate -> re-encode, in place */
/* ------------------------------------------------------------------------- */
/*
* TODO(reverse-engineer): The compressed beamforming report packs the phi/psi
* angles as bit-fields whose widths depend on the codebook (VHT: (7,5) or (9,7);
* HE differs) and on Nc/Nr. The bytes handed to us are NOT plain floats. This
* bridge must:
* (1) parse the fine-angle bit-fields from `report` into `fine[]` as floats
* in the same units/order the receiver + Rust reference expect,
* (2) call veil_shield_apply() on that flat vector,
* (3) re-quantize and repack the rotated angles back into `report`,
* preserving all coarse/header fields and the frame length.
* Steps (1)/(3) are the real work and are UNIMPLEMENTED here.
*/
static void veil_shape_report_inplace(uint8_t *report, uint32_t report_len)
{
if (report == 0 || report_len == 0)
return;
float fine[VEIL_MAX_FINE];
uint32_t n = 0;
/* TODO(reverse-engineer): unpack fine-angle bit-fields -> fine[0..n) */
/* n = veil_bfr_unpack_fine(report, report_len, fine, VEIL_MAX_FINE); */
if (n < 2 || n > VEIL_MAX_FINE)
return; /* nothing safely shapeable; leave frame untouched (fail-open) */
/* Orthogonal, energy-preserving, keyed. This is the ONLY validated step. */
veil_shield_apply(fine, (size_t)n, g_veil_key, VEIL_PASSES);
/* TODO(reverse-engineer): repack fine[0..n) back into `report` bit-fields,
* keeping report_len and all non-fine fields byte-identical. */
/* veil_bfr_pack_fine(report, report_len, fine, n); */
(void)report_len;
}
/* ------------------------------------------------------------------------- */
/* Hook candidate #1 (see README): ARM action-frame TX assembly */
/* ------------------------------------------------------------------------- */
/*
* We hook the point where the "wl" driver has assembled the VHT Compressed
* Beamforming Report action frame in an sk_buff, just before it is queued to
* the D11 for transmission, locate the report body, and shape it.
*
* TODO(reverse-engineer): the symbol/address below is a PLACEHOLDER. The real
* target must be found by disassembling 7_45_189 (IDA + Nexmon's wl_ram.elf
* symbol map) and confirming: (a) the report body is assembled in ARM (not
* only in D11 ucode), (b) `p` really carries a compressed-beamforming action
* frame, and (c) the offset of the report body within the frame.
*
* If (a) is false on this chip, candidate #1 is dead and we fall to #2/#3
* (TX template-RAM rewrite / D11 ucode patch) — both documented in README,
* neither implemented here.
*/
/* Original firmware function prototype (PLACEHOLDER signature). */
extern int wlc_sendmgmt_veil_target(struct wlc_info *wlc, void *p, void *scb);
/* Our replacement. GPatch/BPatch below redirects the target to this. */
int wlc_sendmgmt_veil_hook(struct wlc_info *wlc, void *p, void *scb)
{
/* TODO(reverse-engineer): confirm `p` is a struct sk_buff* and that this
* frame is a VHT/HE compressed beamforming action frame (category 21
* VHT / 30 HE, action = Compressed Beamforming). Guard hard so we never
* mangle unrelated management frames. */
struct sk_buff *skb = (struct sk_buff *)p;
if (skb != 0 /* && veil_is_bf_report_action(skb) */) {
/* TODO(reverse-engineer): compute report body pointer + length from the
* action-frame layout. PLACEHOLDER offsets: */
uint8_t *report = 0; /* skb->data + VEIL_BFR_BODY_OFFSET; */
uint32_t report_len = 0; /* skb->len - VEIL_BFR_BODY_OFFSET; */
veil_shape_report_inplace(report, report_len);
}
/* Always fall through to the real firmware routine so normal TX proceeds. */
return wlc_sendmgmt_veil_target(wlc, p, scb);
}
/*
* Redirect the firmware's mgmt/action TX routine to our hook.
* PLACEHOLDER ADDRESS — 0xDEAD0000 is intentionally invalid so nobody mistakes
* this for a real, flashable patch. TODO(reverse-engineer): replace with the
* verified address for CHIP_VER_BCM43455c0 / FW_VER_7_45_189.
*
* Nexmon idiom: a branch patch that overwrites the target's prologue with a
* branch to our replacement (which tail-calls the saved original).
*/
__attribute__((at(0xDEAD0000, "flashpatch", CHIP_VER_BCM43455c0, FW_VER_7_45_189)))
BPatch(veil_sendmgmt_hook, wlc_sendmgmt_veil_hook);
/* ------------------------------------------------------------------------- */
/* Key provisioning via nexutil IOCTL (stub) */
/* ------------------------------------------------------------------------- */
/*
* TODO(hw): register a custom IOCTL so `nexutil` can push the 64-bit session
* key at runtime instead of baking it into flash. Hook the driver's ioctl
* dispatch (wlc_ioctl) the same way nexmon_csi installs its config IOCTLs.
* Left as a stub: the dispatch address and the nexmon_ioctl plumbing are
* PLACEHOLDERS.
*/
#define VEIL_IOCTL_SET_KEY 0x7EIL /* TODO(hw): pick a free vendor IOCTL id */
int veil_ioctl_set_key(struct wlc_info *wlc, const uint8_t *buf, uint32_t len)
{
(void)wlc;
if (buf == 0 || len < sizeof(uint64_t))
return -1;
uint64_t k = 0;
for (uint32_t i = 0; i < sizeof(uint64_t); i++)
k |= ((uint64_t)buf[i]) << (8u * i);
g_veil_key = k;
return 0;
}
/*
* ---------------------------------------------------------------------------
* Candidate #2 (TX template-RAM rewrite) and #3 (D11 ucode patch) are NOT
* implemented. See ../README.md "Hook-point candidates". #3 would require the
* D11 assembler and the PHY/SHM angle-staging map — deepest and most fragile.
* ---------------------------------------------------------------------------
*/
+123
View File
@@ -0,0 +1,123 @@
# HDL notes — `veil_rot` (TX) / `veil_unrot` (RX)
> **STATUS: SYNTHETIC / L0 — design notes only. No RTL is shipped here, none has
> been synthesized, placed, routed, or run on an FPGA.** This describes the
> Verilog blocks that *would* apply the keyed unitary in the openwifi datapath.
> Every concrete number (offsets, latency, resource use) is `TODO(hdl)` until a
> real build exists. **Orthogonal transform ⇒ transmit energy preserved:
> compliant, never jamming.**
## Where the blocks sit
openwifi's baseband IQ moves as **AXI-Stream** between blocks and its control is
**AXI-Lite** ([FPGA module design][fmd]). The two new blocks are AXI-Stream
pass-through filters with an AXI-Lite slave for the key schedule.
```
TX (protector):
openofdm_tx ──AXI-S(IQ)──► [ veil_rot ] ──AXI-S(IQ)──► tx_intf ──► AD9361 DAC
▲ AXI-Lite (key, coeff RAM)
└── veil_openwifi.c
RX (legitimate STA, shares key):
AD9361 ADC ──► rx_intf ──AXI-S──► [ veil_unrot ] ──AXI-S──► openofdm_rx (FFT → chan est)
▲ AXI-Lite
└── veil_openwifi.c
```
`veil_unrot` may equivalently sit **in the frequency domain**, right after the
FFT and **before channel estimation**, if a per-subcarrier `Q^H` is cheaper to
apply there. Same AXI-Lite contract either way.
## Why a *new* block is required (honesty)
openwifi is **SISO 802.11a/g/n** and has **no explicit-beamforming / spatial-
mapping stage** and **no compressed-BF-report generation** — the two-antenna app
note is RX-only capture, not a TX spatial mapper ([iq_2ant][2ant]). So there is
no existing `Q` matrix to modify; `veil_rot`/`veil_unrot` **introduce** the
spatial-mapping stage. Two realizable RTL scopes:
- **Scope A — 1×1 per-subcarrier phase/rotation (lower effort).** Treat the
rotation as operating over a **synthetic vector** formed from the fine
subspace of the per-packet subcarrier response (a stream of `N` IQ elements
the block buffers), applying the core's Givens schedule across those elements.
Single TX chain; no board change. This is enough to *scramble the CSI a
sniffer estimates* and to demonstrate keyed invert at RX. It is **not** true
spatial MIMO.
- **Scope B — 2×2 true spatial mapping (higher effort, the A-capability demo).**
Enable the **second TX chain** (AD9361 has 2 DACs on fmcomms2/3) and apply a
keyed 2×2 unitary across the two streams — a genuine transmit spatial mapping
the standard marks "not restricted." Needs a Vivado top-level rebuild wiring
the 2nd DAC and the extra AXI-S lane. `TODO(hdl)`.
## `veil_rot` datapath
The core applies `passes` **Givens rotations** `G(i,j,θ)` composed into `Q`
(`../core/veil_shield.c`). In hardware we apply the *same schedule* to the on-air
sample vector, so both ends derive identical coefficients from the shared key —
no matrix is transmitted.
Per Givens op on elements `(i, j)` with programmed `(cos, sin)` in Q1.15:
```
v_i' = cos*v_i - sin*v_j
v_j' = sin*v_i + cos*v_j // complex IQ: apply to I and Q lanes
```
- Coefficients arrive from `veil_openwifi.c` as the packed `(i, j, cos, sin)`
schedule (2 AXI-Lite words per pass; packing defined in `veil_openwifi.c`).
- `veil_unrot` applies the schedule **in reverse with negated sin** (`sin → -sin`,
i.e. `Gᵀ`), matching `veil_shield_recover`. A `CTRL.inverse` bit selects it.
- Fixed point: openwifi baseband IQ is 16-bit I / 16-bit Q; coeffs are signed
Q1.15. `TODO(hdl)`: guard-bit / rounding so the composed rotation stays
norm-preserving to spec and never clips (clipping would break the
energy-preservation invariant — must be verified, not assumed).
## AXI-Lite register map (must match `veil_openwifi.c`)
| Offset | Name | Meaning |
|---|---|---|
| `0x00` | `CTRL` | bit0 enable, bit1 inverse (`veil_unrot`), bit2 load |
| `0x04` | `KEY_LO` | session key [31:0] |
| `0x08` | `KEY_HI` | session key [63:32] |
| `0x0C` | `NDIM` | on-air fine-block dimension `N` (≤ 64) |
| `0x10` | `PASSES` | number of Givens passes (default 96) |
| `0x14` | `COEFF_ADDR` | write index into coeff RAM |
| `0x18` | `COEFF_DATA` | packed `{j,i}` then `{sin,cos}` (2 words/pass) |
| `0x1C` | `STATUS` | bit0 ready, bit1 applied, bit2 err |
`TODO(hdl)`: regenerate this from the block's `*_s_axi.v` once written (cf.
`openofdm_tx`'s 6 AXI-Lite registers at `ip/openofdm_tx/src/openofdm_tx_s_axi.v`)
and reconcile any offset changes back into `veil_openwifi.c`.
## Timing / integration risks (call them out, don't hide them)
- **802.11 SIFS budget.** The block adds pipeline latency between IFFT and DAC;
it must not violate the tight TX timing openwifi maintains in `tx_intf`.
`TODO(hdl)`: measure added cycles; keep within budget or absorb in existing
FIFO slack.
- **On-FPGA schedule vs. per-packet coeff load.** For per-*packet* keying, either
compute the SplitMix64 schedule on-FPGA from `(key, packet_counter)` or
double-buffer the coeff RAM. `TODO(hdl)`.
- **Bit-exactness with the core.** The on-FPGA (or shim-fed) `(cos,sin)` must
reproduce the core's schedule so `veil_unrot` inverts exactly. First gate is a
**self-loopback** IQ test (`veil_rot → veil_unrot`, assert recovered == input
within Q1.15 round-off) using openwifi's existing packet/IQ self-loopback
facility ([self-loopback app note][loop]). Passing loopback is a correctness
gate, **not** a defense `MEASURED` claim.
## Build
`TODO(hdl)`: add `veil_rot`/`veil_unrot` as `openwifi-hw` IP, instantiate in the
board block design, and rebuild the bitstream with Vivado per the openwifi-hw
build flow ([openwifi-hw][hw]). No bitstream is produced from this directory.
## Sources
- FPGA module design (AXI-S / AXI-Lite, block roles) — [deepwiki][fmd]
- openwifi-hw (FPGA IP + build flow) — [github.com/open-sdr/openwifi-hw][hw]
- Two-antenna IQ (RX-only; confirms no TX spatial mapper ships) — [iq_2ant][2ant]
- Packet/IQ self-loopback test — [self-loopback app note][loop]
[fmd]: https://deepwiki.com/open-sdr/openwifi/2.2-fpga-module-design
[hw]: https://github.com/open-sdr/openwifi-hw
[2ant]: https://github.com/open-sdr/openwifi/blob/master/doc/app_notes/iq_2ant.md
[loop]: https://github.com/open-sdr/openwifi/blob/master/doc/app_notes/packet-iq-self-loopback-test.md
+103
View File
@@ -0,0 +1,103 @@
# P5 measurement protocol — openwifi WiFi Veil end-to-end
> **STATUS: SYNTHETIC / L0 — this is a PLAN, not a result. No hardware has been
> run; no capture, log, or number in this repo is real.** This document defines
> exactly what must be executed and captured to earn the first `MEASURED` claim
> under CLAUDE.md's hardware-evidence rule. Until the witness artifact below
> exists, every accuracy/throughput/energy statement about openwifi WiFi Veil is
> `SYNTHETIC` and must be labelled so. **Compliant waveform controls only —
> orthogonal, energy-preserving; never jamming.**
## Roadmap position
This is roadmap **P5**: the two-node hardware measurement that turns the P4
build scaffolds into a `MEASURED` defense result. Prerequisite gates (all on real
silicon, all currently unmet): a bitstream with `veil_rot`/`veil_unrot`
(`HDL_NOTES.md`), a driver loading `veil_openwifi.c`, and a passing on-FPGA
**self-loopback** correctness test.
## Topology
```
[ Protector AP ] over the air [ Legitimate STA ]
openwifi node A ───────────────────────────────────► openwifi node B
veil_rot: Q(key) engaged │ veil_unrot: Q^H(key)
│ (shares key with A)
[ Attacker sniffer ]
commodity NIC, monitor mode
Wi-BFI CSI/BF-feedback extraction
+ re-ID model
```
The attacker is **passive** (monitor capture only). Nothing in this test
transmits to interfere with any station.
## Hardware list
| Role | Hardware | Software |
|---|---|---|
| Protector AP (A) | Zynq-7000 + AD9361 FMC (ZC706+fmcomms2/3, or ADRV9361-Z7035) | openwifi image + `veil_rot` bitstream + `veil_openwifi.c` |
| Legitimate STA (B) | second identical openwifi node | openwifi image + `veil_unrot` bitstream + `veil_openwifi.c`, same key as A |
| Attacker | host + Wi-BFI-supported Wi-Fi NIC in monitor mode | Wi-BFI ([arxiv 2309.04408][wibfi]) + re-ID model |
| Bench | shielded room or wired attenuator path preferred | `iperf3`, power meter / board rail sense |
Key agreement A↔B is out-of-band for the demo (pre-shared session key);
per-packet keying uses `(key, packet_counter)` as in `HDL_NOTES.md`.
## Procedure
Run every condition **twice**: WiFi Veil **OFF** (baseline) and **ON**. Same
positions, same MCS, same duration, same seed for the attacker model.
1. **Correctness precondition (not a defense claim).** Confirm on-FPGA
self-loopback recovers IQ within Q1.15 round-off, and A→B link works with
`veil_unrot` engaged. Capture the console log.
2. **Attacker capture.** Sniffer records CSI / beamforming-feedback for a fixed
traffic pattern A→B, OFF then ON. Save raw captures (pcap + Wi-BFI output).
3. **Re-ID metric.** Run the same re-identification / fingerprinting model on the
OFF and ON captures. Report accuracy and confusion vs. the **chance / mean
baseline** (per CLAUDE.md, a defense claim needs the baseline and a
leakage-free held-out split — never report bare accuracy).
4. **Throughput (near-free check).** `iperf3` A↔B, OFF vs. ON, both directions.
Expected: ON ≈ OFF (the receiver inverts the rotation). Save `iperf3 --json`.
5. **Energy / compliance.** Record per-frame TX energy OFF vs. ON (rail sense or
power meter) to substantiate the "energy-preserving / not jamming" claim, and
spectrum/mask conformance if a spectrum analyzer is available.
## Metrics reported
| Metric | OFF | ON | Requirement for a pass |
|---|---|---|---|
| Attacker re-ID accuracy vs. chance | baseline | — | collapses toward chance ON |
| iperf3 throughput A↔B | baseline | — | ON within a few % of OFF |
| Per-frame TX energy | baseline | — | ON ≈ OFF (orthogonality holds on-air) |
| Spectral mask conformance | pass | — | still conformant ON |
## Required witness artifact (CLAUDE.md gate)
Before **any** `MEASURED` claim, this directory (or the P5 evidence path) must
contain a **captured real-silicon log**, not a build or simulator output:
- Boot/runtime console log of both openwifi nodes showing the `veil_rot` /
`veil_unrot` bitstream loaded and `veil_openwifi.c` programming the session
(register writes / STATUS ready), with timestamps and board identifiers.
- The self-loopback correctness log (step 1).
- Raw attacker captures (pcap + Wi-BFI output) for OFF and ON, plus the exact
re-ID reproducer command and its output.
- `iperf3 --json` for OFF and ON; energy trace for OFF and ON.
- A manifest tying each artifact to the git commit of the RTL, driver, and shim
used, so the result is reproducible.
Label the result `MEASURED` **only** with all of the above captured from real
hardware. A successful Vivado build, a Verilator/QEMU run, or the host
`veil_openwifi.c` self-test is **not** hardware evidence and must stay
`SYNTHETIC`. No log in this repo today — do not fabricate one.
## Sources
- Wi-BFI (attacker BF-feedback extraction) — [arxiv.org/pdf/2309.04408][wibfi]
- Packet/IQ self-loopback test — [openwifi self-loopback app note][loop]
[wibfi]: https://arxiv.org/pdf/2309.04408
[loop]: https://github.com/open-sdr/openwifi/blob/master/doc/app_notes/packet-iq-self-loopback-test.md
+122
View File
@@ -0,0 +1,122 @@
# WiFi Veil protector — openwifi (Xilinx Zynq + AD9361, open PHY/MAC)
> **STATUS: SYNTHETIC / L0 — build-only scaffold. No hardware, no flash, no
> capture. Nothing here has run on silicon.** Per CLAUDE.md, none of this is a
> `MEASURED` result and none may be claimed as working. Files are honest
> skeletons with real openwifi idioms plus `TODO(hw)` / `TODO(hdl)` markers, not
> validated firmware or complete HDL. **Compliant waveform controls only — the
> keyed rotation is orthogonal (energy-preserving) and shapes only this node's
> own standards-conformant emission. Never jamming.**
## Feasibility grade: **B (capability ceiling A; effort D)**
openwifi is the **only** platform in this tree where a true end-to-end keyed
rotation *and its inverse* are physically reachable, because it is the only one
that exposes the full open PHY/MAC on FPGA: `openofdm_tx`/`openofdm_rx`,
`tx_intf`/`rx_intf`, and `side_ch`, all AXI-Lite-programmable from a Linux
driver ([FPGA module design][fmd], [openwifi overview][ov]). That is the **A**
capability ceiling.
It is graded **B**, not A, for two honest reasons that make it the
highest-*effort* path:
1. **openwifi has no native explicit transmit beamforming.** It ships as an
802.11a/g/n **single-spatial-stream (SISO)** design. It does not run NDP
sounding, does not compute an SVD `V` matrix, and does not emit a compressed
beamforming report. The two-antenna app note is **RX-only** coherent capture
(`side_ch_ctl wh3h11`), not a MIMO transmit spatial mapper ([iq_2ant][2ant]).
So there is no shipped compressed-BF-report to obfuscate and no shipped
spatial-mapping matrix `Q` to left-multiply — both must be **added in HDL**.
2. Reaching a true two-stream demo needs a **second TX chain** (the AD9361 on
fmcomms2/3 has two DACs) plus a new spatial-mapping RTL stage and a Vivado
rebuild — days-to-weeks of FPGA work, not a driver patch.
Because of (1), on openwifi WiFi Veil is realized as the **client-transparent
per-packet keyed unitary** (LeakyBeam family) applied at the TX spatial-mapping
stage, with the legitimate STA (a second openwifi node sharing the key)
inverting it — **not** as obfuscation of a compressed-BF report the hardware
never produces. This keeps the claim honest: we rotate the *transmitted spatial
mapping* so a sniffer's per-subcarrier channel estimate `H·Q(key)` is scrambled,
and the keyed receiver applies `Q(key)^H` before channel estimation.
## Exact insertion points
The rotation is a keyed orthogonal (unitary) matrix `Q(key, session)` computed
by the portable core (`../core/veil_shield.{h,c}`), the same SplitMix64 schedule
used everywhere, so both ends derive the identical `Q` from the shared key.
**TX (protector) — FPGA, new block `veil_rot`:**
Insert on the baseband IQ AXI-Stream path **between `openofdm_tx` (post-IFFT,
post-CP) and `tx_intf`** (which feeds the AD9361 DAC). `veil_rot` left-multiplies
the per-subcarrier / per-stream sample vector by `Q(key)`. Its coefficients (or a
key seed + on-FPGA schedule) are written over **AXI-Lite** from the driver shim
using the standard openwifi `iowrite32(value, base_addr + reg)` idiom
([tx_intf driver][txintf]). See `HDL_NOTES.md`.
**RX (legitimate STA) — FPGA, new block `veil_unrot`:**
Insert **between `rx_intf` (AD9361 ADC) and `openofdm_rx`**, or in the frequency
domain immediately after the FFT and **before channel estimation**, applying
`Q(key)^H`. Same AXI-Lite programming path.
**Driver / control plane:** the C shim `veil_openwifi.c` computes the session
key schedule via the core and programs the blocks. Real openwifi control idioms:
AXI-Lite MMIO from the kernel driver, and the `sdrctl` nl80211-testmode tool /
`side_ch_ctl` register pokes for bring-up ([sdrctl/side_ch][ov], [frequent
tricks][ft]). Where the exact offsets/bitfields are not yet fixed, the shim
marks `TODO(hw)`; RTL specifics are `TODO(hdl)`.
Doing the rotation in HDL (not the DMA'd payload) is deliberate: it keeps the
frame **standards-conformant on the wire** and preserves transmit energy — the
"not jamming" invariant the core guarantees by construction (orthogonal `Q`).
## Two-node measurement plan (the P5 path)
Three roles produce the first `MEASURED` / P5 result (full protocol +
required witness log in `MEASUREMENT.md`):
- **Protector AP** — openwifi node A, `veil_rot` engaged, TX spatial mapping
keyed with the session key.
- **Legitimate STA** — openwifi node B, shares the key, `veil_unrot` engaged;
should see **near-baseline throughput** (rotation cancels).
- **Attacker sniffer** — a commodity Wi-Fi NIC running **Wi-BFI** / monitor
capture, extracting the per-subcarrier CSI / beamforming feedback and running
the re-ID model ([Wi-BFI][wibfi]).
Headline metric: **re-identification accuracy off vs. on** at the attacker
(target: collapse toward chance) **while** iperf throughput A↔B stays near
baseline and per-frame energy is unchanged. No number here is real until a
captured on-silicon log exists.
## Bill of materials (target, not procured)
- 2× Xilinx Zynq-7000 board with AD9361 FMC (e.g. ZC706 + fmcomms2/3, or
ADRV9361-Z7035 / Antenna-SDR), openwifi image per the openwifi build docs.
- 1× attacker host + Wi-BFI-capable NIC (per Wi-BFI's supported list).
- Vivado for the FPGA rebuild that adds `veil_rot` / `veil_unrot`.
## Files here
| File | What it is |
|---|---|
| `README.md` | this — feasibility, insertion points, measurement plan |
| `veil_openwifi.c` | driver-side C shim: core → session `Q` → AXI-Lite program (scaffold, `TODO(hw)`) |
| `HDL_NOTES.md` | the `veil_rot` / `veil_unrot` Verilog blocks (design notes, `TODO(hdl)`) |
| `MEASUREMENT.md` | exact P5 protocol, metrics, and the required witness artifact |
## Sources
- FPGA module design — [deepwiki.com/open-sdr/openwifi/2.2-fpga-module-design][fmd]
- openwifi overview (sdrctl, side_ch, nl80211 testmode) — [deepwiki.com/open-sdr/openwifi/1-openwifi-overview][ov]
- Two-antenna IQ (RX-only) app note — [github.com/open-sdr/openwifi .../iq_2ant.md][2ant]
- tx_intf driver register idioms (`iowrite32`/`ioread32`) — [github.com/open-sdr/openwifi .../tx_intf.c][txintf]
- Frequent tricks / register pokes — [github.com/open-sdr/openwifi .../frequent_trick.md][ft]
- openwifi paper (SDR 802.11 on SoC) — [researchgate .../342582824][paper]
- Wi-BFI (attacker BF-feedback extraction) — [arxiv.org/pdf/2309.04408][wibfi]
[fmd]: https://deepwiki.com/open-sdr/openwifi/2.2-fpga-module-design
[ov]: https://deepwiki.com/open-sdr/openwifi/1-openwifi-overview
[2ant]: https://github.com/open-sdr/openwifi/blob/master/doc/app_notes/iq_2ant.md
[txintf]: https://github.com/open-sdr/openwifi/blob/master/driver/tx_intf/tx_intf.c
[ft]: https://github.com/open-sdr/openwifi/blob/master/doc/app_notes/frequent_trick.md
[paper]: https://www.researchgate.net/publication/342582824_openwifi_a_free_and_open-source_IEEE80211_SDR_implementation_on_SoC
[wibfi]: https://arxiv.org/pdf/2309.04408
@@ -0,0 +1,315 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_openwifi — driver-side shim that binds the portable VEIL core
* (../core/veil_shield.{h,c}) to the openwifi FPGA TX/RX datapath.
*
* STATUS: SYNTHETIC / L0. Build-only scaffold. Never compiled into the openwifi
* kernel module on real silicon, never flashed, never captured. Do NOT claim
* runtime behavior without a captured hardware log (CLAUDE.md hardware-evidence
* rule). Register offsets, bitfields, and the FPGA blocks it programs
* (veil_rot / veil_unrot) do NOT exist in upstream openwifi yet — every place
* that depends on real hardware is marked TODO(hw); RTL specifics live in
* HDL_NOTES.md and are marked TODO(hdl) there.
*
* ROLE (honest): this shim runs on the protector AP and on the legitimate STA.
* - Protector: derive the per-session keyed unitary Q(key) from the core and
* program the veil_rot block that left-multiplies the transmit spatial
* mapping (inserted between openofdm_tx and tx_intf; see HDL_NOTES.md).
* - Legitimate STA: derive the same Q(key) and program veil_unrot to apply
* Q^H before channel estimation, cancelling the rotation (near-free tput).
* The transform is orthogonal, so transmit energy is preserved: compliant,
* NOT jamming. openwifi ships SISO with no explicit beamforming, so this is the
* client-transparent per-packet unitary route, not obfuscation of a compressed
* beamforming report (openwifi never generates one) — see README.md.
*
* openwifi idioms used where known:
* - AXI-Lite MMIO from the driver: iowrite32(value, base + reg) /
* ioread32(base + reg), matching driver/tx_intf/tx_intf.c reg_write/reg_read.
* - Coefficients are quantized to the fixed-point width the datapath uses
* (openwifi baseband IQ is 16-bit I / 16-bit Q); see VEIL_ROT_FRAC below.
*
* This file is written to compile in two modes:
* - Host/CI (default): __KERNEL__ undefined -> MMIO is stubbed to a local
* shadow buffer so the key-schedule + quantization logic is unit-testable
* with no hardware. This is the ONLY path exercised today.
* - In-tree kernel build: define VEIL_OPENWIFI_KERNEL to pull the real
* linux/io.h accessors. Untested. TODO(hw).
*/
#include "../core/veil_shield.h"
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <math.h>
/* -------------------------------------------------------------------------
* MMIO layer. Real openwifi drivers keep a per-block __iomem base and use
* iowrite32/ioread32. We isolate that here so host/CI builds need no kernel.
* ------------------------------------------------------------------------- */
#if defined(VEIL_OPENWIFI_KERNEL)
#include <linux/io.h>
typedef void __iomem *veil_mmio_base;
static inline void veil_reg_write(veil_mmio_base b, uint32_t reg, uint32_t v) {
iowrite32(v, (uint8_t __iomem *)b + reg);
}
static inline uint32_t veil_reg_read(veil_mmio_base b, uint32_t reg) {
return ioread32((uint8_t __iomem *)b + reg);
}
#else
/* Host/CI shadow: a small register file so logic is testable with no radio. */
#define VEIL_SHADOW_REGS 256
typedef struct {
uint32_t regs[VEIL_SHADOW_REGS];
} veil_mmio_shadow;
typedef veil_mmio_shadow *veil_mmio_base;
static inline void veil_reg_write(veil_mmio_base b, uint32_t reg, uint32_t v) {
if (b && (reg >> 2) < VEIL_SHADOW_REGS) {
b->regs[reg >> 2] = v;
}
}
static inline uint32_t veil_reg_read(veil_mmio_base b, uint32_t reg) {
if (b && (reg >> 2) < VEIL_SHADOW_REGS) {
return b->regs[reg >> 2];
}
return 0;
}
#endif
/* -------------------------------------------------------------------------
* Register map for the (not-yet-existing) veil_rot / veil_unrot AXI-Lite
* slaves. Offsets are PLACEHOLDERS chosen to be word-aligned; the real map is
* fixed when the RTL lands. TODO(hw): confirm against the generated
* *_s_axi.v once veil_rot exists (cf. openofdm_tx's 6 AXI-Lite regs).
* ------------------------------------------------------------------------- */
#define VEIL_ROT_REG_CTRL 0x00u /* bit0 enable, bit1 inverse, bit2 load */
#define VEIL_ROT_REG_KEY_LO 0x04u /* session key [31:0] */
#define VEIL_ROT_REG_KEY_HI 0x08u /* session key [63:32] */
#define VEIL_ROT_REG_NDIM 0x0Cu /* fine-block dimension N applied on-air */
#define VEIL_ROT_REG_PASSES 0x10u /* number of Givens passes */
#define VEIL_ROT_REG_COEFF_ADDR 0x14u /* write index into the coeff RAM */
#define VEIL_ROT_REG_COEFF_DATA 0x18u /* {Q16.15 sin, Q16.15 cos} packed */
#define VEIL_ROT_REG_STATUS 0x1Cu /* bit0 ready, bit1 applied, bit2 err */
#define VEIL_ROT_CTRL_ENABLE (1u << 0)
#define VEIL_ROT_CTRL_INVERSE (1u << 1)
#define VEIL_ROT_CTRL_LOAD (1u << 2)
#define VEIL_ROT_STATUS_READY (1u << 0)
/* Fixed-point: openwifi baseband IQ is 16-bit. We program rotation coeffs as
* signed Q1.15 (fractional bits = 15). cos/sin in [-1,1] map cleanly. */
#define VEIL_ROT_FRAC 15
/* Default schedule parameters — kept byte-consistent with the core/Rust crate
* defaults. N is the on-air fine-block dimension the datapath vectorizes over;
* for the SISO-plus-synthetic-stream demo this is small (see HDL_NOTES.md). */
#define VEIL_OW_DEFAULT_PASSES 96u
#define VEIL_OW_MAX_NDIM 64u /* bounded coeff RAM; keeps it malloc-free */
typedef enum {
VEIL_OW_ROLE_PROTECTOR = 0, /* TX veil_rot, forward rotation Q */
VEIL_OW_ROLE_LEGIT_RX = 1, /* RX veil_unrot, inverse rotation Q^H */
} veil_ow_role;
typedef struct {
veil_mmio_base base; /* AXI-Lite base of veil_rot / veil_unrot slave */
uint64_t key; /* shared session key (both ends must match) */
uint32_t ndim; /* fine-block dimension, <= VEIL_OW_MAX_NDIM */
uint32_t passes; /* Givens passes */
veil_ow_role role;
} veil_ow_ctx;
/* Saturating float -> signed Q1.15. */
static int16_t veil_q15(float x) {
float scaled = x * (float)(1 << VEIL_ROT_FRAC);
if (scaled > 32767.0f) return 32767;
if (scaled < -32768.0f) return -32768;
return (int16_t)lrintf(scaled);
}
/* -------------------------------------------------------------------------
* Coefficient generation. The core's schedule is (i, j, theta) Givens ops
* derived from SplitMix64(key). The FPGA applies the SAME schedule to on-air
* samples, so we hand it the per-pass (i, j, cos, sin). We regenerate the
* schedule here with the identical draw order as veil_shield.c so the shim and
* the (future) RTL agree bit-for-bit with the reference crate.
*
* NOTE: this mirrors veil_shield.c's private schedule. It is duplicated (not
* exported) on purpose — the core stays a pure in-memory transform with a
* stable ABI; the adapter owns the hardware-facing serialization. If the core
* later exports its schedule, collapse this. TODO(hw): validate equality with a
* captured on-FPGA coeff dump before any MEASURED claim.
* ------------------------------------------------------------------------- */
typedef struct {
uint16_t i;
uint16_t j;
int16_t cos_q15;
int16_t sin_q15;
} veil_ow_givens;
/* TAU matches VEIL_TAU in veil_shield.c / Rust core::f32::consts::TAU. */
#define VEIL_OW_TAU 6.28318530717958647692f
static void veil_ow_build_schedule(uint64_t key, uint32_t n, uint32_t passes,
veil_ow_givens *out /* [passes] */) {
veil_rng r;
uint32_t p;
if (n < 2) {
for (p = 0; p < passes; p++) {
out[p].i = 0; out[p].j = 0;
out[p].cos_q15 = veil_q15(1.0f); out[p].sin_q15 = 0;
}
return;
}
veil_rng_seed(&r, key);
for (p = 0; p < passes; p++) {
uint32_t i = (uint32_t)(veil_rng_next_u64(&r) % (uint64_t)n);
uint32_t j = (uint32_t)(veil_rng_next_u64(&r) % (uint64_t)n);
float theta;
if (j == i) {
j = (j + 1) % n;
}
theta = veil_rng_next_f32(&r) * VEIL_OW_TAU;
out[p].i = (uint16_t)i;
out[p].j = (uint16_t)j;
out[p].cos_q15 = veil_q15(cosf(theta));
out[p].sin_q15 = veil_q15(sinf(theta));
}
}
/* -------------------------------------------------------------------------
* Public API.
* ------------------------------------------------------------------------- */
/* Program a session key into the veil_rot/veil_unrot block. Returns 0 on the
* host shadow path; on real hardware it must poll STATUS_READY. */
int veil_ow_program_session(veil_ow_ctx *ctx) {
veil_ow_givens sched[VEIL_OW_DEFAULT_PASSES];
uint32_t ctrl = VEIL_ROT_CTRL_LOAD;
uint32_t p, passes, n;
if (!ctx || ctx->ndim < 2 || ctx->ndim > VEIL_OW_MAX_NDIM) {
return -1; /* bounds check the on-air dimension (least authority) */
}
passes = ctx->passes ? ctx->passes : VEIL_OW_DEFAULT_PASSES;
if (passes > VEIL_OW_DEFAULT_PASSES) {
passes = VEIL_OW_DEFAULT_PASSES; /* bounded, stack-only schedule */
}
n = ctx->ndim;
veil_ow_build_schedule(ctx->key, n, passes, sched);
/* Program header registers. */
veil_reg_write(ctx->base, VEIL_ROT_REG_KEY_LO, (uint32_t)(ctx->key));
veil_reg_write(ctx->base, VEIL_ROT_REG_KEY_HI, (uint32_t)(ctx->key >> 32));
veil_reg_write(ctx->base, VEIL_ROT_REG_NDIM, n);
veil_reg_write(ctx->base, VEIL_ROT_REG_PASSES, passes);
/* Stream the (i, j, cos, sin) schedule into the coeff RAM. Packing:
* COEFF_DATA = {i[15:0]... } is too wide for one 32-bit word, so we use a
* 2-word-per-pass convention: word A = {j[15:0], i[15:0]}, word B =
* {sin_q15[15:0], cos_q15[15:0]}. TODO(hdl): the veil_rot coeff-RAM write
* FSM must match this exact packing. TODO(hw): confirm endianness of the
* AXI-Lite slave. */
for (p = 0; p < passes; p++) {
uint32_t wa = ((uint32_t)(uint16_t)sched[p].j << 16) |
(uint32_t)(uint16_t)sched[p].i;
uint32_t wb = ((uint32_t)(uint16_t)sched[p].sin_q15 << 16) |
(uint32_t)(uint16_t)sched[p].cos_q15;
veil_reg_write(ctx->base, VEIL_ROT_REG_COEFF_ADDR, p * 2u);
veil_reg_write(ctx->base, VEIL_ROT_REG_COEFF_DATA, wa);
veil_reg_write(ctx->base, VEIL_ROT_REG_COEFF_ADDR, p * 2u + 1u);
veil_reg_write(ctx->base, VEIL_ROT_REG_COEFF_DATA, wb);
}
if (ctx->role == VEIL_OW_ROLE_LEGIT_RX) {
ctrl |= VEIL_ROT_CTRL_INVERSE; /* veil_unrot applies Q^H */
}
veil_reg_write(ctx->base, VEIL_ROT_REG_CTRL, ctrl);
/* TODO(hw): on real silicon, poll VEIL_ROT_REG_STATUS for READY here and
* time out. The host shadow has no FSM, so we return success directly and
* DO NOT claim the hardware accepted it. */
#if defined(VEIL_OPENWIFI_KERNEL)
{
int spins = 100000; /* TODO(hw): calibrate against real ready latency */
while (spins-- > 0) {
if (veil_reg_read(ctx->base, VEIL_ROT_REG_STATUS) &
VEIL_ROT_STATUS_READY) {
break;
}
}
if (spins <= 0) {
return -2; /* not ready — never treat as success */
}
}
#endif
return 0;
}
/* Engage / disengage the block (bit0 of CTRL), preserving the inverse bit. */
int veil_ow_set_enabled(veil_ow_ctx *ctx, int enable) {
uint32_t ctrl;
if (!ctx) {
return -1;
}
ctrl = veil_reg_read(ctx->base, VEIL_ROT_REG_CTRL);
if (enable) {
ctrl |= VEIL_ROT_CTRL_ENABLE;
} else {
ctrl &= ~VEIL_ROT_CTRL_ENABLE;
}
veil_reg_write(ctx->base, VEIL_ROT_REG_CTRL, ctrl);
return 0;
}
/*
* Control-plane bring-up alternatives (documented idioms, not wired here):
* - sdrctl (nl80211 testmode) for driver-level toggles once a testmode verb
* is added, e.g. a "veil" subcommand mirroring existing sdrctl reg pokes.
* - side_ch_ctl-style hex register pokes during bench bring-up, e.g. the
* side_ch app note's `./side_ch_ctl whXXdY` write convention, retargeted at
* the veil_rot slave. TODO(hw): pick and document the actual verb.
*
* Self-loopback validation (before over-the-air): openwifi supports a
* packet/IQ self-loopback test. Route veil_rot -> veil_unrot in loopback and
* assert recovered IQ == original within Q1.15 round-off. That is the first
* on-FPGA correctness gate (still not a defense MEASURED claim). TODO(hw).
*/
#if defined(VEIL_OPENWIFI_SELFTEST)
/* Host-only smoke test of the schedule/quantization path — NO hardware.
* Verifies the shadow register file receives a plausible, bounded program.
* Build: cc -DVEIL_OPENWIFI_SELFTEST veil_openwifi.c ../core/veil_shield.c -lm */
#include <stdio.h>
int main(void) {
veil_mmio_shadow shadow;
veil_ow_ctx ctx;
memset(&shadow, 0, sizeof(shadow));
ctx.base = &shadow;
ctx.key = 0x0123456789ABCDEFull;
ctx.ndim = 16;
ctx.passes = VEIL_OW_DEFAULT_PASSES;
ctx.role = VEIL_OW_ROLE_PROTECTOR;
if (veil_ow_program_session(&ctx) != 0) {
printf("FAIL: program_session\n");
return 1;
}
if (veil_ow_set_enabled(&ctx, 1) != 0) {
printf("FAIL: set_enabled\n");
return 1;
}
if (veil_reg_read(&shadow, VEIL_ROT_REG_NDIM) != 16u) {
printf("FAIL: ndim not programmed\n");
return 1;
}
if (!(veil_reg_read(&shadow, VEIL_ROT_REG_CTRL) & VEIL_ROT_CTRL_ENABLE)) {
printf("FAIL: enable bit\n");
return 1;
}
printf("OK (SYNTHETIC/L0 host shadow only — NOT hardware-validated)\n");
return 0;
}
#endif
@@ -0,0 +1,95 @@
# WiFi Veil ↔ `mac80211` / driver integration map
> **`SYNTHETIC / L0` — BUILD-ONLY, UNTESTED ON HARDWARE.** These are hook-point
> designs derived from public API/source, not validated on silicon. Function and
> attribute names are real (verified against in-tree `linux/nl80211.h` and public
> hostapd/driver docs); where a hook does **not** exist upstream it is marked
> `TODO(hw)` with what a patch would have to add. Compliant controls only.
Legend: **US** = userspace-reachable today · **DP** = needs driver patch ·
**FW** = needs firmware patch (blob-blocked).
---
## 1. TX antenna-map perturbation — **US** (feasible)
- **Daemon:** `veil_set_tx_antenna_mask()` in `veil_shieldd.c`.
- **Kernel path:** `nl80211``cfg80211_ops.set_antenna()` → driver
`.set_antenna` (e.g. `mt7915_set_antenna`, `ath9k` `set_antenna`).
- **Attributes:** `NL80211_CMD_SET_WIPHY`, `NL80211_ATTR_WIPHY_ANTENNA_TX`,
`NL80211_ATTR_WIPHY_ANTENNA_RX`.
- **Constraints:** many drivers require the phy DOWN and accept only symmetric
masks; validate per driver. Coarse static spatial-mapping change, not the keyed
rotation. Fully standards-compliant.
## 2. NDP sounding-cadence jitter — **US (indirect)**
- **Daemon:** `veil_randomize_sounding_cadence()` / `veil_next_cadence_ms()`.
The schedule is derived from the session key via the core SplitMix64 so the
paired receiver can anticipate it (not random spraying).
- **Real lever:** hostapd `ctrl_iface` (UNIX socket `/var/run/hostapd/<iface>`):
`SET he_su_beamformer …` / rewrite `vht_capab` `[SOUNDING-DIMENSION-n]` /
toggle `[SU-BEAMFORMER]`, then `RECONFIGURE`. Config keys documented in
`hostapd.conf`.
- **`TODO(hw)`:** there is **no** `nl80211` "set sounding interval" command; the
per-NDP timer is in driver/firmware. We can only jitter the *offered* cadence.
The `ctrl_iface` write itself is not yet wired (function currently only
computes `ms`).
## 3. MU-MIMO group shuffling — **FW** (blob-blocked)
- **Daemon:** `veil_shuffle_mumimo_groups()` — explicit `-ENOTSUP` no-op.
- **Where it lives:** MU group formation + per-group steering matrices are
computed in the WiFi MCU firmware on mt76 (mt7915) and all ath1x parts.
- **`TODO(hw)`:** would require `NL80211_CMD_VENDOR` with a driver-specific
`NL80211_ATTR_VENDOR_ID` / `NL80211_ATTR_VENDOR_SUBCMD` /
`NL80211_ATTR_VENDOR_DATA` that upstream mt76/ath do **not** define, plus a
firmware change to honor an externally supplied grouping. Not reachable without
both a driver and firmware patch.
## 4. Per-packet keyed unitary (the core WiFi Veil transform) — **FW** (blob-blocked)
- **Daemon:** `veil_apply_keyed_rotation()` → `veil_shield_apply(fine, n, key,
passes)` from the portable core. Orthogonal / energy-preserving (the
"not jamming" invariant, checked via `veil_l2_norm` before/after).
- **What a full path must touch:**
- **mt76 (mt7915):** the MCU firmware stage that builds the compressed
beamforming report (φ/ψ angles) or applies the steering/precoder Q to the
LTF spatial mapping. A firmware patch would call the rotation on the fine
subspace *before* the report is emitted / precoder applied. The driver
(`mt7915/mcu.c`) would ferry the key/passes down via a new MCU command.
- **ath9k (DP, best open case):** the static spatial-mapping matrix is set via
`AR_PHY_*` registers in the open PHY init; a driver patch could apply a keyed
*static* Q there. This is coarser than a true per-packet report edit but is
the most credible OpenWRT-adjacent route (older 802.11n hardware only).
- **ath10k/ath11k/ath12k:** report generation + precoder are entirely
firmware-side with no open firmware (ath11k/ath12k) — not patchable.
- **`TODO(hw)`:** on OpenWRT there is **no** userspace/`mac80211` hook that hands
the pre-precoder V/steering buffer to the daemon before TX. Reaching it needs
the driver+firmware patch above, or use the **openwifi (FPGA)** / **Nexmon
(Broadcom)** adapters, which expose the datapath. The daemon only proves the
math is invariant; nothing goes on air.
## 5. Sensing-solicitation (NDPA) detection — **US/DP** (partial)
- **Daemon:** `veil_event_cb()` on `NL80211_CMD_FRAME`.
- **Real path:** `NL80211_CMD_REGISTER_FRAME` to subscribe to specific
management action categories, delivered as `NL80211_CMD_FRAME` with
`NL80211_ATTR_FRAME`. Classify VHT/HE compressed beamforming action
(categories 21 / 30) and NDP Announcement to measure cadence.
- **`TODO(hw)`:** commodity drivers do **not** forward raw NDPA to userspace by
default; honest external-solicitation detection needs monitor-mode capture or a
driver notification that is not guaranteed upstream. Frame parsing is stubbed.
---
## Summary of the effort boundary
| Control | Effort to reach full WiFi Veil fidelity |
|---|---|
| TX antenna map | Ready now (US), coarse only |
| Sounding cadence jitter | Wire hostapd `ctrl_iface` (US), coarse only |
| Static spatial Q | ath9k driver patch (DP) |
| MU grouping | driver vendor subcmd + firmware (FW) |
| Per-packet keyed rotation | mt76/ath **firmware** patch, or openwifi/Nexmon adapter (FW) |
| NDPA detection | frame registration + likely driver patch (US/DP) |
+44
View File
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: MIT OR Apache-2.0
#
# Host build-CHECK for the OpenWRT/mac80211 VEIL adapter.
# STATUS: SYNTHETIC / L0 — build-only, UNTESTED ON HARDWARE.
#
# Two targets:
# make core - compile+link the portable core only (always works,
# no libnl needed) — proves the rotation math builds.
# make daemon - build veil_shieldd against libnl-genl-3 (needs the
# dev headers: `pkg-config libnl-genl-3.0`). On OpenWRT
# the package build uses libnl-tiny instead (see openwrt.mk).
#
# This Makefile does NOT flash, run on, or validate any radio.
CC ?= cc
COREDIR := ../core
CFLAGS ?= -std=c99 -Wall -Wextra -O2 -I$(COREDIR)
LDLIBS ?= -lm
NL_CFLAGS := $(shell pkg-config --cflags libnl-genl-3.0 2>/dev/null)
NL_LIBS := $(shell pkg-config --libs libnl-genl-3.0 2>/dev/null)
.PHONY: all core daemon clean
all: core
# Always-buildable: the core object, no netlink dependency.
core: $(COREDIR)/veil_shield.c $(COREDIR)/veil_shield.h
$(CC) $(CFLAGS) -c $(COREDIR)/veil_shield.c -o veil_shield.o
@echo "core built (rotation math OK). Nothing was run on hardware."
# Full daemon: requires libnl-genl-3 dev headers on the host.
daemon: veil_shieldd.c core
ifeq ($(strip $(NL_LIBS)),)
@echo "SKIP daemon: libnl-genl-3.0 not found (pkg-config)."
@echo " Install libnl-3-dev + libnl-genl-3-dev, or build via openwrt.mk."
@exit 0
else
$(CC) $(CFLAGS) $(NL_CFLAGS) -o veil_shieldd \
veil_shieldd.c veil_shield.o $(NL_LIBS) $(LDLIBS)
@echo "veil_shieldd linked (BUILD-ONLY; untested on silicon)."
endif
clean:
rm -f veil_shield.o veil_shieldd
+112
View File
@@ -0,0 +1,112 @@
# WiFi Veil — OpenWRT / Linux `mac80211` adapter
> **STATUS: `SYNTHETIC / L0` — BUILD-ONLY, UNTESTED ON HARDWARE.**
> No radio was driven, no CSI captured, no log produced on silicon. Every
> claim below is a design/feasibility statement, not a `MEASURED` result. This
> adapter uses **compliant waveform controls only** — it never jams and emits
> no denial energy.
This directory is the OpenWRT/`mac80211` platform adapter for the WiFi Veil privacy
shield. It links the validated portable core
(`../core/veil_shield.{h,c}` — the keyed Givens rotation over the identity-bearing
"fine" subspace of 802.11 compressed beamforming feedback) and drives the subset
of controls that Linux userspace/`mac80211` can actually reach on commodity APs.
---
## Feasibility grade: **C** (partial — coarse compliant controls only)
**Why C, not higher.** WiFi Veil's defining action is a *per-packet keyed unitary* on
the compressed beamforming-feedback angles (equivalently, a keyed Q on the LTF
spatial mapping / precoder). On every mainstream OpenWRT AP chipset
(Qualcomm ath10k/ath11k/ath12k, MediaTek mt76 / mt7915), that report is generated
and the precoder applied **inside the WiFi MCU firmware blob** — userspace and the
open driver never touch the pre-transmit V matrix. So the full keyed-rotation path
is **blob-blocked** from OpenWRT. What remains reachable is a set of *coarse*
compliant knobs that perturb, but do not cryptographically obfuscate, the CSI a
sensor observes. That is a real, honest defense-in-depth layer — hence C, not D —
but it is not the full WiFi Veil transform.
**Why not D.** Some controls genuinely work from userspace (TX antenna map;
hostapd-mediated sounding/beamformer capability), and one chipset family
(**ath9k**) is open enough at the register level that a *driver patch* could reach
the static spatial-mapping matrix — a credible route to B on that specific,
older hardware. openwifi (FPGA) and Nexmon (Broadcom) are the routes to the full
A-grade keyed rotation, but those are **separate adapters**, not OpenWRT.
---
## What is FEASIBLE vs. BLOB-BLOCKED from OpenWRT
| WiFi Veil control | Reachable from OpenWRT? | Mechanism (real API / knob) | Notes |
|---|---|---|---|
| **TX antenna-map perturbation** | ✅ Feasible | `NL80211_CMD_SET_WIPHY` + `NL80211_ATTR_WIPHY_ANTENNA_TX` / `_RX` | Coarse static spatial-mapping change. Many drivers require phy DOWN and symmetric masks. Compliant. |
| **NDP sounding-cadence jitter** | 🟡 Indirect | hostapd `ctrl_iface` (rewrite `SOUNDING-DIMENSION`, toggle `[SU-BEAMFORMER]`, `RECONFIGURE`) | No `nl80211` "set sounding interval" exists; the per-NDP timer lives in driver/firmware. We can only jitter the *offered* capability. |
| **Beamformer/beamformee capability toggle** | ✅ Feasible | hostapd `vht_capab` / `he_su_beamformer` etc. | Standards-compliant advertisement. Coarse on/off, not per-packet. |
| **Spatial-stream → antenna mapping (static Q)** | 🟡 Driver-patch (ath9k only) | ath9k PHY spatial-mapping registers (`AR_PHY_*`) | Open enough to patch on ath9k; opaque/firmware on ath10k+/mt76. Not a stock userspace knob. |
| **MU-MIMO group shuffling** | ❌ Blob-blocked | would need `NL80211_CMD_VENDOR` subcmd that upstream mt76/ath do **not** expose | Group formation + steering matrices computed in MCU firmware. |
| **Per-packet keyed unitary on LTF / precoder** | ❌ Blob-blocked | — | The core WiFi Veil transform. Lives in firmware on all commodity AP parts. Requires firmware patch, or use openwifi / Nexmon adapters. |
| **Compressed-BF-report angle edit (φ/ψ)** | ❌ Blob-blocked | — | Report is generated in firmware/PHY; not exposed pre-TX on OpenWRT. |
| **External sensing-solicitation detection (NDPA cadence)** | 🟡 Partial | `NL80211_CMD_FRAME` + `NL80211_CMD_REGISTER_FRAME`, or monitor-mode capture | Commodity drivers do not forward raw NDPA to userspace by default. |
---
## Best candidate chipsets / drivers
- **ath9k (Atheros 802.11n)** — *best open target for a driver-side patch.* The
most transparent open driver (no per-packet firmware for the datapath), with a
long history of PHY register access and the Atheros CSI Tool ecosystem. A
static spatial-mapping perturbation and CSI observation are realistic here;
full HT beamforming-feedback editing still is not in open code. 802.11n-only.
- **mt76 (MediaTek mt7915 / mt7622-mt7615)** — *best-maintained modern open
driver* and the most likely place upstream would eventually accept a vendor
hook, but beamforming/sounding/MU grouping run in the MCU firmware today, so
the keyed path needs a firmware patch (blob-blocked out of the box).
- **ath10k / ath11k / ath12k (Qualcomm)** — most capable radios but the most
closed: regulatory + beamforming + sounding all firmware-side. ath11k/ath12k
have **no open firmware** at all. Worst target for the keyed path.
- **openwifi (FPGA SDR) / Nexmon (Broadcom)** — the only routes to the full
A-grade keyed rotation; handled by the sibling `../openwifi/` and `../nexmon/`
adapters, **not** this OpenWRT one.
**Recommendation:** for OpenWRT specifically, target **ath9k** for a
driver-patch proof-of-concept (spatial-mapping + CSI), and **mt76/mt7915** as the
strategic modern platform pending a firmware/vendor-subcmd hook.
---
## Build (host, build-only)
```bash
make core # always works: compiles+links the portable core, no libnl needed
make daemon # builds veil_shieldd IF libnl-genl-3.0 dev headers are present
make clean
```
`make daemon` cleanly **skips** (does not fail) when `libnl-genl-3.0` is absent,
printing the required dev packages. On an OpenWRT buildroot use `openwrt.mk`
(rename to `Makefile` under `package/utils/veil-shieldd/`), which builds against
`libnl-tiny`. See `INTEGRATION.md` for the per-control hook points and exactly
what a driver/firmware patch would need to touch.
---
## Sources
- Linux `nl80211.h` (in-tree, this host): `NL80211_CMD_SET_WIPHY`,
`NL80211_ATTR_WIPHY_ANTENNA_TX` / `_RX`, `NL80211_CMD_VENDOR`,
`NL80211_CMD_FRAME` / `NL80211_CMD_REGISTER_FRAME`.
- ath10k configuration (beamforming only via hostapd `vht_capab`, no debugfs
sounding control): <https://wireless.docs.kernel.org/en/latest/en/users/drivers/ath10k/configuration.html>
- hostapd beamforming/sounding knobs (`[SU-BEAMFORMER]`, `[MU-BEAMFORMER]`,
`[SOUNDING-DIMENSION-4]`, `he_su_beamformer`):
<https://w1.fi/cgit/hostap/tree/hostapd/hostapd.conf> and
<https://github.com/morrownr/USB-WiFi/blob/main/home/AP_Mode/hostapd-WiFi6.conf>
- mt76 beamforming lives in firmware (mt7622/mt7615 performance/beamforming
discussion): <https://github.com/openwrt/mt76/issues/863>
- Qualcomm firmware closedness (ath11k/ath12k no open firmware; regulatory +
features firmware-enforced): ath10k mailing-list thread
<https://ath10k.infradead.narkive.com/6bdEJZih/qca99xx-with-mu-mimo-and-beamforming>
and CodeLinaro ath firmware <https://git.codelinaro.org/clo/ath-firmware/ath11k-firmware>
- ath11k reports VHT beamformee spatial streams *from firmware*:
<https://lkml.iu.edu/2210.2/09619.html>
+60
View File
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: MIT OR Apache-2.0
#
# OpenWRT package Makefile STUB for veil_shieldd.
# STATUS: SYNTHETIC / L0 — package skeleton, UNTESTED ON HARDWARE / not in any feed.
#
# Drop this (renamed to `Makefile`) into a package dir such as
# `package/utils/veil-shieldd/` in an OpenWRT buildroot, alongside the copied
# core (veil_shield.{c,h}) and veil_shieldd.c under ./src/. It builds against
# libnl-tiny (the OpenWRT netlink lib) — the same nl80211 API surface, smaller.
#
# This stub does NOT prove the daemon works on a device; it only wires the
# build. No hardware validation is implied.
include $(TOPDIR)/rules.mk
PKG_NAME:=veil-shieldd
PKG_VERSION:=0.0.0-l0
PKG_RELEASE:=1
PKG_LICENSE:=MIT OR Apache-2.0
include $(INCLUDE_DIR)/package.mk
define Package/veil-shieldd
SECTION:=utils
CATEGORY:=Utilities
TITLE:=VEIL compliant-waveform privacy shield (mac80211 adapter, L0)
# libnl-tiny provides nl80211/genl; hostapd for the ctrl_iface cadence path.
DEPENDS:=+libnl-tiny +hostapd-common
URL:=https://github.com/ruvnet/RuView
endef
define Package/veil-shieldd/description
BUILD-ONLY / UNTESTED-ON-HARDWARE userspace adapter that drives the
standards-compliant subset of VEIL controls reachable from OpenWRT
(TX antenna map, hostapd-mediated sounding cadence) and links the portable
keyed-rotation core. The full per-packet keyed rotation is blob-blocked on
commodity Qualcomm/MediaTek parts and requires a driver/firmware patch.
This is NOT a jammer and emits no denial energy.
endef
# Build flags: point at libnl-tiny headers and the copied core.
TARGET_CFLAGS += -I$(STAGING_DIR)/usr/include/libnl-tiny -I$(PKG_BUILD_DIR)/src
TARGET_LDFLAGS += -lnl-tiny -lm
define Build/Compile
$(TARGET_CC) $(TARGET_CFLAGS) -std=c99 -Wall -Wextra \
-o $(PKG_BUILD_DIR)/veil_shieldd \
$(PKG_BUILD_DIR)/src/veil_shieldd.c \
$(PKG_BUILD_DIR)/src/veil_shield.c \
$(TARGET_LDFLAGS)
endef
define Package/veil-shieldd/install
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/veil_shieldd $(1)/usr/sbin/veil_shieldd
# TODO(hw): ship a procd init script that reads the session key from a
# secure store (never a world-readable config) and passes -i <ifindex>.
endef
$(eval $(call BuildPackage,veil-shieldd))
+294
View File
@@ -0,0 +1,294 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_shieldd — OpenWRT / Linux mac80211 userspace adapter for the VEIL
* compliant-waveform privacy shield (ADR-288 / ADR-290).
*
* ============================= HONESTY BANNER ==============================
* STATUS: SYNTHETIC / L0 — BUILD-ONLY SCAFFOLD, UNTESTED ON HARDWARE.
*
* This daemon compiles and links the portable veil_shield core, and it issues
* REAL nl80211/libnl calls for the small set of controls that Linux actually
* exposes to userspace (antenna TX mask, station/BSS observation). Everything
* that would edit the per-packet spatial mapping / precoder or the compressed
* beamforming-feedback angles is BLOB-BLOCKED on commodity Qualcomm/MediaTek
* parts and is marked `TODO(hw)` at the exact call site — see README.md and
* INTEGRATION.md. Nothing here has been run against a radio. Do not read any
* comment in this file as evidence that VEIL obfuscation reaches the air.
*
* COMPLIANCE: every control below is a standards-compliant configuration or
* observation action. This daemon never transmits energy to deny a channel;
* it only shapes/observes our own compliant frames. It is NOT a jammer.
* ==========================================================================
*
* Build deps (OpenWRT: libnl-tiny; desktop: libnl-3 + libnl-genl-3):
* pkg-config --cflags --libs libnl-genl-3.0
* See Makefile (host build-check) and openwrt.mk (package stub).
*/
#include <errno.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* Real libnl / nl80211 headers. On OpenWRT these resolve to libnl-tiny; on a
* desktop to libnl-3. If the toolchain lacks them the host Makefile still
* builds the core object so the rotation math is validated in isolation. */
#include <netlink/netlink.h>
#include <netlink/genl/genl.h>
#include <netlink/genl/ctrl.h>
#include <linux/nl80211.h>
#include "veil_shield.h"
/* ---- Tunables (compliant, conservative defaults) ---------------------- */
#define VEIL_DEFAULT_PASSES 96u /* matches core default (ADR-290) */
#define VEIL_CADENCE_JITTER_MIN_MS 20 /* NDP sounding cadence jitter floor */
#define VEIL_CADENCE_JITTER_MAX_MS 400 /* ... and ceiling (stays in-spec) */
/* ---- Daemon context --------------------------------------------------- */
struct veil_ctx {
struct nl_sock *sock; /* generic-netlink socket to nl80211 */
int family; /* resolved "nl80211" genl family id */
int ifindex;/* target AP interface (e.g. phy0-ap0) */
uint64_t key; /* shared session key for the keyed rotation */
size_t passes; /* Givens passes */
volatile sig_atomic_t running;
};
static struct veil_ctx g_ctx;
static void on_signal(int sig) { (void)sig; g_ctx.running = 0; }
/* ---------------------------------------------------------------------- */
/* nl80211 bring-up — all REAL libnl-genl-3 API names. */
/* ---------------------------------------------------------------------- */
static int veil_nl_connect(struct veil_ctx *c) {
c->sock = nl_socket_alloc();
if (!c->sock) {
fprintf(stderr, "veil: nl_socket_alloc failed\n");
return -ENOMEM;
}
if (genl_connect(c->sock)) {
fprintf(stderr, "veil: genl_connect failed\n");
return -EIO;
}
c->family = genl_ctrl_resolve(c->sock, "nl80211");
if (c->family < 0) {
fprintf(stderr, "veil: genl_ctrl_resolve(nl80211) failed: %d\n",
c->family);
return c->family;
}
/* Observe MLME events (auth/assoc, and — where the driver forwards them —
* action-frame notifications). Real multicast group name is "mlme". */
int grp = genl_ctrl_resolve_grp(c->sock, "nl80211", "mlme");
if (grp >= 0) {
(void)nl_socket_add_membership(c->sock, grp);
}
return 0;
}
/* ---------------------------------------------------------------------- */
/* CONTROL 1 (FEASIBLE): TX antenna-map perturbation. */
/* Rotating the allowed TX antenna bitmap changes the static spatial */
/* mapping the PHY uses, coarsely perturbing the CSI a sensor observes. */
/* This is a genuinely userspace-reachable, compliant knob. */
/* NL80211_CMD_SET_WIPHY + NL80211_ATTR_WIPHY_ANTENNA_TX / _RX */
/* NOTE: many drivers only accept this while the phy is DOWN, and only on */
/* symmetric masks — validate per driver. Coarse, not the keyed rotation. */
/* ---------------------------------------------------------------------- */
static int veil_set_tx_antenna_mask(struct veil_ctx *c,
uint32_t tx_mask, uint32_t rx_mask) {
struct nl_msg *msg = nlmsg_alloc();
if (!msg) return -ENOMEM;
genlmsg_put(msg, NL_AUTO_PORT, NL_AUTO_SEQ, c->family, 0, 0,
NL80211_CMD_SET_WIPHY, 0);
/* wiphy is addressed via the interface index on most drivers. */
NLA_PUT_U32(msg, NL80211_ATTR_IFINDEX, (uint32_t)c->ifindex);
NLA_PUT_U32(msg, NL80211_ATTR_WIPHY_ANTENNA_TX, tx_mask);
NLA_PUT_U32(msg, NL80211_ATTR_WIPHY_ANTENNA_RX, rx_mask);
int ret = nl_send_auto(c->sock, msg);
nlmsg_free(msg);
if (ret < 0) return ret;
return nl_recvmsgs_default(c->sock); /* consume ACK/ERR */
nla_put_failure:
nlmsg_free(msg);
return -EMSGSIZE;
}
/* ---------------------------------------------------------------------- */
/* CONTROL 2 (FEASIBLE, indirect): NDP sounding-cadence randomization. */
/* mac80211/driver decides when to send NDP Announcement + NDP. There is */
/* NO stable nl80211 attribute to set the sounding period directly, so the */
/* compliant lever from userspace is hostapd's advertised sounding */
/* capability and dimensions, toggled/rewritten over the hostapd ctrl */
/* interface (RECONFIGURE / SET). We jitter the *offered* cadence. */
/* */
/* TODO(hw): there is no nl80211 "set sounding interval" command. Confirm */
/* against hostapd ctrl_iface docs; the direct per-NDP timer lives in */
/* driver/firmware. See INTEGRATION.md §2. Cite: */
/* https://w1.fi/cgit/hostap/tree/hostapd/hostapd.conf */
/* ---------------------------------------------------------------------- */
static unsigned veil_next_cadence_ms(struct veil_ctx *c) {
/* Derive jitter deterministically from the session key stream so the
* paired receiver can anticipate the schedule (compliant, not random
* spraying). Reuses the core SplitMix64 for byte-identical behavior. */
static veil_rng r;
static int seeded = 0;
if (!seeded) { veil_rng_seed(&r, c->key ^ 0xCADE11CEULL); seeded = 1; }
unsigned span = VEIL_CADENCE_JITTER_MAX_MS - VEIL_CADENCE_JITTER_MIN_MS;
return VEIL_CADENCE_JITTER_MIN_MS +
(unsigned)(veil_rng_next_f32(&r) * (float)span);
}
static int veil_randomize_sounding_cadence(struct veil_ctx *c) {
unsigned ms = veil_next_cadence_ms(c);
/* TODO(hw): push `ms` into the offered sounding cadence. On OpenWRT the
* realistic path is the hostapd ctrl_iface (UNIX socket at
* /var/run/hostapd/<iface>): rewrite he/vht sounding-dimension or toggle
* beamformer capability and RECONFIGURE. mac80211 has no direct knob.
* This function currently only computes the schedule. */
fprintf(stderr, "veil: [feasible/indirect] next sounding jitter = %u ms "
"(TODO(hw): apply via hostapd ctrl_iface)\n", ms);
return 0;
}
/* ---------------------------------------------------------------------- */
/* CONTROL 3 (MOSTLY BLOB-BLOCKED): MU-MIMO group shuffling. */
/* The MU group definition + steering matrices are computed and applied in */
/* the WiFi MCU firmware on mt76 (mt7915) and all ath1x parts. There is no */
/* generic nl80211 command to reshuffle MU groups. Only a vendor subcmd */
/* (NL80211_CMD_VENDOR) on a driver that chose to expose one could do it. */
/* ---------------------------------------------------------------------- */
static int veil_shuffle_mumimo_groups(struct veil_ctx *c) {
(void)c;
/* TODO(hw): requires NL80211_CMD_VENDOR + a driver-specific
* NL80211_ATTR_VENDOR_ID / _SUBCMD / _DATA that does not exist upstream
* for mt76/ath. Without a driver+firmware patch this is unreachable.
* See INTEGRATION.md §3. Left as an explicit no-op, not a fake success. */
fprintf(stderr, "veil: [blob-blocked] MU-MIMO group shuffle needs a "
"vendor subcmd / firmware patch (TODO(hw))\n");
return -ENOTSUP;
}
/* ---------------------------------------------------------------------- */
/* CONTROL 4 (BLOB-BLOCKED on commodity AP silicon): the keyed rotation. */
/* This is the actual VEIL transform — a keyed Givens rotation on the fine */
/* subspace of the compressed beamforming feedback (the phi/psi angles), */
/* or equivalently a unitary Q on the LTF spatial mapping. On mt76/ath the */
/* feedback report is generated and the precoder applied inside firmware, */
/* so userspace cannot edit it. This function shows WHERE the core plugs */
/* in for the platforms that CAN reach the buffer (openwifi FPGA datapath, */
/* Nexmon Broadcom patch) — it operates on a caller-supplied fine block. */
/* ---------------------------------------------------------------------- */
static int veil_apply_keyed_rotation(struct veil_ctx *c,
float *fine, size_t n) {
if (!fine || n < 2) return -EINVAL;
/* Pure, orthogonal, energy-preserving (the "not jamming" invariant). */
float before = veil_l2_norm(fine, n);
veil_shield_apply(fine, n, c->key, c->passes);
float after = veil_l2_norm(fine, n);
/* TODO(hw): on OpenWRT there is NO userspace/mac80211 hook that hands us
* this buffer before TX. Reaching it requires a driver+firmware patch
* (mt76 MCU / ath) to expose the pre-precoder V/steering matrix, OR use
* the openwifi (FPGA) or Nexmon adapters. See INTEGRATION.md §4.
* We only prove the math is invariant here; nothing goes on air. */
fprintf(stderr, "veil: [blob-blocked path] rotated %zu coeffs, "
"L2 %.6f -> %.6f (delta %.2e; must be ~0)\n",
n, before, after, (double)(after - before));
return 0;
}
/* ---------------------------------------------------------------------- */
/* Event loop: watch for sensing-solicitation cadence. */
/* We register interest in MLME/frame events. On commodity drivers the raw */
/* NDP Announcement is NOT forwarded to userspace, so honest detection of */
/* an *external* sensing solicitation needs monitor-mode capture or a */
/* driver notification that does not exist upstream — marked TODO(hw). */
/* ---------------------------------------------------------------------- */
static int veil_event_cb(struct nl_msg *msg, void *arg) {
struct veil_ctx *c = (struct veil_ctx *)arg;
struct genlmsghdr *gnlh = nlmsg_data(nlmsg_hdr(msg));
switch (gnlh->cmd) {
case NL80211_CMD_FRAME:
/* TODO(hw): parse NL80211_ATTR_FRAME; classify VHT/HE compressed
* beamforming action (category 21/30) or NDPA to measure solicitation
* cadence. Requires the driver to forward these frames (registered via
* NL80211_CMD_REGISTER_FRAME / monitor). Not guaranteed upstream. */
(void)veil_randomize_sounding_cadence(c);
break;
case NL80211_CMD_NEW_STATION:
case NL80211_CMD_DEL_STATION:
/* Membership churn changes MU grouping surface. */
(void)veil_shuffle_mumimo_groups(c);
break;
default:
break;
}
return NL_SKIP;
}
static void usage(const char *p) {
fprintf(stderr,
"Usage: %s -i <ifindex> [-k <key_hex>] [-p <passes>]\n"
" BUILD-ONLY / UNTESTED-ON-HARDWARE. See README.md.\n", p);
}
int main(int argc, char **argv) {
memset(&g_ctx, 0, sizeof(g_ctx));
g_ctx.key = 0xA5A5A5A5A5A5A5A5ULL; /* placeholder; real key from keystore */
g_ctx.passes = VEIL_DEFAULT_PASSES;
g_ctx.ifindex = -1;
g_ctx.running = 1;
int opt;
while ((opt = getopt(argc, argv, "i:k:p:h")) != -1) {
switch (opt) {
case 'i': g_ctx.ifindex = atoi(optarg); break;
case 'k': g_ctx.key = strtoull(optarg, NULL, 16); break;
case 'p': g_ctx.passes = (size_t)strtoul(optarg, NULL, 10); break;
case 'h': default: usage(argv[0]); return (opt == 'h') ? 0 : 2;
}
}
if (g_ctx.ifindex < 0) { usage(argv[0]); return 2; }
fprintf(stderr, "veil_shieldd: SYNTHETIC/L0 build-only scaffold — "
"no RF is emitted, nothing is validated on silicon.\n");
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
if (veil_nl_connect(&g_ctx)) return 1;
/* Install the event callback (valid-message path). */
nl_socket_modify_cb(g_ctx.sock, NL_CB_VALID, NL_CB_CUSTOM,
veil_event_cb, &g_ctx);
nl_socket_disable_seq_check(g_ctx.sock); /* required for multicast events */
/* Self-check the one genuinely feasible active control at startup. Comment
* this out on a live AP; it may bounce the radio depending on the driver.
* (void)veil_set_tx_antenna_mask(&g_ctx, 0x3, 0x3); */
(void)veil_set_tx_antenna_mask;
/* Prove the linked core is byte-consistent (no radio involved). */
{
float demo[8] = {1,0,0,0,0,0,0,0};
(void)veil_apply_keyed_rotation(&g_ctx, demo, 8);
veil_shield_recover(demo, 8, g_ctx.key, g_ctx.passes);
fprintf(stderr, "veil: recover round-trip demo[0]=%.6f (expect ~1.0)\n",
(double)demo[0]);
}
while (g_ctx.running) {
int r = nl_recvmsgs_default(g_ctx.sock);
if (r < 0 && r != -NLE_AGAIN) {
fprintf(stderr, "veil: nl_recvmsgs_default: %d\n", r);
break;
}
}
nl_socket_free(g_ctx.sock);
return 0;
}
@@ -0,0 +1,25 @@
{
"name": "wifi-densepose-privshield-harness",
"version": "0.1.0",
"description": "Harness for wifi-densepose-privshield (WiFi Veil privacy shield)",
"author": {
"displayName": "Generated by metaharness",
"url": "https://www.npmjs.com/package/metaharness"
},
"license": "MIT",
"categories": [
"agent-harness",
"metaharness-scaffold",
"Engineering",
"software-engineering"
],
"tags": [
"metaharness",
"agent-harness",
"vertical:coding",
"software-engineering",
"wifi-sensing",
"privacy"
],
"homepage": "https://github.com/ruvnet/agent-harness-generator"
}
@@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(npx wifi-densepose-privshield-harness*)",
"mcp__wifi-densepose-privshield-harness__*",
"Bash(npm test*)",
"Bash(npm run*)",
"Bash(cargo test -p wifi-densepose-privshield*)",
"Bash(cargo clippy -p wifi-densepose-privshield*)",
"Bash(git diff*)",
"Bash(git status*)",
"Bash(git log*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(git push*)",
"Bash(rm -rf*)"
]
}
}
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
@@ -0,0 +1,32 @@
{
"schema": 1,
"generator": "0.1.0",
"template": "vertical:coding",
"template_version": "0.0.0",
"vars": {
"name": "wifi-densepose-privshield-harness",
"description": "Harness for wifi-densepose-privshield (WiFi Veil privacy shield)",
"host": "claude-code"
},
"hosts": ["claude-code"],
"files": {
".claude/settings.json": "fedb60921a0e3c78848f43edddd75f448819594c680d48ff2033ef8f1588da3f",
".claude-plugin/plugin.json": "8b155a3130d212c88dd8b631d9bd6dd1b4eacb52e5eb282fddbe08576ae23be2",
"bin/cli.js": "1133e7a47accada1c9b2184873776d8ca0d028f9b76dd55f467dfe38bb9ce609",
"CLAUDE.md": "f9ccf20c341ff0296b2e64ce692103572d61e856ae0df8a8bc4c35a7ac8b2ff5",
"package.json": "1ccedf0e62b0ed884431a2a9192a3865b525a2dad72fa6491569b9001e7dd24f",
"README.md": "688e207f95e4f58eeade84f149c38fa8ec048796556a7b1bd75f08ee1945edba",
"src/init.ts": "f05d6905d8681f45f610ff5b6e9d425dfa66183acdfe7857248e50e3583e13b8",
"src/router.ts": "4545b42d1423db21bcfe6ab6bf132b805ba383937d142997cb7256e835c245e4",
"src/flywheel.ts": "aab56d82c4f018ddc83923c877a66acdf9c624214307d0a9c4bf930ddb00599a",
"tsconfig.json": "8b4e730a1aa39162ac574455d7a98e1881f5313ca80ffe503b9652dcf0c76b9d",
"vitest.config.ts": "021b33ec623593effc3d163020479a91a1179329ee4ed1cb25f2dd9388e19820",
"__tests__/smoke.test.ts": "8c5a2acc3a956ea48e60c996c9034224684f6f4110cb3c5885741ff8d18bb82d",
"__tests__/router.test.ts": "97c29fc0ff718692ec97a9cd81f92e65ebde996fe1a3d8d2182e66598a583fad",
"__tests__/flywheel.test.ts": "87b149f7d68b4cf72fe3dcf6c76e4307b280e9f4ab6e6b1f7ee6689340faf5fb",
"__tests__/guidance.test.ts": "66b68615d27671d91b9efcf1eee5f2c7a53b0db7f475cc4ddb17ba8b6ddbff7f",
"LICENSE": "07b1a7c2aa25991872e3594de2ecb64ff6b4c5d3dc2376dd5b9e9f77c4b258e8"
},
"generated_at": "2026-08-09T00:00:00.000Z",
"meta": { "surface": "cli" }
}
@@ -0,0 +1 @@
da48afb45d776c10f1841331facf65aa7ba4802f990a2480b91227fc100d4a47
@@ -0,0 +1,67 @@
# wifi-densepose-privshield-harness
Harness for [`wifi-densepose-privshield`](../../v2/crates/wifi-densepose-privshield)
(WiFi Veil — the compliant-waveform WiFi-sensing privacy shield, ADR-288). This
package is defined by ADR-289.
> Advanced Coding harness · domain: `software-engineering`. Modeled on the
> `wifi-densepose-sar-harness` scaffold (ADR-286), generated with
> [create-agent-harness](https://github.com/ruvnet/agent-harness-generator).
## Behavioral rules
- Use the harness's tools for orchestration; memory and routing are handled by
the kernel.
- Defer destructive operations to the user.
- **Never present WiFi sensing as camera-grade, and never relabel WiFi Veil's
SYNTHETIC/L0 results as MEASURED** — a hardware witness is required first
(CLAUDE.md hardware rule; ADR-282 ladder). The harness is a development aid;
it does not run a radio and cannot emit RF.
- WiFi Veil uses compliant waveform controls only — **never jamming.** Do not add,
suggest, or scaffold interference-based "defenses."
## Commands
- `init` — boot the kernel + host adapter.
- `doctor` — verify the install end-to-end (kernel, host, guidance map).
- `guidance --topic <t> [--query <q>]` — read-only WiFi Veil capability map
(dependency-free; topics: `overview`, `threat`, `countermeasure`,
`compliance`, `optimization`, `experiment`). Source-cited and
evidence-labelled; navigation only, not authority.
- `route <e0> <e1> <e2> <e3>` — cost-optimal model routing via
`@metaharness/router` (needs `npm run build`).
- `flywheel [generations]` — SYNTHETIC self-improvement demo via
`@metaharness/flywheel` (needs `npm run build`).
## Architecture
Uses [@metaharness/kernel](https://www.npmjs.com/package/@metaharness/kernel)
(Rust-compiled WASM with a NAPI-RS native fallback) so the same code runs on
every platform. The `@metaharness/*` packages are imported *dynamically* inside
the commands that need them, so `guidance`/`--help` work with no dependencies
installed.
### Darwin, router, flywheel
- **Darwin Mode** (`@metaharness/darwin`, devDependency) — `npm run evolve` /
`evolve:dry` mutates the harness's own config and keeps only measurable
improvements.
- **Router** (`@metaharness/router`) — `src/router.ts` wires a real cost-optimal
`Router` (`qualityBar: 0.8`) over two model tiers. Its labelled examples are
illustrative seed data (see the file's honesty note), not measured eval-log
observations.
- **Flywheel** (`@metaharness/flywheel`) — `src/flywheel.ts` wires the real
promotion loop (propose → evaluate → gate → promote, Ed25519-signed,
independently replayable) with a SYNTHETIC proposer/evaluator
(`dataSource: 'SYNTHETIC'`, no model call). A LIVE run needs a real Proposer
and Evaluator supplied by the operator — see the file's comments.
## Relationship to the crate
This harness assists development *on* the WiFi Veil crate; it does not replace the
crate's own gates. The authoritative validation for a WiFi Veil change is still:
```bash
cargo test -p wifi-densepose-privshield --no-default-features
cargo clippy -p wifi-densepose-privshield --all-targets -- -D warnings
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 wifi-densepose-privshield-harness authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,68 @@
# wifi-densepose-privshield-harness
A metaharness (contributor harness) for
[`wifi-densepose-privshield`](../../v2/crates/wifi-densepose-privshield) — **WiFi Veil**,
the compliant-waveform WiFi-sensing privacy shield (ADR-288). Defined by ADR-289.
> **Advanced Coding** — architect → implement → review → test, plus a
> dependency-free WiFi Veil guidance surface. Modeled on `wifi-densepose-sar-harness`
> (ADR-286). Multi-host scaffold with a kernel that resolves native → wasm → js.
## Install
```bash
npm install -g wifi-densepose-privshield-harness
wifi-densepose-privshield-harness doctor
```
Or run without installing:
```bash
npx wifi-densepose-privshield-harness guidance --topic overview
```
## Commands
| Command | Deps needed | Purpose |
|---|---|---|
| `init` | kernel + host | Boot the kernel + host adapter |
| `doctor` | kernel + host | Verify the install end-to-end |
| `guidance --topic <t>` | **none** | Read-only WiFi Veil capability map (source-cited, evidence-labelled) |
| `route <e0..e3>` | router + `npm run build` | Cost-optimal model routing |
| `flywheel [gens]` | flywheel + `npm run build` | SYNTHETIC self-improvement demo |
`guidance` topics: `overview`, `threat`, `countermeasure`, `compliance`,
`optimization`, `experiment`. It needs no dependencies or build step, so it
works offline and in CI before `npm install`.
## What WiFi Veil is
WiFi Veil shapes a node's **own** beamforming feedback with keyed Givens rotations so
a third-party passive sniffer cannot re-identify people, while a keyed receiver
sees an essentially unchanged link. **Compliant waveform controls only — never
jamming.** Reference results are **SYNTHETIC / evidence level L0** (reproduced by
`cargo test`), never MEASURED until a hardware witness exists. See the crate's
[ADR-288](../../docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md) and
[research bundle](../../docs/research/privacy-shield/).
## Darwin, router, flywheel
- `npm run evolve` / `evolve:dry` — Darwin Mode self-mutation of the harness
config (`@metaharness/darwin`).
- `npm run route -- <e0> <e1> <e2> <e3>` (after `npm run build`) — cost-optimal
model routing (`@metaharness/router`).
- `npm run flywheel:dry` — the SYNTHETIC `@metaharness/flywheel` demo
(propose → evaluate → gate → promote, signed + independently replayable).
See `CLAUDE.md` and the honesty notes atop `src/router.ts` / `src/flywheel.ts`
for what is real wiring vs. illustrative/synthetic data.
## Scope
The harness is a **development aid**. It does not run a WiFi Veil radio, does not
emit RF, and cannot jam. It does not replace the crate's own gates — the
authoritative check for a WiFi Veil change is `cargo test -p wifi-densepose-privshield`.
## License
MIT
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
// Verifies the SYNTHETIC flywheel demo wires end-to-end: a non-empty lift curve
// and a replay bundle that verifies independently. Does NOT assert any real
// self-improvement — the proposer/evaluator are deterministic stand-ins.
import { describe, it, expect } from 'vitest';
import { runVeilFlywheelDemo, verifyVeilFlywheelDemo } from '../src/flywheel.js';
describe('wifi-densepose-privshield-harness — flywheel (SYNTHETIC)', () => {
it('produces a non-empty lift curve', async () => {
const result = await runVeilFlywheelDemo(3);
expect(result.liftCurve.length).toBeGreaterThan(0);
expect(result.generationsRun).toBeGreaterThan(0);
});
it('produces an independently verifiable replay bundle', async () => {
const result = await runVeilFlywheelDemo(3);
const verdict = verifyVeilFlywheelDemo(result);
expect(verdict.pass).toBe(true);
});
it('stamps the run as SYNTHETIC provenance', async () => {
const result = await runVeilFlywheelDemo(2);
expect(result.replayBundle.data_source).toBe('SYNTHETIC');
});
});
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
// The VEIL guidance map is dependency-free (no @metaharness/* import), so this
// test runs even before `npm install` resolves the kernel. It guards the
// read-only capability map the MCP/CLI `guidance` surface exposes.
import { describe, it, expect } from 'vitest';
import { run, guidanceReport } from '../bin/cli.js';
describe('wifi-densepose-privshield-harness — guidance', () => {
it('returns a source-cited report for a known topic', () => {
const r = guidanceReport('optimization');
expect(r.ok).toBe(true);
expect(r.summary.length).toBeGreaterThan(0);
expect(r.sources.some((s: string) => s.includes('optimize.rs'))).toBe(true);
expect(r.authority).toContain('read-only');
});
it('labels evidence as SYNTHETIC/L0', () => {
const r = guidanceReport('experiment');
expect(r.evidence).toContain('SYNTHETIC');
});
it('rejects an unknown topic and lists the valid ones', () => {
const r = guidanceReport('not-a-topic');
expect(r.ok).toBe(false);
expect(r.topics).toContain('overview');
expect(r.topics).toContain('compliance');
});
it('CLI `guidance --topic overview` exits 0; unknown topic exits non-zero', async () => {
expect(await run(['guidance', '--topic', 'overview'])).toBe(0);
expect(await run(['guidance', '--topic', 'nope'])).not.toBe(0);
});
});
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
// Verifies the cost-optimal router mechanism (not its illustrative data): cheap
// query shapes route to the cheap tier; hard shapes escalate to the frontier.
import { describe, it, expect } from 'vitest';
import { routeVeilQuery } from '../src/router.js';
describe('wifi-densepose-privshield-harness — router', () => {
it('routes a threat-model query (cheap-tier-capable) to the cheap tier', () => {
const pick = routeVeilQuery([1, 0, 0, 0]);
expect(pick.id).toBe('cheap-tier');
expect(pick.metBar).toBe(true);
});
it('escalates a compliance-review query to the frontier tier', () => {
const pick = routeVeilQuery([0, 1, 0, 0]);
expect(pick.id).toBe('frontier-tier');
});
it('escalates an optimizer-tuning query to the frontier tier', () => {
const pick = routeVeilQuery([0, 0, 1, 0]);
expect(pick.id).toBe('frontier-tier');
});
});
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
// A real smoke test for wifi-densepose-privshield-harness: it boots the actual
// kernel + host adapter the harness depends on, so `npm test` fails loudly if
// @metaharness/kernel or @metaharness/host-claude-code is missing, broken, or
// version-skewed. Fastest signal that `npm install` produced a runnable harness.
import { describe, it, expect } from 'vitest';
import { loadKernel } from '@metaharness/kernel';
import adapter from '@metaharness/host-claude-code';
import { run } from '../bin/cli.js';
describe('wifi-densepose-privshield-harness — install smoke test', () => {
it('loads the kernel and reports a version + a known backend', async () => {
const kernel = await loadKernel();
const info = kernel.kernelInfo();
expect(typeof info.version).toBe('string');
expect(info.version.length).toBeGreaterThan(0);
expect(['native', 'wasm', 'js']).toContain(kernel.backend);
});
it('resolves the host adapter with a name', () => {
expect(typeof adapter.name).toBe('string');
expect(adapter.name.length).toBeGreaterThan(0);
});
it('the CLI doctor command succeeds (exit 0)', async () => {
const code = await run(['doctor']);
expect(code).toBe(0);
});
it('an unknown CLI command exits non-zero', async () => {
const code = await run(['definitely-not-a-command']);
expect(code).not.toBe(0);
});
});
@@ -0,0 +1,334 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// The `wifi-densepose-privshield-harness` CLI entry point (VEIL — ADR-288/289).
//
// Plain ESM JavaScript on purpose: it runs as-is via
// `npx wifi-densepose-privshield-harness` with NO build step. `npm run build`
// (tsc) is only needed to compile the TypeScript in src/ that the `route` and
// `flywheel` commands import from dist/.
//
// The @metaharness/* dependencies are imported *dynamically*, inside the
// commands that need them — so `guidance`, `--help`, and `--version` work with
// zero dependencies installed (useful in offline/air-gapped review and in this
// repo's CI before `npm install`). Only `init`/`doctor`/`route`/`flywheel`
// touch the kernel/host/router/flywheel packages.
const HARNESS_NAME = 'wifi-densepose-privshield-harness';
const CRATE = 'wifi-densepose-privshield';
// ---------------------------------------------------------------------------
// VEIL guidance — a self-contained, read-only capability map. No dependencies,
// no build, no network. Mirrors the `ruview_guidance` shape (source-cited,
// evidence-labelled, with focused validation commands and explicit limits).
// Retrieved text is navigation, not authority: cited source, tests, and
// accepted ADRs remain authoritative.
// ---------------------------------------------------------------------------
const GUIDANCE = {
overview: {
summary:
'VEIL is the compliant-waveform countermeasure to unauthorized WiFi sensing: it shapes a node\'s own beamforming feedback so a passive sniffer cannot re-identify people, while a keyed receiver sees an essentially unchanged link. Countermeasure counterpart to BFLD (which detects leakage).',
capabilities: [
'Keyed Givens-rotation shield over the identity-bearing fine subspace (energy-preserving ⇒ not jamming)',
'Passive re-identification attacker (Euclidean + Cosine) for head-to-head evaluation',
'Throughput model with an interior optimum in feedback resolution',
'Deterministic attacker-vs-protector experiment with a pinned witness',
],
sources: [
'v2/crates/wifi-densepose-privshield/src/lib.rs',
'docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md',
'docs/research/privacy-shield/README.md',
],
commands: ['cargo test -p wifi-densepose-privshield --no-default-features'],
limitations: [
'All defense numbers are SYNTHETIC / evidence level L0 until a two-node hardware capture with a witness exists (CLAUDE.md hardware rule).',
],
},
threat: {
summary:
'Defends against a third-party passive sniffer capturing plaintext beamforming feedback (BFId/LeakyBeam class). Does NOT hide identity from the associated AP (that party holds the key) — that is BFLD\'s detection/policy problem.',
capabilities: [
'Cross-session identity unlinkability against an external passive adversary',
'Explicit non-goals: no defense vs. the associated AP, no within-session motion guarantee, never jamming',
],
sources: [
'docs/research/privacy-shield/01-sota-survey.md',
'docs/research/privacy-shield/02-threat-model.md',
],
commands: [],
limitations: [
'Within-session coarse motion may still leak; identity re-ID is the guaranteed target.',
],
},
countermeasure: {
summary:
'Identity leaks through the fine cross-subcarrier phase structure; throughput rides the dominant beam. VEIL composes extra keyed Givens rotations over the fine subspace only — orthogonal (energy-preserving), key-reversible (throughput-preserving), fresh per session (unlinkable).',
capabilities: [
'protector.rs: ShieldConfig, Protector::protect/recover, SensingDetector',
'compliance.rs: machine-checkable energy-conservation ("not jamming") audit',
],
sources: [
'v2/crates/wifi-densepose-privshield/src/protector.rs',
'v2/crates/wifi-densepose-privshield/src/compliance.rs',
'docs/research/privacy-shield/03-countermeasure-design.md',
],
commands: ['cargo test -p wifi-densepose-privshield protector'],
limitations: [
'The two-subspace separability is a model abstraction; real hardware is only approximately separable.',
],
},
compliance: {
summary:
'Compliant waveform controls only, never jamming. The keyed rotation is orthogonal, so it preserves the report energy exactly (ratio ≈ 1.0) — it adds no interfering emission. Jamming (47 U.S.C. §333/§302a) is defined by interfering with OTHERS\' transmissions, not shaping your own.',
capabilities: [
'ComplianceReport::audit / is_compliant — energy ratio + non-interference verdict',
],
sources: [
'v2/crates/wifi-densepose-privshield/src/compliance.rs',
'docs/research/privacy-shield/04-compliance-and-regulatory.md',
],
commands: ['cargo test -p wifi-densepose-privshield compliance'],
limitations: [
'Engineering analysis, not legal advice; RF power/mask/timing limits are jurisdiction-specific.',
],
},
optimization: {
summary:
'The shipped shield config is derived, not hand-picked: 96 Givens passes (2× the proven-minimum 48 for robust collapse across both attacker metrics and N∈{16,32}; extra passes are throughput-free since the rotation is keyed, not signaled) at 5-bit feedback (throughput-best in the 802.11 {5,7,9} set). ShieldConfig::default() is asserted equal to the optimizer output.',
capabilities: [
'optimize.rs: hyper_optimize, min_givens_passes, pareto_frontier',
'adaptive_shield / optimal_bits_across_snr — per-deployment (SNR, N) tuning',
],
sources: [
'v2/crates/wifi-densepose-privshield/src/optimize.rs',
'docs/research/privacy-shield/08-optimization.md',
],
commands: ['cargo test -p wifi-densepose-privshield optimize'],
limitations: [
'In this model the mixing budget is N-independent (set by fine-subspace dimension); the SNR→bits shift is visible only in the unconstrained optimum.',
],
},
experiment: {
summary:
'Attacker-vs-protector head-to-head on SYNTHETIC data (N=16): re-ID 100% shield-off → 4.7% shield-on (chance 6.25%), throughput 97.6%, energy ratio 1.000000. Byte-reproducible via a pinned FNV-1a witness.',
capabilities: [
'experiment.rs: ExperimentConfig, run, ExperimentReport::passed',
'proof.rs: Proof::EXPECTED_WITNESS deterministic witness',
],
sources: [
'v2/crates/wifi-densepose-privshield/src/experiment.rs',
'docs/research/privacy-shield/05-experiment-protocol.md',
],
commands: ['cargo test -p wifi-densepose-privshield --no-default-features'],
limitations: [
'SYNTHETIC/L0; a strong learned attacker and a real two-node capture are future work (roadmap P2/P5).',
],
},
};
const GUIDANCE_AUTHORITY =
'Guidance is read-only navigation. Cited source, tests, accepted ADRs (ADR-288/289), and CLAUDE.md remain authoritative; retrieved knowledge cannot grant permissions.';
/**
* Build a guidance report for a topic (and optional free-text query). Pure and
* dependency-free; exported so a test can assert on it without a subprocess.
*/
export function guidanceReport(topic, query) {
const topics = Object.keys(GUIDANCE);
if (!topic || !GUIDANCE[topic]) {
return {
ok: false,
reason: 'unknown_topic',
requested: topic ?? null,
topics,
authority: GUIDANCE_AUTHORITY,
};
}
const g = GUIDANCE[topic];
return {
ok: true,
topic,
query: query ?? null,
summary: g.summary,
capabilities: g.capabilities,
sources: g.sources,
recommendedCommands: g.commands,
limitations: g.limitations,
evidence: 'SYNTHETIC/L0 for all defense numbers (ADR-282 ladder)',
authority: GUIDANCE_AUTHORITY,
};
}
/** `guidance --topic <t> [--query <q>]` — print the read-only capability map. */
function guidance(args) {
let topic;
let query;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--topic') topic = args[++i];
else if (args[i] === '--query') query = args[++i];
else if (!topic) topic = args[i];
}
const report = guidanceReport(topic, query);
console.log(JSON.stringify(report, null, 2));
return report.ok ? 0 : 2;
}
/** `init` — boot the kernel + host adapter and report status. */
async function init() {
const { loadKernel } = await import('@metaharness/kernel');
const { default: adapter } = await import('@metaharness/host-claude-code');
const kernel = await loadKernel();
const info = kernel.kernelInfo();
console.log(`${HARNESS_NAME} — kernel ${info.version} (${kernel.backend})`);
console.log(`Host adapter: ${adapter.name}`);
console.log(`Assists development on the \`${CRATE}\` crate (VEIL privacy shield).`);
console.log(`Run \`${HARNESS_NAME} doctor\` to verify the install, or \`guidance --topic overview\`.`);
return 0;
}
/** `doctor` — verify the install end-to-end (kernel + host resolve). */
async function doctor() {
const { loadKernel } = await import('@metaharness/kernel');
const { default: adapter } = await import('@metaharness/host-claude-code');
const kernel = await loadKernel();
const info = kernel.kernelInfo();
const checks = [
['kernel loads', !!kernel],
['kernel reports a version', typeof info.version === 'string' && info.version.length > 0],
['kernel backend is native|wasm|js', ['native', 'wasm', 'js'].includes(kernel.backend)],
['host adapter has a name', typeof adapter?.name === 'string' && adapter.name.length > 0],
['guidance map resolves', guidanceReport('overview').ok === true],
];
let ok = true;
for (const [label, pass] of checks) {
console.log(`${pass ? 'PASS' : 'FAIL'} ${label}`);
if (!pass) ok = false;
}
console.log(
ok
? `\n${HARNESS_NAME}: all checks passed (kernel ${info.version}, ${kernel.backend} backend, host ${adapter.name})`
: `\n${HARNESS_NAME}: doctor found problems`,
);
return ok ? 0 : 1;
}
/**
* `route <e0> <e1> <e2> <e3>` — route a 4-axis task embedding to the
* cost-optimal model tier via @metaharness/router. Needs `npm run build`.
*/
async function route(args) {
const embedding = args.map(Number);
if (embedding.length !== 4 || embedding.some((n) => Number.isNaN(n))) {
console.error(
`Usage: ${HARNESS_NAME} route <threatModeling> <complianceReview> <optimizerTuning> <docWriting> (four 0..1 numbers)`,
);
return 2;
}
let routeVeilQuery;
try {
({ routeVeilQuery } = await import('../dist/router.js'));
} catch (err) {
console.error(`route: dist/router.js not found — run \`npm run build\` first. (${err.message})`);
return 1;
}
const pick = routeVeilQuery(embedding);
console.log(
`route -> ${pick.id} (predicted quality ${pick.predictedQuality.toFixed(3)}, $${pick.costPerMTok}/MTok, met bar: ${pick.metBar})`,
);
return 0;
}
/**
* `flywheel [generations]` — run the SYNTHETIC @metaharness/flywheel demo and
* print the lift curve + an independent replay-bundle verification. Needs
* `npm run build`.
*/
async function flywheel(args) {
const generations = args[0] ? Number(args[0]) : 3;
if (Number.isNaN(generations) || generations < 1) {
console.error(`Usage: ${HARNESS_NAME} flywheel [generations>=1]`);
return 2;
}
let runVeilFlywheelDemo, verifyVeilFlywheelDemo;
try {
({ runVeilFlywheelDemo, verifyVeilFlywheelDemo } = await import('../dist/flywheel.js'));
} catch (err) {
console.error(`flywheel: dist/flywheel.js not found — run \`npm run build\` first. (${err.message})`);
return 1;
}
console.log(`Running ${generations}-generation flywheel demo (dataSource: SYNTHETIC — see src/flywheel.ts)...`);
const result = await runVeilFlywheelDemo(generations);
for (const point of result.liftCurve) {
console.log(` gen ${point.generation}: primary=${point.primary.toFixed(3)} delta=${point.delta.toFixed(3)} anchor=${point.anchor ?? 'n/a'}`);
}
const verdict = verifyVeilFlywheelDemo(result);
console.log(`generations run: ${result.generationsRun} · promotions: ${result.promotions.length} · replay verified: ${verdict.pass}`);
return verdict.pass ? 0 : 1;
}
/**
* Dispatch one CLI invocation. Exported (not just run on import) so a test can
* drive it without spawning a subprocess. Returns the intended exit code.
*/
export async function run(argv) {
const cmd = argv[0] ?? 'init';
switch (cmd) {
case 'init':
return init();
case 'doctor':
return doctor();
case 'guidance':
return guidance(argv.slice(1));
case 'route':
return route(argv.slice(1));
case 'flywheel':
return flywheel(argv.slice(1));
case '--version':
case '-v': {
const { loadKernel } = await import('@metaharness/kernel');
const kernel = await loadKernel();
console.log(kernel.version());
return 0;
}
case '--help':
case '-h':
console.log(
`Usage: ${HARNESS_NAME} <command>\n\n` +
` init boot the kernel + host adapter (default)\n` +
` doctor verify the install end-to-end\n` +
` guidance --topic <t> read-only VEIL capability map (no deps/build)\n` +
` topics: overview threat countermeasure compliance optimization experiment\n` +
` route <e0..e3> cost-optimal model routing (needs \`npm run build\`)\n` +
` flywheel [generations] SYNTHETIC self-improvement demo (needs \`npm run build\`)\n` +
` --version print the kernel version`,
);
return 0;
default:
console.error(`Unknown command: ${cmd}. Try \`${HARNESS_NAME} --help\`.`);
return 2;
}
}
// CLI guard: execute only when invoked directly (not when imported by a test).
// npm's bin shims pass a NON-normalized argv[1], so realpath BOTH sides before
// comparing — a naive string === misses the npx/shim path and the CLI no-ops.
import { fileURLToPath } from 'node:url';
import { realpathSync } from 'node:fs';
import { argv } from 'node:process';
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch {
return false;
}
})();
if (invokedDirectly) {
run(argv.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
console.error(err);
process.exit(1);
});
}
@@ -0,0 +1,50 @@
{
"name": "wifi-densepose-privshield-harness",
"version": "0.1.0",
"description": "Harness for wifi-densepose-privshield (WiFi Veil — compliant-waveform WiFi-sensing privacy shield, ADR-288/289)",
"license": "MIT",
"type": "module",
"bin": {
"wifi-densepose-privshield-harness": "bin/cli.js"
},
"files": [
"bin/**",
"dist/**",
"src/**",
"tsconfig.json",
".claude/**",
".claude-plugin/**",
"CLAUDE.md",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"test": "vitest run",
"init": "node ./bin/cli.js init",
"doctor": "node ./bin/cli.js doctor",
"guidance": "node ./bin/cli.js guidance",
"evolve": "metaharness-darwin evolve . --sandbox real --generations 3 --children 4",
"evolve:dry": "metaharness-darwin evolve . --sandbox mock --generations 2 --children 3",
"route": "npm run build && node ./bin/cli.js route",
"flywheel:dry": "npm run build && node ./bin/cli.js flywheel 3"
},
"dependencies": {
"@metaharness/kernel": "^0.1.0",
"@metaharness/host-claude-code": "^0.1.0",
"@metaharness/router": "^0.3.2",
"@metaharness/flywheel": "^0.1.7"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.4.0",
"vitest": "^3.0.0",
"@metaharness/darwin": "^0.2.2"
},
"engines": {
"node": ">=20.0.0"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: MIT
//
// The wifi-densepose-privshield (VEIL) harness's self-improvement loop, via
// @metaharness/flywheel: run -> measure -> mutate -> verify -> promote, with a
// frozen, conjunctive promotion gate and a signed, replayable lineage.
//
// HONESTY NOTE (load-bearing): `runVeilFlywheelDemo()` wires the real
// @metaharness/flywheel API end-to-end, but its Proposer and Evaluator are
// SYNTHETIC stand-ins — a deterministic string mutation and a deterministic
// scoring function over that string, with NO model call and NO real benchmark.
// It proves the wiring works (see __tests__/flywheel.test.ts: a non-empty lift
// curve, a verifiable replay bundle) and gives a `dataSource: 'SYNTHETIC'`-
// stamped demo. A LIVE run needs the operator to supply:
// - a real Proposer: a model call that improves one policy lever (e.g. the
// compliance-review checklist, the threat-model triage prompt);
// - a real Evaluator: scores that policy against real tasks (e.g. "did the
// compliance reviewer catch a non-energy-preserving perturbation").
// Neither exists in this repo — wiring them is a live-API-key decision for the
// harness operator, not something to fake here.
import {
runFlywheelGenerations,
meetsPromotionRule,
makeSigner,
verifyReplayBundle,
type Policy,
type PolicyGenome,
type Proposer,
type Evaluator,
type Suite,
type FlywheelResult,
} from '@metaharness/flywheel';
/** The gen-0 operating policy for the VEIL harness's review agents. Opaque
* string levers — the flywheel never interprets their meaning, only the
* Evaluator does. */
export const VEIL_ROOT_POLICY: Policy = {
complianceReview: 'energy-ratio-checklist',
threatTriage: 'single-pass',
};
/** SYNTHETIC proposer: deterministically varies the target lever's value
* rather than calling a model. */
const syntheticProposer: Proposer = async (base: PolicyGenome, target: string) => {
const current = base.policy[target] ?? '';
return `${current}+g${base.generation + 1}`;
};
/** SYNTHETIC evaluator: scores a policy purely as a function of its own string
* content — a deterministic stand-in for running the harness's agents against a
* real task suite. `noopRate` must move for anything to promote (the default
* gate requires it to strictly improve generation over generation). */
const syntheticEvaluator: Evaluator = async (policy: Policy, _suite: Suite) => {
const totalLength = Object.values(policy).reduce((s, v) => s + v.length, 0);
const primary = Math.min(0.5 + totalLength / 200, 0.98);
const noopRate = Math.max(0.3 - totalLength / 300, 0.02);
return {
primary,
noopRate,
costPerWin: 1 / primary,
regressed: false,
};
};
const VEIL_HOLDOUT: Suite = {
id: 'veil-harness-holdout-synthetic',
items: ['seeded-compliance-task-1', 'seeded-threat-task-2', 'seeded-optimizer-task-3'],
};
const VEIL_ANCHOR: Suite = {
id: 'veil-harness-anchor-synthetic',
items: ['frozen-not-jamming-regression-1'],
};
/**
* Run a small, fully SYNTHETIC flywheel demo end-to-end and return the real
* @metaharness/flywheel result — a genuine lift curve and a signed,
* independently replayable bundle, built from synthetic (not live) evidence.
*/
export async function runVeilFlywheelDemo(maxGenerations = 3): Promise<FlywheelResult> {
return runFlywheelGenerations({
rootPolicy: VEIL_ROOT_POLICY,
proposer: syntheticProposer,
evaluator: syntheticEvaluator,
promotionRule: meetsPromotionRule,
holdout: VEIL_HOLDOUT,
anchor: VEIL_ANCHOR,
maxGenerations,
signer: makeSigner(),
dataSource: 'SYNTHETIC',
});
}
/** Independently verify a flywheel demo's replay bundle (no trust in the producer). */
export function verifyVeilFlywheelDemo(result: FlywheelResult) {
return verifyReplayBundle(result.replayBundle);
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// The harness's `wifi-densepose-privshield-harness init` entry (typed mirror of
// the JS command in bin/cli.js; the published CLI uses the JS version so no
// build is required for `init`).
import { loadKernel } from '@metaharness/kernel';
import adapter from '@metaharness/host-claude-code';
const HARNESS_NAME = 'wifi-densepose-privshield-harness';
async function main(): Promise<number> {
const kernel = await loadKernel();
const info = kernel.kernelInfo();
console.log(`${HARNESS_NAME} — kernel ${info.version} (${kernel.backend})`);
console.log(`Host adapter: ${adapter.name}`);
console.log(`Run \`${HARNESS_NAME} doctor\` to verify the install.`);
return 0;
}
main()
.then((c) => process.exit(c))
.catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MIT
//
// Cost-optimal task routing for the wifi-densepose-privshield (VEIL) harness,
// via @metaharness/router: route each agent query to the cheapest model
// predicted to clear a quality bar, instead of defaulting every query to the
// frontier tier.
//
// HONESTY NOTE: the candidate `examples` below are SEED/ILLUSTRATIVE data —
// four hand-picked (embedding, quality) points per candidate, not measured
// eval-log observations. They exist so `veilTaskRouter` is a real, runnable
// k-NN router out of the box (see __tests__/router.test.ts), not so its routing
// decisions should be trusted for production cost savings. Replace
// `VEIL_ROUTER_CANDIDATES[*].examples` with real (query embedding → quality
// achieved) rows from your own eval logs before relying on this.
import { Router, type RouterCandidate } from '@metaharness/router';
/**
* A 4-axis feature embedding for a harness query (each axis 0..1):
* [0] threatModeling — "is this attack in scope / what does VEIL defend"-shaped
* [1] complianceReview — "does this stay compliant / not jamming"-shaped
* [2] optimizerTuning — "tune passes/bits / re-run the optimizer"-shaped
* [3] docWriting — "write/update the research bundle or ADR"-shaped
* A caller with a real embedding model should project onto that model's
* dimensionality instead — the router only needs consistent vectors.
*/
export type VeilTaskEmbedding = readonly [number, number, number, number];
export const VEIL_ROUTER_CANDIDATES: RouterCandidate[] = [
{
id: 'cheap-tier',
costPerMTok: 1,
examples: [
{ embedding: [1, 0, 0, 0], quality: 0.88 }, // threat-model Q&A: cheap tier is fine
{ embedding: [0, 0, 0, 1], quality: 0.85 }, // doc writing: cheap tier is fine
{ embedding: [0, 1, 0, 0], quality: 0.55 }, // compliance review: cheap tier is weak
{ embedding: [0, 0, 1, 0], quality: 0.5 }, // optimizer tuning: cheap tier is weak
],
},
{
id: 'frontier-tier',
costPerMTok: 15,
examples: [
{ embedding: [1, 0, 0, 0], quality: 0.95 },
{ embedding: [0, 0, 0, 1], quality: 0.93 },
{ embedding: [0, 1, 0, 0], quality: 0.93 }, // compliance review: frontier tier needed
{ embedding: [0, 0, 1, 0], quality: 0.92 }, // optimizer tuning: frontier tier needed
],
},
];
/**
* Cost-optimal router for the harness's four query shapes above. `qualityBar`
* of 0.8: return the cheapest candidate predicted to clear 80% quality, or the
* best-predicted candidate if none do. k=1 because each candidate has only 4
* orthogonal one-hot examples (see the SAR harness note on why the default k=5
* would collapse every query to the same prediction here).
*/
export const veilTaskRouter = new Router({
qualityBar: 0.8,
candidates: VEIL_ROUTER_CANDIDATES,
k: 1,
});
/** Route one query embedding to the cost-optimal model tier. */
export function routeVeilQuery(queryEmbedding: VeilTaskEmbedding) {
return veilTaskRouter.route([...queryEmbedding]);
}
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "__tests__"]
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
// Strips the `#!/usr/bin/env node` shebang from importable entrypoints (e.g.
// bin/cli.js) before Vite parses them — Vite/esbuild (used internally by
// Vitest) does NOT strip shebangs, so importing a shebanged module throws
// `SyntaxError: Invalid or unexpected token`. No effect on direct CLI
// execution.
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
{
name: 'strip-shebang',
enforce: 'pre',
transform(code: string) {
if (code.startsWith('#!')) {
return { code: code.replace(/^#![^\n]*/, ''), map: null };
}
return null;
},
},
],
});
+1
View File
@@ -21,6 +21,7 @@ members = [
"crates/wifi-densepose-train", "crates/wifi-densepose-train",
"crates/wifi-densepose-sensing-server", "crates/wifi-densepose-sensing-server",
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only) "crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
"crates/wifi-densepose-privshield", # ADR-288 — VEIL privacy shield (compliant-waveform anti-sensing; std-only leaf)
"crates/wifi-densepose-wifiscan", "crates/wifi-densepose-wifiscan",
"crates/wifi-densepose-vitals", "crates/wifi-densepose-vitals",
"crates/wifi-densepose-ruvector", "crates/wifi-densepose-ruvector",
@@ -0,0 +1,32 @@
[package]
name = "wifi-densepose-privshield"
description = "WiFi Veil privacy shield (ADR-288): compliant-waveform countermeasure against unauthorized WiFi sensing. Deterministic attacker-vs-protector experiment that drives beamforming-feedback identity inference toward chance while preserving link throughput. Std-only pure-compute leaf, no async/server/RF-hardware deps; SYNTHETIC data only."
version = "0.1.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
documentation.workspace = true
keywords.workspace = true
categories.workspace = true
readme = "README.md"
# Intentionally dependency-free (mirrors `wifi-densepose-aether`, ADR-185 §13).
# WiFi Veil is a pure-compute experiment/reference: no `rand` (its own deterministic
# PRNG), no `std::time`/`std::fs`/`std::env`/threads, so it builds unchanged for
# `wasm32-unknown-unknown` and can never emit RF or touch a radio. The shield
# *models* compliant waveform controls; it does not drive hardware.
[dependencies]
[dev-dependencies]
[lib]
name = "wifi_densepose_privshield"
path = "src/lib.rs"
# `veil` — the custom, dependency-free terminal harness + TUI (ADR-288 §harness).
# Native counterpart to the npm metaharness. Std-only; builds without any extra
# deps. Excluded from the wasm leaf story (that stays `--lib`).
[[bin]]
name = "veil"
path = "src/bin/veil.rs"
@@ -0,0 +1,161 @@
![WiFi Veil Console — the shield engaged, with the room's WiFi identity clusters collapsed to the chance floor (re-ID 4.7%, throughput preserved, compliant)](docs/veil-console.png)
# wifi-densepose-privshield — WiFi Veil
**WiFi Veil** (codename **VEIL** — Verifiable Emission-shaping for
Identity-Leakage prevention) is the compliant-waveform **countermeasure**
counterpart to
[BFLD](../wifi-densepose-bfld) (ADR-118/121). BFLD *detects* when beamforming
feedback becomes identifying; WiFi Veil *acts* — it shapes a node's own outgoing
beamforming feedback so that an unauthorized passive sniffer cannot
re-identify people or infer activity, while a legitimate receiver (which shares
the per-session key) sees an essentially unchanged link.
This crate is a **deterministic, dependency-free, WASM-ready reference and
experiment** — not a radio driver. It never emits RF. Every number it prints is
`SYNTHETIC`, reproduced by `cargo test -p wifi-densepose-privshield`.
See [ADR-288](../../../docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md)
and the [research bundle](../../../docs/research/privacy-shield/). A per-crate npm
contributor harness lives at
[`harness/wifi-densepose-privshield/`](../../../harness/wifi-densepose-privshield)
(ADR-289): `npx wifi-densepose-privshield-harness guidance --topic overview`.
## How this protects you from unauthorized WiFi surveillance
**The threat — silent, device-free identification.** Since WiFi 5, your device
tells the router how to aim its signal by sending back *beamforming feedback*
and it goes out **unencrypted**. Anyone within radio range can passively capture
those reports and, from the tiny stable details in them, **tell individual
people apart by their radio "fingerprint"** — through walls, with no camera, no
app, and nothing you have to be carrying. Published research re-identifies
individuals, counts occupancy through walls, and even reads activity this way,
and the 2025 sensing standard (802.11bf) added the capability but **no privacy
protection**. Because the attacker only listens, you get no indication it is
happening.
**The defense — scramble the fingerprint, keep the link.** WiFi Veil adds a secret,
**per-session "twist"** to your own outgoing feedback, built from the same
rotation math (Givens rotations) the report already uses:
- Your **own router shares the key** and undoes the twist instantly, so it
decodes normally — **your WiFi keeps ~98% of its speed.**
- An **outside listener sees a *different* twist every session** and cannot
average many captures back into one stable fingerprint. Its guess of *who is
in the room* **collapses to chance** — no better than a random guess among the
possible people.
- The twist only **reshapes your own, standards-legal signal** — it preserves
the signal's energy exactly (`energy in = energy out`), so it is **compliant,
never jamming.** It never floods the air or blocks anyone else.
**What it does *not* do (kept honest).** WiFi Veil defends against a *third-party
sniffer*, not the access point you are connected to (that party holds the key —
protecting against a malicious AP is BFLD's detection job). It targets identity
re-identification; coarse motion obfuscation is future work. And every figure in
this crate is **SYNTHETIC / evidence-level L0** — it describes the reference
model and is *not* a measured guarantee on real hardware until validated with a
captured hardware log.
> **In one line:** it makes the room's WiFi stop leaking *who you are* to
> outside listeners, while your network keeps working and without breaking any
> radio rules.
## The idea
Identity leaks through the **fine** cross-subcarrier phase structure of a
compressed beamforming report; data throughput rides the **dominant** beam
direction. These live in (mostly) separable subspaces. WiFi Veil composes extra
**keyed Givens rotations** — the exact primitive the report is already built
from — over the *fine* subspace only:
| Property | Consequence |
|---|---|
| **Orthogonal** (energy-preserving) | No added transmit power ⇒ **not jamming** (47 U.S.C. §333/§302a) |
| **Keyed per session** | The legitimate AP inverts it ⇒ throughput preserved |
| **Fresh each session** | A sniffer sees a different rotation every time and can't average it back ⇒ re-identification collapses to chance |
## Result (hyper-optimized default scene, N = 16 identities)
| Metric | Shield off | Shield on |
|---|---|---|
| Passive re-ID accuracy | **100%** | **4.7%** (chance = 6.25%) |
| Link throughput ratio | 100% | **97.6%** |
| Emission energy ratio | — | **1.000000** (compliant) |
The shipped shield config is not hand-picked — it is the output of the
`optimize` module (ADR-288 §opt): **96 Givens passes** (2× the proven-minimum
48 for robust collapse across both attacker metrics and N∈{16,32}; extra passes
are free because the keyed rotation is never signaled) at **5-bit** feedback
resolution (the throughput-best value in the 802.11 {5,7,9} set). The
unconstrained model optimum is 3-bit, matching the DySPAN-2026 finding.
## Threat model & scope (stated plainly)
WiFi Veil defends against a **third-party passive sniffer** capturing plaintext
beamforming feedback. It does **not** hide identity from the AP a node is
associated with (that party holds the key by construction) — that is BFLD's
detection/policy problem, not this shield's. It is **compliant by
construction**: it only shapes the node's own standards-conformant frames, never
transmits to interfere with another station, and never operates an unauthorized
emitter. It is not jamming, not RF denial, and not a claim of camera-grade
anything.
## Run it
```bash
cargo test -p wifi-densepose-privshield --no-default-features
```
## `veil` — terminal harness & TUI
A custom, **dependency-free** native harness ships with the crate (the in-repo
counterpart to the npm metaharness). It drives the same model the tests use — an
interactive ANSI dashboard plus scriptable subcommands, std-only (no
`crossterm`/`ratatui`), so it runs in any terminal, pipe, or CI.
![veil TUI walkthrough — toggling the shield off/on, dropping to 32 passes (out of spec), back to 96 (pass), a ward preset, optimize, and a witness check](docs/veil-tui.gif)
```bash
cargo run -p wifi-densepose-privshield --bin veil # interactive TUI (or a one-shot report when piped)
cargo run -p wifi-densepose-privshield --bin veil -- sweep # re-ID vs passes + throughput vs bits
cargo run -p wifi-densepose-privshield --bin veil -- optimize
cargo run -p wifi-densepose-privshield --bin veil -- doctor # self-check, exit 0 = healthy
```
```text
┌──────────────────────────────────────────────────────────
│ WiFi Veil · wifi-sensing privacy shield ● PROTECTED
│ re-ID off 100.0% re-ID on 4.7% (chance 6.25%)
│ throughput 97.6% emission 1.000× · not jamming
│ collapse ████▇▆▅▂▂▂▁▂ passes 2→112 · op 96
│ config passes 96 · bits 5 · N 16 · snr 20dB · euclid
│ verdict ✓ PASS — re-ID at chance · throughput ≥95% · compliant
└──────────────────────────────────────────────────────────
```
In the TUI, type commands to steer the shield live: `on`/`off`, `passes <n>`,
`bits <n>`, `n <k>`, `snr <db>`, `metric euclid|cosine`,
`preset scif|board|ward|hotel`, `optimize`, `proof`, `quit`. All readouts are
**SYNTHETIC / L0**.
A self-contained graphical **WiFi Veil Console** web dashboard mirrors this same
instrument — it ships in [`ui/veil-console.html`](ui/veil-console.html) (open it
in any browser; no build, no network). `veil` is the terminal-native version.
## Modules
| Module | Purpose |
|---|---|
| `prng` | Deterministic, WASM-safe PRNG + key derivation |
| `linalg` | Givens-rotation vector algebra |
| `identity` | SYNTHETIC two-subspace beamforming-feedback model |
| `protector` | The compliant waveform controls: 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) |
| `proof` | Byte-stable deterministic witness |
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,364 @@
//! The adversary: a passive re-identification classifier over captured
//! beamforming feedback.
//!
//! The attacker models the BFId/CCS-2025 threat: a sniffer that enrolls a
//! template per candidate from observed reports, then classifies fresh
//! captures. We use a **nearest-centroid** classifier over the full report
//! vector. It is deliberately simple but is the right shape for the effect
//! under test: it succeeds exactly when a *stable* per-identity signature
//! survives across capture sessions, and fails when the signature is rotated
//! unpredictably each session (which is what the protector does).
//!
//! Nearest-centroid is also the honest choice for the collapse claim: a more
//! elaborate classifier cannot recover identity that has been mapped through a
//! fresh secret orthogonal transform each session — the mutual information
//! between a Haar-rotated signature and the identity label, marginalized over
//! unknown rotations, is what the protector drives down. The classifier
//! strength is not the lever; signature stability is.
use crate::identity::BfiSample;
use crate::linalg::{dist_sq, dot, norm, set_norm_inplace};
/// Similarity metric the attacker uses to match a capture to a centroid.
///
/// Sweeping the metric is how [`crate::optimize`] checks that the shield's
/// collapse is a property of the *signal* (a rotated signature carries no
/// stable identity), not an artifact of one classifier's geometry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Metric {
/// Euclidean nearest-centroid (default). Sensitive to magnitude.
#[default]
Euclidean,
/// Cosine nearest-centroid. Scale-invariant; a natural stronger attacker
/// against energy-preserving perturbations, since it ignores magnitude.
Cosine,
}
/// A nearest-centroid re-identification attacker.
#[derive(Debug, Clone, Default)]
pub struct NearestCentroidAttacker {
centroids: Vec<Vec<f32>>,
ids: Vec<usize>,
metric: Metric,
}
impl NearestCentroidAttacker {
/// Build an empty attacker using the Euclidean metric.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Build an empty attacker using the given metric.
#[must_use]
pub fn with_metric(metric: Metric) -> Self {
Self {
metric,
..Self::default()
}
}
/// Enroll from labeled captures: one centroid per identity, the mean of
/// that identity's observed report vectors.
pub fn enroll(&mut self, samples: &[(usize, BfiSample)]) {
// Group by identity, preserving first-seen order.
let mut ids: Vec<usize> = Vec::new();
let mut sums: Vec<Vec<f32>> = Vec::new();
let mut counts: Vec<usize> = Vec::new();
for (id, s) in samples {
let slot = ids.iter().position(|x| x == id).unwrap_or_else(|| {
ids.push(*id);
sums.push(vec![0.0; s.values.len()]);
counts.push(0);
ids.len() - 1
});
for (acc, v) in sums[slot].iter_mut().zip(&s.values) {
*acc += v;
}
counts[slot] += 1;
}
for (sum, &c) in sums.iter_mut().zip(&counts) {
if c > 0 {
let inv = 1.0 / c as f32;
for v in sum.iter_mut() {
*v *= inv;
}
}
}
self.ids = ids;
self.centroids = sums;
}
/// Classify a capture to the nearest enrolled centroid. Returns the
/// predicted identity, or `None` if the attacker has not enrolled.
#[must_use]
pub fn classify(&self, sample: &BfiSample) -> Option<usize> {
// Score is "lower is better" for both metrics: Euclidean uses squared
// distance; Cosine uses the negated similarity.
let score = |c: &[f32]| -> f32 {
match self.metric {
Metric::Euclidean => dist_sq(c, &sample.values),
Metric::Cosine => {
let denom = norm(c) * norm(&sample.values);
if denom > 1e-12 {
-dot(c, &sample.values) / denom
} else {
0.0
}
}
}
};
let mut best: Option<(usize, f32)> = None;
for (id, c) in self.ids.iter().zip(&self.centroids) {
let d = score(c);
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((*id, d));
}
}
best.map(|(id, _)| id)
}
/// Top-1 re-identification accuracy over a labeled test set.
#[must_use]
pub fn accuracy(&self, test: &[(usize, BfiSample)]) -> f32 {
if test.is_empty() {
return 0.0;
}
let correct = test
.iter()
.filter(|(id, s)| self.classify(s) == Some(*id))
.count();
correct as f32 / test.len() as f32
}
}
/// 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)]
mod tests {
use super::*;
use crate::identity::{Channel, SceneConfig};
#[test]
fn attacker_re_ids_unprotected_traffic() {
let ch = Channel::new(SceneConfig::default());
let mut enroll = Vec::new();
let mut test = Vec::new();
for id in 0..ch.config().identities {
for s in 0..12 {
enroll.push((id, ch.observe(id, b"enroll", s)));
}
for s in 0..12 {
test.push((id, ch.observe(id, b"test", s)));
}
}
let mut atk = NearestCentroidAttacker::new();
atk.enroll(&enroll);
// On unprotected traffic the stable signature is trivially recovered.
assert!(atk.accuracy(&test) > 0.85);
}
}
@@ -0,0 +1,549 @@
//! `veil` — a custom, dependency-free terminal harness for the VEIL privacy
//! shield (ADR-288). It is the in-repo, native counterpart to the npm
//! metaharness (`harness/wifi-densepose-privshield/`, ADR-289): where that one
//! assists *development*, this one *drives the model* — an interactive TUI plus
//! scriptable subcommands over the same crate API the tests use.
//!
//! Std-only on purpose: no `crossterm`/`ratatui`, no external deps. The TUI is
//! a command-driven ANSI dashboard (line input, redraw on change), which keeps
//! the crate a pure leaf and lets the harness run in any pipe or CI.
//!
//! ```text
//! veil # TUI if attached to a terminal, else a one-shot report
//! veil tui # force the interactive dashboard
//! veil report # print the dashboard once (plain, pipe-friendly)
//! veil sweep # re-ID vs passes and throughput vs bits tables
//! veil optimize # run the hyper-optimizer, print the recommendation
//! veil adaptive <N> # derive the shield for a room of N candidate identities
//! veil proof # verify the deterministic witness
//! veil doctor # self-check (exit 0 = healthy)
//! ```
//!
//! All numbers are **SYNTHETIC / L0** — reproduced by `cargo test`, describing
//! the model, not real hardware.
use std::io::{self, BufRead, IsTerminal, Write};
use veil::optimize;
use veil::{run, ExperimentConfig, ExperimentReport, Metric, Proof};
use wifi_densepose_privshield as veil;
// ---- ANSI palette (matches the VEIL Console: teal shield, amber threat) ----
const TEAL: &str = "\x1b[38;2;32;211;192m";
const AMBER: &str = "\x1b[38;2;245;158;75m";
const GOOD: &str = "\x1b[38;2;62;207;142m";
const CRIT: &str = "\x1b[38;2;242;107;111m";
const MUTE: &str = "\x1b[38;2;139;160;159m";
const BOLD: &str = "\x1b[1m";
const RST: &str = "\x1b[0m";
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
/// Emit color for a real terminal; never when `NO_COLOR` is set; always when
/// `CLICOLOR_FORCE` is set (so piped captures keep their color).
fn color_enabled() -> bool {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
if std::env::var_os("CLICOLOR_FORCE").is_some() {
return true;
}
io::stdout().is_terminal()
}
/// Wrap `s` in `code` when color is on.
fn c(s: &str, code: &str, on: bool) -> String {
if on {
format!("{code}{s}{RST}")
} else {
s.to_string()
}
}
/// A raw color code, or "" when color is off — for inline `format!` colouring.
fn k(code: &'static str, on: bool) -> &'static str {
if on {
code
} else {
""
}
}
/// Shield-on re-ID at a given mixing budget, holding the rest of `cfg`.
fn reid_at(cfg: &ExperimentConfig, passes: usize) -> f32 {
let mut c = cfg.clone();
c.shield.givens_passes = passes;
run(&c).accuracy_shield_on
}
/// A block-sparkline character for a value in `[0, 1]`.
fn spark(v: f32) -> char {
let i = (v.clamp(0.0, 1.0) * 7.0).round() as usize;
BLOCKS[i.min(7)]
}
/// Render the full dashboard as colored lines (left-bar panel; no right border,
/// so ANSI escape width never has to be counted).
fn dashboard(cfg: &ExperimentConfig, on: bool) -> Vec<String> {
let rep: ExperimentReport = run(cfg);
let chance = rep.chance_level * 100.0;
let off = rep.accuracy_shield_off * 100.0;
let onp = rep.accuracy_shield_on * 100.0;
let tp = rep.throughput_ratio * 100.0;
let (state, scode) = if !cfg.shield.enabled {
("EXPOSED", CRIT)
} else if rep.passed() {
("PROTECTED", GOOD)
} else {
("AT RISK", AMBER)
};
let on_code = if onp <= rep.chance_band * 100.0 {
GOOD
} else {
AMBER
};
let tp_code = if tp >= 95.0 { GOOD } else { CRIT };
let bar = c("", MUTE, on);
let mut out = Vec::new();
out.push(c(
"┌──────────────────────────────────────────────────────────",
MUTE,
on,
));
out.push(format!(
"{} {}{}VEIL{} {}· wifi-sensing privacy shield{} {}{}{}",
bar,
k(BOLD, on),
k(TEAL, on),
k(RST, on),
k(MUTE, on),
k(RST, on),
k(scode, on),
state,
k(RST, on),
));
out.push(bar.clone());
out.push(format!(
"{} re-ID off {}{:>6.1}%{} re-ID on {}{:>5.1}%{} {}(chance {:.2}%){}",
bar,
k(AMBER, on),
off,
k(RST, on),
k(on_code, on),
onp,
k(RST, on),
k(MUTE, on),
chance,
k(RST, on),
));
out.push(format!(
"{} throughput {}{:>6.1}%{} emission {}{:.3}×{} {}· not jamming{}",
bar,
k(tp_code, on),
tp,
k(RST, on),
k(GOOD, on),
rep.compliance.energy_ratio,
k(RST, on),
k(MUTE, on),
k(RST, on),
));
out.push(bar.clone());
let cand = optimize::PASS_CANDIDATES;
let line: String = cand.iter().map(|&p| spark(reid_at(cfg, p))).collect();
out.push(format!(
"{} {}collapse{} {}{}{} {}passes {}{} · op {}{}",
bar,
k(MUTE, on),
k(RST, on),
k(TEAL, on),
line,
k(RST, on),
k(MUTE, on),
cand[0],
cand[cand.len() - 1],
cfg.shield.givens_passes,
k(RST, on),
));
out.push(bar.clone());
let metric = match cfg.attacker_metric {
Metric::Euclidean => "euclid",
Metric::Cosine => "cosine",
};
out.push(format!(
"{} {}config{} passes {} · bits {} · N {} · snr {:.0}dB · {}",
bar,
k(MUTE, on),
k(RST, on),
cfg.shield.givens_passes,
cfg.shield.feedback_bits,
cfg.scene.identities,
cfg.link.snr_db,
metric,
));
let (vlabel, vcode) = if !cfg.shield.enabled {
("SHIELD OFF — room exposed", CRIT)
} else if rep.passed() {
(
"✓ PASS — re-ID at chance · throughput ≥95% · compliant",
GOOD,
)
} else {
(
"△ OUT OF SPEC — raise passes/bits to re-enter the chance band",
AMBER,
)
};
out.push(format!(
"{} {}verdict{} {}{}{}",
bar,
k(MUTE, on),
k(RST, on),
k(vcode, on),
vlabel,
k(RST, on)
));
out.push(c(
"└──────────────────────────────────────────────────────────",
MUTE,
on,
));
out
}
/// Deployment presets (mirror `optimize::adaptive_shield` results per room).
fn preset(name: &str, cfg: &mut ExperimentConfig) -> bool {
let (n, passes, bits, snr) = match name {
"scif" => (64, 96, 5, 20.0),
"board" => (16, 96, 5, 25.0),
"ward" => (32, 96, 5, 15.0),
"hotel" => (48, 64, 5, 20.0),
_ => return false,
};
cfg.scene.identities = n;
cfg.shield.givens_passes = passes;
cfg.shield.feedback_bits = bits;
cfg.link.snr_db = snr;
true
}
fn print_dashboard(cfg: &ExperimentConfig, on: bool) {
for l in dashboard(cfg, on) {
println!("{l}");
}
}
fn cmd_sweep(cfg: &ExperimentConfig, on: bool) {
println!(
"{}re-ID (shield on) vs Givens passes — N={}{}",
k(MUTE, on),
cfg.scene.identities,
k(RST, on)
);
for &p in &optimize::PASS_CANDIDATES {
let robust =
optimize::passes_collapse_at_n(cfg, p, cfg.shield.feedback_bits, cfg.scene.identities);
println!(
" passes {:>3} re-ID {:>5.1}% {}",
p,
reid_at(cfg, p) * 100.0,
if robust {
c("collapses", GOOD, on)
} else {
c("above chance", AMBER, on)
}
);
}
println!(
"\n{}throughput vs feedback bits — snr={:.0}dB{}",
k(MUTE, on),
cfg.link.snr_db,
k(RST, on)
);
for bits in 1..=12u32 {
let mut s = cfg.shield.clone();
s.feedback_bits = bits;
let tp = cfg.link.throughput_ratio(&s) * 100.0;
let barlen = ((tp - 90.0).clamp(0.0, 10.0) / 10.0 * 24.0) as usize;
println!(
" {:>2} bit {:>6.3}% {}{}{}",
bits,
tp,
k(TEAL, on),
"".repeat(barlen),
k(RST, on)
);
}
let (sb, _) = optimize::spec_optimal_feedback_bits(cfg);
println!(" {}spec-optimal: {} bit{}", k(MUTE, on), sb, k(RST, on));
}
fn cmd_optimize(cfg: &ExperimentConfig, on: bool) {
let opt = veil::hyper_optimize(cfg);
let r = &opt.report;
println!("{}hyper-optimizer{}", k(BOLD, on), k(RST, on));
println!(" min robust passes : {}", opt.min_passes);
println!(
" shipped passes : {} {}(min × 2 margin, throughput-free){}",
opt.shipped_passes,
k(MUTE, on),
k(RST, on)
);
println!(
" spec-optimal bits : {} {}(model optimum {}){}",
opt.spec_optimal_bits,
k(MUTE, on),
opt.model_optimal_bits,
k(RST, on)
);
println!(
" result : re-ID {}{:.1}%{} · throughput {}{:.1}%{} · {}",
k(GOOD, on),
r.accuracy_shield_on * 100.0,
k(RST, on),
k(GOOD, on),
r.throughput_ratio * 100.0,
k(RST, on),
if r.passed() {
c("PASS", GOOD, on)
} else {
c("FAIL", CRIT, on)
}
);
println!(
" {}SNR → model-optimal bits: {:?}{}",
k(MUTE, on),
optimize::optimal_bits_across_snr(cfg),
k(RST, on)
);
}
fn cmd_adaptive(cfg: &ExperimentConfig, n: usize, on: bool) {
let sh = veil::adaptive_shield(cfg, n);
println!(
"adaptive shield for N={}: passes {} · bits {} {}(mixing budget is N-independent in this model){}",
n, sh.givens_passes, sh.feedback_bits, k(MUTE, on), k(RST, on)
);
}
fn cmd_proof(on: bool) -> i32 {
let w = Proof::witness(&Proof::run_reference());
let ok = w == Proof::EXPECTED_WITNESS;
println!(
"witness {:#018x} expected {:#018x} {}",
w,
Proof::EXPECTED_WITNESS,
if ok {
c("MATCH", GOOD, on)
} else {
c("DRIFT", CRIT, on)
}
);
i32::from(!ok)
}
fn cmd_doctor(on: bool) -> i32 {
let rep = run(&ExperimentConfig::default());
let checks = [
("reference experiment passes", rep.passed()),
(
"attack is real without shield",
rep.attack_is_effective_without_shield(),
),
("collapse drives to chance", rep.drives_to_chance()),
("throughput ≥ 95%", rep.preserves_throughput()),
("emission is compliant", rep.compliance.is_compliant()),
(
"deterministic witness matches",
Proof::witness(&Proof::run_reference()) == Proof::EXPECTED_WITNESS,
),
];
let mut ok = true;
for (label, pass) in checks {
ok &= pass;
println!(
"{} {label}",
if pass {
c("PASS", GOOD, on)
} else {
c("FAIL", CRIT, on)
}
);
}
println!(
"\nveil doctor: {}",
if ok {
c("all checks passed", GOOD, on)
} else {
c("problems found", CRIT, on)
}
);
i32::from(!ok)
}
fn help() {
println!(
"veil — VEIL privacy-shield harness (SYNTHETIC / L0)\n\n\
USAGE\n veil [command]\n\n\
COMMANDS\n\
\x20 tui interactive dashboard (default on a terminal)\n\
\x20 report print the dashboard once\n\
\x20 sweep re-ID vs passes + throughput vs bits\n\
\x20 optimize run the hyper-optimizer\n\
\x20 adaptive <N> derive the shield for N candidate identities\n\
\x20 proof verify the deterministic witness\n\
\x20 doctor self-check (exit 0 = healthy)\n\
\x20 help this text\n\n\
TUI COMMANDS (type + Enter)\n\
\x20 on | off toggle the shield\n\
\x20 passes <n> · bits <n> · n <k> · snr <db>\n\
\x20 metric euclid|cosine\n\
\x20 preset scif|board|ward|hotel\n\
\x20 run | optimize | proof | help | quit"
);
}
fn tui(mut cfg: ExperimentConfig, on: bool) {
let stdin = io::stdin();
let interactive = stdin.is_terminal();
let redraw = |cfg: &ExperimentConfig, msg: &str| {
if interactive {
print!("\x1b[2J\x1b[H");
}
print_dashboard(cfg, on);
if !msg.is_empty() {
println!(" {}{}{}", k(MUTE, on), msg, k(RST, on));
}
print!("{}veil{} ", k(TEAL, on), k(RST, on));
let _ = io::stdout().flush();
};
redraw(&cfg, "type `help` for commands");
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let mut it = line.split_whitespace();
let cmd = it.next().unwrap_or("");
let arg = it.next().unwrap_or("");
let mut msg = String::new();
match cmd {
"" => {}
"quit" | "q" | "exit" => break,
"help" | "h" => {
if interactive {
print!("\x1b[2J\x1b[H");
}
help();
continue;
}
"on" => cfg.shield.enabled = true,
"off" => cfg.shield.enabled = false,
"passes" => match arg.parse::<usize>() {
Ok(v) => cfg.shield.givens_passes = v.clamp(1, 512),
Err(_) => msg = "passes: need a number".into(),
},
"bits" => match arg.parse::<u32>() {
Ok(v) => cfg.shield.feedback_bits = v.clamp(1, 12),
Err(_) => msg = "bits: need 1..12".into(),
},
"n" => match arg.parse::<usize>() {
Ok(v) => cfg.scene.identities = v.clamp(2, 128),
Err(_) => msg = "n: need 2..128".into(),
},
"snr" => match arg.parse::<f64>() {
Ok(v) => cfg.link.snr_db = v.clamp(0.0, 60.0),
Err(_) => msg = "snr: need a number (dB)".into(),
},
"metric" => match arg {
"euclid" | "euclidean" => cfg.attacker_metric = Metric::Euclidean,
"cosine" | "cos" => cfg.attacker_metric = Metric::Cosine,
_ => msg = "metric: euclid | cosine".into(),
},
"preset" => {
if !preset(arg, &mut cfg) {
msg = "preset: scif | board | ward | hotel".into();
}
}
"run" => msg = "ran — numbers above reflect current settings".into(),
"optimize" | "opt" => {
cfg.shield = veil::hyper_optimize(&cfg).shield;
msg = format!(
"optimized → passes {} · bits {}",
cfg.shield.givens_passes, cfg.shield.feedback_bits
);
}
"proof" => {
let w = Proof::witness(&Proof::run_reference());
msg = format!(
"witness {:#018x} ({})",
w,
if w == Proof::EXPECTED_WITNESS {
"match"
} else {
"drift"
}
);
}
other => msg = format!("unknown: {other} (try `help`)"),
}
redraw(&cfg, &msg);
}
if interactive {
println!();
}
}
fn main() {
let on = color_enabled();
let args: Vec<String> = std::env::args().skip(1).collect();
let cfg = ExperimentConfig::default();
let code = match args.first().map(String::as_str).unwrap_or("") {
"" => {
if io::stdout().is_terminal() {
tui(cfg, on);
} else {
print_dashboard(&cfg, on);
}
0
}
"tui" => {
tui(cfg, on);
0
}
"report" => {
print_dashboard(&cfg, on);
0
}
"sweep" => {
cmd_sweep(&cfg, on);
0
}
"optimize" | "opt" => {
cmd_optimize(&cfg, on);
0
}
"adaptive" => {
let n = args
.get(1)
.and_then(|s| s.parse().ok())
.unwrap_or(cfg.scene.identities);
cmd_adaptive(&cfg, n, on);
0
}
"proof" => cmd_proof(on),
"doctor" => cmd_doctor(on),
"help" | "-h" | "--help" => {
help();
0
}
other => {
eprintln!("unknown command: {other}. Try `veil help`.");
2
}
};
std::process::exit(code);
}
@@ -0,0 +1,79 @@
//! Machine-checkable compliance: the shield shapes its own frames, never jams.
//!
//! Jamming (47 U.S.C. §333, §302a) is defined by *adding energy to interfere
//! with others' transmissions*. VEIL's protector applies an **orthogonal**
//! transform to its own beamforming feedback, which preserves the report's
//! energy exactly. This module turns that invariant into a checked artifact: it
//! measures the input/output energy of a protection step and asserts the ratio
//! is ~1, i.e. no energy was added. A regulator, an auditor, or the runtime
//! attestation layer (ADR-141) can read a [`ComplianceReport`] and see the
//! shield is a waveform-shaping control, not an emitter of interference.
use crate::identity::BfiSample;
use crate::linalg::norm_sq;
/// Tolerance on the energy ratio. Orthogonal rotations are exact up to f32
/// round-off across many Givens passes.
pub const ENERGY_TOLERANCE: f32 = 1e-2;
/// The result of auditing one protection step.
#[derive(Debug, Clone, PartialEq)]
pub struct ComplianceReport {
/// Energy of the report before protection.
pub input_energy: f32,
/// Energy of the report after protection.
pub output_energy: f32,
/// `output_energy / input_energy`. ~1.0 for an energy-preserving control.
pub energy_ratio: f32,
/// True iff the energy ratio is within [`ENERGY_TOLERANCE`] of 1.0.
pub energy_conserving: bool,
/// True iff the control adds energy on top of another station's signal.
/// Always false for VEIL by construction — it transforms its own report.
pub adds_interfering_energy: bool,
}
impl ComplianceReport {
/// Audit a `(before, after)` protection pair.
#[must_use]
pub fn audit(before: &BfiSample, after: &BfiSample) -> Self {
let input_energy = norm_sq(&before.values);
let output_energy = norm_sq(&after.values);
let energy_ratio = if input_energy > 1e-12 {
output_energy / input_energy
} else {
1.0
};
Self {
input_energy,
output_energy,
energy_ratio,
energy_conserving: (energy_ratio - 1.0).abs() <= ENERGY_TOLERANCE,
adds_interfering_energy: false,
}
}
/// The bottom-line compliance verdict: energy-preserving and
/// non-interfering ⇒ a compliant waveform control, not jamming.
#[must_use]
pub fn is_compliant(&self) -> bool {
self.energy_conserving && !self.adds_interfering_energy
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{Channel, SceneConfig};
use crate::protector::{Protector, ShieldConfig};
#[test]
fn protection_is_compliant() {
let ch = Channel::new(SceneConfig::default());
let s = ch.observe(0, b"enroll", 3);
let p = Protector::new(ShieldConfig::default());
let out = p.protect(&s, 555);
let report = ComplianceReport::audit(&s, &out);
assert!(report.is_compliant(), "{report:?}");
assert!((report.energy_ratio - 1.0).abs() < ENERGY_TOLERANCE);
}
}
@@ -0,0 +1,352 @@
//! The attacker-vs-protector head-to-head.
//!
//! This is the "one node is the attacker, one node is the protector" experiment
//! from the project brief, in deterministic synthetic form. It runs the passive
//! re-identification attacker ([`crate::attacker`]) twice — once against
//! unprotected traffic and once against traffic shaped by the protector
//! ([`crate::protector`]) — and reports both accuracies against the chance
//! floor, alongside the modeled link throughput ([`crate::throughput`]) and a
//! compliance audit ([`crate::compliance`]).
//!
//! Success criteria (the brief's own bar):
//! 1. protection drives re-identification toward chance (`1/identities`);
//! 2. throughput stays above 95% of the unshielded baseline;
//! 3. the control is compliant (energy-preserving, non-jamming).
use crate::attacker::{
AdaptivePoolingAttacker, AttackerKind, Metric, NearestCentroidAttacker, ReconstructionAttacker,
};
use crate::compliance::ComplianceReport;
use crate::identity::{Channel, SceneConfig};
use crate::prng::derive_key;
use crate::protector::{ObfMode, Protector, ShieldConfig};
use crate::throughput::LinkModel;
/// Configuration for a full experiment.
#[derive(Debug, Clone)]
pub struct ExperimentConfig {
/// Synthetic scene.
pub scene: SceneConfig,
/// Protector configuration.
pub shield: ShieldConfig,
/// Link model for the throughput estimate.
pub link: LinkModel,
/// Enrollment sessions per identity.
pub enroll_sessions: u64,
/// Test sessions per identity.
pub test_sessions: u64,
/// Accept re-ID as "at chance" if it is at or below
/// `chance × chance_multiple + chance_margin`.
pub chance_multiple: f32,
/// Additive slack on the chance band.
pub chance_margin: f32,
/// Minimum acceptable throughput ratio.
pub min_throughput_ratio: f64,
/// 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 {
fn default() -> Self {
Self {
scene: SceneConfig::default(),
shield: ShieldConfig::default(),
link: LinkModel::default(),
enroll_sessions: 12,
test_sessions: 12,
chance_multiple: 2.0,
chance_margin: 0.03,
min_throughput_ratio: 0.95,
attacker_metric: Metric::Euclidean,
attacker_kind: AttackerKind::NearestCentroid,
}
}
}
/// The outcome of an experiment.
#[derive(Debug, Clone, PartialEq)]
pub struct ExperimentReport {
/// Number of candidate identities.
pub identities: usize,
/// Ideal chance-level accuracy (`1/identities`).
pub chance_level: f32,
/// Re-identification accuracy with the shield off.
pub accuracy_shield_off: f32,
/// Re-identification accuracy with the shield on.
pub accuracy_shield_on: f32,
/// Modeled throughput ratio of the protected link vs baseline.
pub throughput_ratio: f64,
/// Compliance audit of a representative protected frame.
pub compliance: ComplianceReport,
/// Upper edge of the accepted "at chance" band.
pub chance_band: f32,
}
impl ExperimentReport {
/// Did protection drive re-identification into the chance band?
#[must_use]
pub fn drives_to_chance(&self) -> bool {
self.accuracy_shield_on <= self.chance_band
}
/// Is the shield-off attacker meaningfully better than chance (i.e. the
/// threat is real in this scene, so the collapse is meaningful)?
#[must_use]
pub fn attack_is_effective_without_shield(&self) -> bool {
self.accuracy_shield_off >= 0.5
}
/// Did throughput stay above the required floor?
#[must_use]
pub fn preserves_throughput(&self) -> bool {
self.throughput_ratio >= 0.95
}
/// Overall pass: real threat, collapsed to chance, throughput preserved,
/// and compliant.
#[must_use]
pub fn passed(&self) -> bool {
self.attack_is_effective_without_shield()
&& self.drives_to_chance()
&& self.preserves_throughput()
&& self.compliance.is_compliant()
}
}
/// Build the enroll/test capture sets for a given shield, then measure attacker
/// accuracy. `shield_on` selects whether the protector is applied to every
/// captured frame (the attacker only ever sees what is transmitted).
fn measure_accuracy(
cfg: &ExperimentConfig,
ch: &Channel,
protector: &Protector,
shield_on: bool,
) -> f32 {
let mut enroll = Vec::new();
let mut test = Vec::new();
for id in 0..cfg.scene.identities {
for s in 0..cfg.enroll_sessions {
let raw = ch.observe(id, b"enroll", s);
let seen = if shield_on {
protector.protect(&raw, rotation_key(cfg, b"enroll", s, id))
} else {
raw
};
enroll.push((id, seen));
}
for s in 0..cfg.test_sessions {
let raw = ch.observe(id, b"test", s);
let seen = if shield_on {
protector.protect(&raw, rotation_key(cfg, b"test", s, id))
} else {
raw
};
test.push((id, seen));
}
}
// 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.
#[must_use]
pub fn run(cfg: &ExperimentConfig) -> ExperimentReport {
let protector = Protector::new(cfg.shield.clone());
let ch = Channel::new(cfg.scene.clone());
let accuracy_shield_off = measure_accuracy(cfg, &ch, &protector, false);
let accuracy_shield_on = measure_accuracy(cfg, &ch, &protector, true);
let throughput_ratio = cfg.link.throughput_ratio(&cfg.shield);
// Representative compliance audit: one protected frame vs its clean form.
let clean = ch.observe(0, b"test", 0);
let protected = protector.protect(&clean, derive_key(cfg.scene.seed, b"rot-test", 0, 0));
let compliance = ComplianceReport::audit(&clean, &protected);
let chance_level = cfg.scene.chance_level();
let chance_band = chance_level * cfg.chance_multiple + cfg.chance_margin;
ExperimentReport {
identities: cfg.scene.identities,
chance_level,
accuracy_shield_off,
accuracy_shield_on,
throughput_ratio,
compliance,
chance_band,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shield_off_attack_succeeds() {
let report = run(&ExperimentConfig::default());
assert!(
report.attack_is_effective_without_shield(),
"shield-off accuracy {} should be well above chance {}",
report.accuracy_shield_off,
report.chance_level
);
}
#[test]
fn shield_on_drives_to_chance() {
let report = run(&ExperimentConfig::default());
assert!(
report.drives_to_chance(),
"shield-on accuracy {} should be within chance band {}",
report.accuracy_shield_on,
report.chance_band
);
}
#[test]
fn shield_preserves_throughput() {
let report = run(&ExperimentConfig::default());
assert!(
report.preserves_throughput(),
"throughput ratio {} below 0.95",
report.throughput_ratio
);
}
#[test]
fn overall_experiment_passes() {
let report = run(&ExperimentConfig::default());
assert!(report.passed(), "{report:#?}");
}
#[test]
fn experiment_is_deterministic() {
assert_eq!(
run(&ExperimentConfig::default()),
run(&ExperimentConfig::default())
);
}
// ---- 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
);
}
}
@@ -0,0 +1,204 @@
//! Synthetic beamforming-feedback model. **SYNTHETIC data only.**
//!
//! Nothing here is captured from a real radio. The model is a deliberately
//! simple, physically-motivated abstraction of a flattened 802.11 compressed
//! beamforming report, chosen so the attacker/protector dynamics are
//! transparent and the experiment is byte-reproducible. It is *not* a channel
//! simulator and its accuracy numbers describe this model, not real hardware
//! (per CLAUDE.md: results are `SYNTHETIC`, reproduced by `cargo test`).
//!
//! # The two-subspace abstraction
//!
//! A beamforming report is split into two orthogonal blocks:
//!
//! - **Comm block** (`comm_dims` leading coordinates) — the dominant beam
//! direction the AP actually uses to steer data. It varies per session with
//! position/traffic and carries **no** identity. Link throughput rides here.
//! - **Fine block** (the remainder) — the fine cross-subcarrier phase
//! structure. This is where a re-identification attacker's signal lives: the
//! literature (BFId, CCS 2025) shows the *stable* fine structure re-IDs
//! people. Communication barely uses it.
//!
//! Each identity owns a fixed, near-orthogonal signature vector in the fine
//! block. A session observation is `signature + environmental nuisance`; the
//! comm block is fresh per session. This is the honest crux of the whole
//! design: **identity leakage and data throughput live in (mostly) separable
//! subspaces**, so a transform can wreck the former while sparing the latter.
use crate::linalg::set_norm_inplace;
use crate::prng::{derive_key, Rng};
/// A flattened compressed-beamforming-report vector, split into a comm block
/// and a fine block.
#[derive(Debug, Clone, PartialEq)]
pub struct BfiSample {
/// The full report: `comm_dims` comm coordinates followed by fine ones.
pub values: Vec<f32>,
/// Number of leading coordinates that form the comm (data-carrying) block.
pub comm_dims: usize,
}
impl BfiSample {
/// Comm (data-carrying) block.
#[must_use]
pub fn comm(&self) -> &[f32] {
&self.values[..self.comm_dims]
}
/// Fine (identity-bearing) block.
#[must_use]
pub fn fine(&self) -> &[f32] {
&self.values[self.comm_dims..]
}
/// Mutable fine block — the only part the protector is allowed to rotate.
pub fn fine_mut(&mut self) -> &mut [f32] {
&mut self.values[self.comm_dims..]
}
}
/// Configuration of the synthetic scene.
#[derive(Debug, Clone)]
pub struct SceneConfig {
/// Total report dimension.
pub dim: usize,
/// Leading coordinates forming the comm block.
pub comm_dims: usize,
/// Number of distinct identities (candidates). Chance level is `1/identities`.
pub identities: usize,
/// L2 norm of each identity's fine-block signature.
pub signature_norm: f32,
/// Std-dev of per-session environmental nuisance added to the fine block.
pub env_sigma: f32,
/// L2 norm of the fresh per-session comm-block beam.
pub beam_amplitude: f32,
/// Master seed. All keys derive from this; nothing touches OS entropy.
pub seed: u64,
}
impl Default for SceneConfig {
fn default() -> Self {
Self {
dim: 64,
comm_dims: 8,
identities: 16,
signature_norm: 1.0,
env_sigma: 0.15,
beam_amplitude: 0.30,
seed: 0x5EED_1BF1,
}
}
}
impl SceneConfig {
/// Ideal chance-level accuracy, `1 / identities`.
#[must_use]
pub fn chance_level(&self) -> f32 {
1.0 / self.identities as f32
}
/// Length of the fine block.
#[must_use]
pub fn fine_dims(&self) -> usize {
self.dim - self.comm_dims
}
}
/// Synthetic channel: turns `(identity, session)` into a [`BfiSample`].
#[derive(Debug, Clone)]
pub struct Channel {
cfg: SceneConfig,
/// Precomputed per-identity fine-block signatures.
signatures: Vec<Vec<f32>>,
}
impl Channel {
/// Build the channel, drawing each identity's stable signature.
#[must_use]
pub fn new(cfg: SceneConfig) -> Self {
let fine = cfg.fine_dims();
let mut signatures = Vec::with_capacity(cfg.identities);
for id in 0..cfg.identities {
let mut rng = Rng::new(derive_key(cfg.seed, b"signature", id as u64, 0));
let mut s: Vec<f32> = (0..fine).map(|_| rng.next_gaussian()).collect();
set_norm_inplace(&mut s, cfg.signature_norm);
signatures.push(s);
}
Self { cfg, signatures }
}
/// The scene configuration.
#[must_use]
pub fn config(&self) -> &SceneConfig {
&self.cfg
}
/// The stable fine-block signature of `identity` (the thing an attacker
/// wants and the thing the shield must hide).
#[must_use]
pub fn signature(&self, identity: usize) -> &[f32] {
&self.signatures[identity]
}
/// Observe the unprotected report for `identity` in the given session under
/// `phase` (an experiment stage label, e.g. `b"enroll"` / `b"test"`, so the
/// same session index draws independent nuisance across stages).
#[must_use]
pub fn observe(&self, identity: usize, phase: &[u8], session: u64) -> BfiSample {
let cfg = &self.cfg;
let mut values = vec![0.0f32; cfg.dim];
// Comm block: fresh per session, identity-independent. This is the
// data-carrying dominant beam — it holds no re-ID information.
let mut brng = Rng::new(derive_key(cfg.seed, b"beam", session, phase[0] as u64));
for v in values[..cfg.comm_dims].iter_mut() {
*v = brng.next_gaussian();
}
set_norm_inplace(&mut values[..cfg.comm_dims], cfg.beam_amplitude);
// Fine block: stable identity signature + per-session nuisance.
let mut nrng = Rng::new(derive_key(cfg.seed, phase, identity as u64, session));
let sig = &self.signatures[identity];
for (v, s) in values[cfg.comm_dims..].iter_mut().zip(sig) {
*v = s + cfg.env_sigma * nrng.next_gaussian();
}
BfiSample {
values,
comm_dims: cfg.comm_dims,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linalg::{dist_sq, norm};
#[test]
fn signatures_are_well_separated() {
let ch = Channel::new(SceneConfig::default());
// Distinct identities' signatures are near-orthogonal in high-dim,
// so pairwise distance is large relative to env noise.
let d = dist_sq(ch.signature(0), ch.signature(1)).sqrt();
assert!(d > 1.0, "signatures too close: {d}");
}
#[test]
fn signature_norm_matches_config() {
let ch = Channel::new(SceneConfig::default());
assert!((norm(ch.signature(3)) - 1.0).abs() < 1e-4);
}
#[test]
fn observation_is_deterministic() {
let ch = Channel::new(SceneConfig::default());
assert_eq!(ch.observe(2, b"enroll", 5), ch.observe(2, b"enroll", 5));
}
#[test]
fn same_session_different_phase_differs() {
let ch = Channel::new(SceneConfig::default());
assert_ne!(ch.observe(2, b"enroll", 5), ch.observe(2, b"test", 5));
}
}
@@ -0,0 +1,87 @@
//! # VEIL — a compliant-waveform privacy shield against WiFi sensing
//!
//! VEIL (Verifiable Emission-shaping for Identity-Leakage prevention) is the
//! countermeasure counterpart to BFLD (ADR-118/121, `wifi-densepose-bfld`).
//! Where BFLD *detects* when beamforming feedback becomes identifying, VEIL
//! *acts*: it shapes a node's own outgoing beamforming feedback so that an
//! unauthorized passive sniffer cannot re-identify people or infer activity,
//! while a legitimate receiver — which shares the per-session key — sees an
//! essentially unchanged link.
//!
//! This crate is a **deterministic, dependency-free, WASM-ready reference and
//! experiment**, not a radio driver. It models the physics faithfully enough to
//! measure the core claim, and it never emits RF. Per ADR-288 and CLAUDE.md,
//! every number it produces is `SYNTHETIC`, reproduced by
//! `cargo test -p wifi-densepose-privshield`.
//!
//! ## The idea in one paragraph
//!
//! Identity leaks through the *fine* cross-subcarrier phase structure of a
//! compressed beamforming report; data throughput rides the *dominant* beam
//! direction. These live in (mostly) separable subspaces. VEIL composes extra
//! keyed [`linalg::apply_givens`] rotations — the exact primitive the report is
//! already built from — over the **fine** subspace only. The rotation is:
//! orthogonal (energy-preserving ⇒ no added transmit power ⇒ **not jamming**,
//! [`compliance`]); keyed per session (the legitimate AP inverts it ⇒
//! throughput preserved, [`throughput`]); and fresh each session (a sniffer
//! sees a different rotation every time and cannot average back the signature
//! ⇒ re-identification collapses to chance, [`attacker`]/[`experiment`]).
//!
//! ## Threat model and scope (stated plainly)
//!
//! VEIL defends against a **third-party passive sniffer** capturing
//! plaintext beamforming feedback. It does **not** hide identity from the AP a
//! node is associated with (that party holds the key). It is **compliant by
//! construction**: it only shapes the node's own standards-conformant frames;
//! it never transmits to interfere with another station (47 U.S.C. §333) and
//! never operates an unauthorized emitter (§302a). It is not jamming, not RF
//! denial, and not a claim of camera-grade anything.
//!
//! ## Modules
//!
//! - [`prng`] — deterministic, WASM-safe PRNG and key derivation.
//! - [`linalg`] — the small Givens-rotation vector algebra.
//! - [`identity`] — the SYNTHETIC two-subspace beamforming-feedback model.
//! - [`protector`] — the compliant waveform controls (the shield).
//! - [`attacker`] — the passive re-identification adversary.
//! - [`throughput`] — the link-throughput model.
//! - [`compliance`] — the machine-checkable "not jamming" audit.
//! - [`experiment`] — the attacker-vs-protector head-to-head.
//! - [`proof`] — the byte-stable deterministic witness.
//!
//! ## Quick start
//!
//! ```
//! use wifi_densepose_privshield::experiment::{run, ExperimentConfig};
//!
//! let report = run(&ExperimentConfig::default());
//! assert!(report.attack_is_effective_without_shield()); // threat is real
//! assert!(report.drives_to_chance()); // shield collapses re-ID
//! assert!(report.preserves_throughput()); // throughput ≥ 95%
//! assert!(report.compliance.is_compliant()); // energy-preserving
//! ```
#![warn(missing_docs)]
#![forbid(unsafe_code)]
pub mod attacker;
pub mod compliance;
pub mod experiment;
pub mod identity;
pub mod linalg;
pub mod optimize;
pub mod prng;
pub mod proof;
pub mod protector;
pub mod throughput;
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::{ObfMode, Protector, SensingDetector, ShieldConfig};
pub use throughput::LinkModel;
@@ -0,0 +1,89 @@
//! Minimal, dependency-free vector algebra over `f32` slices.
//!
//! VEIL deliberately avoids `ndarray`/BLAS: the vectors are short (tens of
//! elements — a flattened compressed-beamforming angle report), the crate is
//! a WASM-ready leaf, and keeping the math inline makes the energy-conservation
//! proof in [`crate::compliance`] auditable line-by-line.
/// Euclidean inner product. Panics if lengths differ.
#[must_use]
pub fn dot(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "dot: length mismatch");
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// Squared L2 norm.
#[must_use]
pub fn norm_sq(a: &[f32]) -> f32 {
a.iter().map(|x| x * x).sum()
}
/// L2 norm.
#[must_use]
pub fn norm(a: &[f32]) -> f32 {
norm_sq(a).sqrt()
}
/// Squared Euclidean distance. Panics if lengths differ.
#[must_use]
pub fn dist_sq(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "dist_sq: length mismatch");
a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
}
/// Scale in place.
pub fn scale_inplace(a: &mut [f32], k: f32) {
for x in a.iter_mut() {
*x *= k;
}
}
/// Normalize `a` to a target L2 norm in place. No-op if `a` is (near) zero.
pub fn set_norm_inplace(a: &mut [f32], target: f32) {
let n = norm(a);
if n > 1e-12 {
scale_inplace(a, target / n);
}
}
/// Apply a Givens rotation to coordinates `(i, j)` of `v` by angle `theta`.
///
/// A Givens rotation is the exact primitive 802.11 compressed beamforming
/// feedback is built from (the ψ/φ angles a beamformee reports). It is an
/// **orthogonal** operation: it preserves `‖v‖` to machine precision, which is
/// precisely why composing extra keyed Givens rotations adds *no transmit
/// energy* — the compliance argument in [`crate::compliance`].
pub fn apply_givens(v: &mut [f32], i: usize, j: usize, theta: f32) {
debug_assert!(i < v.len() && j < v.len() && i != j);
let (c, s) = (theta.cos(), theta.sin());
let (vi, vj) = (v[i], v[j]);
v[i] = c * vi - s * vj;
v[j] = s * vi + c * vj;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn givens_preserves_norm() {
let mut v = vec![0.3, -1.2, 0.7, 2.1, -0.5];
let before = norm(&v);
apply_givens(&mut v, 1, 3, 0.9);
apply_givens(&mut v, 0, 4, -2.3);
apply_givens(&mut v, 2, 3, 1.1);
let after = norm(&v);
assert!((before - after).abs() < 1e-5, "{before} vs {after}");
}
#[test]
fn givens_is_invertible() {
let orig = vec![1.0f32, 2.0, 3.0, 4.0];
let mut v = orig.clone();
apply_givens(&mut v, 0, 2, 0.7);
apply_givens(&mut v, 0, 2, -0.7);
for (a, b) in orig.iter().zip(&v) {
assert!((a - b).abs() < 1e-5);
}
}
}
@@ -0,0 +1,424 @@
//! Hyper-optimization of the shield's operating point.
//!
//! The reference crate shipped a hand-picked shield config. This module finds
//! the *optimal* one deterministically, and — crucially — proves the optimum is
//! robust rather than tuned to one attacker or one identity count:
//!
//! - [`optimal_feedback_bits`] finds the throughput-maximizing feedback
//! resolution, exploiting the interior optimum the [`crate::throughput`] model
//! exposes (residual falls with bits, airtime rises).
//! - [`min_givens_passes`] finds the **smallest** rotation-mixing budget that
//! still drives re-identification into the chance band — checked against
//! *every* attacker [`Metric`] and *every* identity count in a robustness set,
//! so the answer is the minimum that survives the hardest case, not the
//! easiest.
//! - [`pareto_frontier`] enumerates the non-dominated (privacy, throughput)
//! points for documentation and inspection.
//! - [`hyper_optimize`] combines the two into a ready-to-ship [`ShieldConfig`]
//! plus the verifying [`ExperimentReport`].
//!
//! Optimizing over both metrics and multiple `N` is the point: if the collapse
//! held only for Euclidean at N=16, it would be a classifier artifact. It holds
//! across the set because a session-fresh secret rotation removes stable
//! identity information from the *signal*.
use crate::attacker::Metric;
use crate::experiment::{run, ExperimentConfig, ExperimentReport};
use crate::protector::ShieldConfig;
/// Attacker metrics the optimizer must satisfy simultaneously.
pub const ROBUSTNESS_METRICS: [Metric; 2] = [Metric::Euclidean, Metric::Cosine];
/// Identity counts the optimizer must satisfy simultaneously. Larger `N` has a
/// lower chance floor, so it is the harder collapse target.
pub const ROBUSTNESS_IDENTITIES: [usize; 2] = [16, 32];
/// Candidate Givens-pass budgets, ascending. The optimizer returns the first
/// that collapses re-ID across the whole robustness set.
pub const PASS_CANDIDATES: [usize; 12] = [2, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 112];
/// Per-angle feedback resolutions 802.11 compressed beamforming actually uses
/// (ψ/φ are quantized to roughly 59 bits). The shipped shield picks the
/// throughput-best value from this *spec-allowed* set, not the unconstrained
/// model optimum, so the config stays standards-faithful.
pub const ALLOWED_FEEDBACK_BITS: [u32; 3] = [5, 7, 9];
/// Safety margin applied to the proven-minimum pass budget. Rotation mixing is
/// keyed (derived from the shared link secret, never signaled), so extra passes
/// cost compute but **no** throughput — we spend a 2× margin on privacy for
/// free.
pub const PRIVACY_MARGIN_FACTOR: usize = 2;
/// Run one experiment variant with the given knobs, holding everything else at
/// `base`.
fn run_variant(
base: &ExperimentConfig,
passes: usize,
bits: u32,
metric: Metric,
identities: usize,
) -> ExperimentReport {
let mut cfg = base.clone();
cfg.shield = ShieldConfig {
givens_passes: passes,
feedback_bits: bits,
..base.shield.clone()
};
cfg.scene.identities = identities;
cfg.attacker_metric = metric;
run(&cfg)
}
/// Throughput of the base link at a given feedback resolution.
fn throughput_at_bits(base: &ExperimentConfig, bits: u32) -> f64 {
base.link.throughput_ratio(&ShieldConfig {
feedback_bits: bits,
..base.shield.clone()
})
}
/// Find the throughput-maximizing `feedback_bits` in `1..=max_bits`
/// (unconstrained model optimum). Returns `(bits, throughput_ratio)`.
#[must_use]
pub fn optimal_feedback_bits(base: &ExperimentConfig, max_bits: u32) -> (u32, f64) {
(1..=max_bits)
.map(|bits| (bits, throughput_at_bits(base, bits)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.unwrap_or((base.shield.feedback_bits, 0.0))
}
/// Find the throughput-maximizing feedback resolution within the spec-allowed
/// set [`ALLOWED_FEEDBACK_BITS`]. This is what the shipped shield uses.
#[must_use]
pub fn spec_optimal_feedback_bits(base: &ExperimentConfig) -> (u32, f64) {
ALLOWED_FEEDBACK_BITS
.iter()
.map(|&bits| (bits, throughput_at_bits(base, bits)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.unwrap()
}
/// Does `passes` collapse re-ID into the chance band for *every* metric and
/// *every* identity count in the robustness set?
#[must_use]
pub fn passes_collapse_robustly(base: &ExperimentConfig, passes: usize, bits: u32) -> bool {
for &n in &ROBUSTNESS_IDENTITIES {
for &m in &ROBUSTNESS_METRICS {
if !run_variant(base, passes, bits, m, n).drives_to_chance() {
return false;
}
}
}
true
}
/// Smallest Givens-pass budget from [`PASS_CANDIDATES`] that collapses re-ID
/// robustly, or `None` if even the largest candidate fails.
#[must_use]
pub fn min_givens_passes(base: &ExperimentConfig, bits: u32) -> Option<usize> {
PASS_CANDIDATES
.iter()
.copied()
.find(|&p| passes_collapse_robustly(base, p, bits))
}
/// One point on the privacythroughput tradeoff.
#[derive(Debug, Clone, PartialEq)]
pub struct ParetoPoint {
/// Givens-pass budget.
pub givens_passes: usize,
/// Feedback resolution in bits.
pub feedback_bits: u32,
/// Worst-case (highest) re-ID accuracy over the robustness metrics at the
/// base identity count.
pub worst_reid: f32,
/// Modeled throughput ratio.
pub throughput_ratio: f64,
/// Whether this point collapses re-ID robustly (all metrics, all N).
pub robustly_private: bool,
}
/// Enumerate the non-dominated (lower re-ID, higher throughput) points over a
/// grid of pass budgets and feedback resolutions.
#[must_use]
pub fn pareto_frontier(base: &ExperimentConfig, max_bits: u32) -> Vec<ParetoPoint> {
let mut points: Vec<ParetoPoint> = Vec::new();
for &passes in &PASS_CANDIDATES {
for bits in 1..=max_bits {
// Worst-case re-ID over metrics at the base identity count.
let worst_reid = ROBUSTNESS_METRICS
.iter()
.map(|&m| {
run_variant(base, passes, bits, m, base.scene.identities).accuracy_shield_on
})
.fold(0.0_f32, f32::max);
let shield = ShieldConfig {
givens_passes: passes,
feedback_bits: bits,
..base.shield.clone()
};
points.push(ParetoPoint {
givens_passes: passes,
feedback_bits: bits,
worst_reid,
throughput_ratio: base.link.throughput_ratio(&shield),
robustly_private: passes_collapse_robustly(base, passes, bits),
});
}
}
// Keep only non-dominated points: no other point has both lower-or-equal
// re-ID and higher-or-equal throughput while being strictly better in one.
points
.iter()
.filter(|p| {
!points.iter().any(|q| {
let better_or_eq =
q.worst_reid <= p.worst_reid && q.throughput_ratio >= p.throughput_ratio;
let strictly_better =
q.worst_reid < p.worst_reid || q.throughput_ratio > p.throughput_ratio;
better_or_eq && strictly_better
})
})
.cloned()
.collect()
}
/// The chosen optimum plus the report that verifies it.
#[derive(Debug, Clone)]
pub struct HyperOptimized {
/// The optimized, ready-to-ship shield configuration.
pub shield: ShieldConfig,
/// Minimum Givens passes that collapses re-ID robustly (before the margin).
pub min_passes: usize,
/// Shipped Givens passes = `min_passes` grown by [`PRIVACY_MARGIN_FACTOR`].
pub shipped_passes: usize,
/// Unconstrained throughput-optimal feedback resolution (a research point).
pub model_optimal_bits: u32,
/// Spec-allowed throughput-optimal resolution (what the shield ships with).
pub spec_optimal_bits: u32,
/// The verifying experiment at the base identity count.
pub report: ExperimentReport,
}
/// Smallest pass candidate that is at least `target`.
fn ceil_to_candidate(target: usize) -> usize {
PASS_CANDIDATES
.iter()
.copied()
.find(|&p| p >= target)
.unwrap_or_else(|| *PASS_CANDIDATES.last().unwrap())
}
/// Find the optimal shield: the spec-allowed throughput-optimal feedback
/// resolution, and the minimum rotation-mixing budget that collapses re-ID
/// robustly, grown by a free privacy margin. Deterministic and idempotent — the
/// shipped [`ShieldConfig::default`] is exactly this function's output on the
/// default base (asserted in tests).
#[must_use]
pub fn hyper_optimize(base: &ExperimentConfig) -> HyperOptimized {
let (model_optimal_bits, _) = optimal_feedback_bits(base, 12);
let (spec_optimal_bits, _) = spec_optimal_feedback_bits(base);
let min_passes = min_givens_passes(base, spec_optimal_bits)
.unwrap_or_else(|| *PASS_CANDIDATES.last().unwrap());
let shipped_passes = ceil_to_candidate(min_passes * PRIVACY_MARGIN_FACTOR);
let shield = ShieldConfig {
givens_passes: shipped_passes,
feedback_bits: spec_optimal_bits,
..base.shield.clone()
};
let mut cfg = base.clone();
cfg.shield = shield.clone();
let report = run(&cfg);
HyperOptimized {
shield,
min_passes,
shipped_passes,
model_optimal_bits,
spec_optimal_bits,
report,
}
}
// ---------------------------------------------------------------------------
// Adaptive optimization: the optimum is not one config — it depends on the
// deployment's SNR (which shifts the throughput-optimal feedback resolution)
// and its identity count (which sets how much rotation mixing collapse needs).
// These functions derive the right config per deployment rather than assuming
// the default scene.
// ---------------------------------------------------------------------------
/// SNR values (dB) to profile the throughput-optimal feedback resolution over.
pub const SNR_PROFILE_DB: [f64; 5] = [5.0, 10.0, 20.0, 30.0, 40.0];
/// Unconstrained throughput-optimal feedback resolution for a specific SNR,
/// holding the rest of `base`. At low SNR the residual matters proportionally
/// more (Shannon capacity is near-linear), so higher resolution wins; at high
/// SNR the log compresses the residual away and feedback airtime dominates,
/// favoring fewer bits. (The *shipped* shield clamps to the 802.11 {5,7,9} set,
/// where 5 already zeroes the residual — so this shift is visible only in the
/// unconstrained optimum, and is what motivates keeping resolution low.)
#[must_use]
pub fn model_optimal_bits_for_snr(base: &ExperimentConfig, snr_db: f64) -> (u32, f64) {
let mut cfg = base.clone();
cfg.link.snr_db = snr_db;
optimal_feedback_bits(&cfg, 12)
}
/// Profile the unconstrained throughput-optimal feedback resolution across
/// [`SNR_PROFILE_DB`]. Demonstrates the SNR → resolution dependence.
#[must_use]
pub fn optimal_bits_across_snr(base: &ExperimentConfig) -> Vec<(f64, u32)> {
SNR_PROFILE_DB
.iter()
.map(|&snr| (snr, model_optimal_bits_for_snr(base, snr).0))
.collect()
}
/// Does `passes` collapse re-ID for both metrics at a single identity count?
#[must_use]
pub fn passes_collapse_at_n(base: &ExperimentConfig, passes: usize, bits: u32, n: usize) -> bool {
ROBUSTNESS_METRICS
.iter()
.all(|&m| run_variant(base, passes, bits, m, n).drives_to_chance())
}
/// Smallest pass budget that collapses re-ID for a *specific* identity count.
/// More candidates ⇒ lower chance floor ⇒ generally more mixing required, so
/// this grows with `n`.
#[must_use]
pub fn min_passes_for_n(base: &ExperimentConfig, bits: u32, n: usize) -> Option<usize> {
PASS_CANDIDATES
.iter()
.copied()
.find(|&p| passes_collapse_at_n(base, p, bits, n))
}
/// Derive a ready-to-ship shield for a specific deployment: throughput-optimal
/// feedback resolution for the deployment SNR, and the minimum mixing budget for
/// its identity count grown by the free [`PRIVACY_MARGIN_FACTOR`] margin. This is
/// what an operator should call for a room with `n` expected occupants on a link
/// with `base.link`'s SNR — the default config is just this at N=16.
#[must_use]
pub fn adaptive_shield(base: &ExperimentConfig, n: usize) -> ShieldConfig {
let (bits, _) = spec_optimal_feedback_bits(base);
let min_passes =
min_passes_for_n(base, bits, n).unwrap_or_else(|| *PASS_CANDIDATES.last().unwrap());
ShieldConfig {
givens_passes: ceil_to_candidate(min_passes * PRIVACY_MARGIN_FACTOR),
feedback_bits: bits,
..base.shield.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_optimal_bits_is_interior() {
let (bits, ratio) = optimal_feedback_bits(&ExperimentConfig::default(), 12);
assert!(bits > 1 && bits < 12, "optimum at edge: {bits}");
assert!(ratio > 0.95);
}
#[test]
fn spec_optimal_bits_is_the_low_res_end() {
// Within {5,7,9}, lower resolution wins because the receiver compensates
// the keyed rotation, so extra bits mostly buy airtime.
let (bits, _) = spec_optimal_feedback_bits(&ExperimentConfig::default());
assert_eq!(bits, 5);
}
#[test]
fn min_passes_is_below_the_original_default() {
// The original hand-picked default was 112 passes. The optimizer proves
// far fewer suffice — the "we over-provisioned" finding.
let (bits, _) = spec_optimal_feedback_bits(&ExperimentConfig::default());
let p = min_givens_passes(&ExperimentConfig::default(), bits).expect("collapses");
assert!(p < 112, "min passes {p} should be below the old 112");
assert!(p >= 2);
}
#[test]
fn shipped_default_equals_optimizer_output() {
// The crate's default shield IS the optimizer's recommendation — they
// cannot silently drift apart.
let opt = hyper_optimize(&ExperimentConfig::default());
assert_eq!(
opt.shield.givens_passes,
ShieldConfig::default().givens_passes
);
assert_eq!(
opt.shield.feedback_bits,
ShieldConfig::default().feedback_bits
);
assert!(opt.report.passed(), "{:#?}", opt.report);
}
#[test]
fn optimum_collapses_under_both_metrics_and_larger_n() {
let opt = hyper_optimize(&ExperimentConfig::default());
assert!(passes_collapse_robustly(
&ExperimentConfig::default(),
opt.shipped_passes,
opt.spec_optimal_bits
));
}
#[test]
fn optimal_bits_shift_with_snr() {
// Low-SNR deployments favor higher feedback resolution; high-SNR favor
// lower. The (unconstrained) profile is non-increasing in SNR and not
// constant across the range.
let profile = optimal_bits_across_snr(&ExperimentConfig::default());
let low = profile.first().unwrap().1;
let high = profile.last().unwrap().1;
assert!(
low >= high,
"low-SNR bits {low} should be >= high-SNR bits {high}"
);
assert!(low != high, "profile did not shift with SNR: {profile:?}");
}
#[test]
fn adaptive_shield_mixing_is_nondecreasing_in_n() {
// A room with more candidate identities needs at least as much mixing.
// In this model the collapse budget is governed by fine-subspace
// dimension, so the requirement is flat across N — the invariant we can
// assert is non-decreasing, and that it never *under*-provisions.
let base = ExperimentConfig::default();
let small = adaptive_shield(&base, 8);
let large = adaptive_shield(&base, 64);
assert!(
large.givens_passes >= small.givens_passes,
"N=64 passes {} should be >= N=8 passes {}",
large.givens_passes,
small.givens_passes
);
}
#[test]
fn adaptive_shield_collapses_at_its_target_n() {
let base = ExperimentConfig::default();
for n in [8usize, 32, 64] {
let sh = adaptive_shield(&base, n);
assert!(
passes_collapse_at_n(&base, sh.givens_passes, sh.feedback_bits, n),
"adaptive shield for N={n} does not collapse"
);
}
}
#[test]
fn frontier_is_non_empty_and_deterministic() {
// Small grid keeps this fast; the frontier logic is grid-size agnostic.
let base = ExperimentConfig::default();
let a = pareto_frontier(&base, 3);
let b = pareto_frontier(&base, 3);
assert!(!a.is_empty());
assert_eq!(a, b);
}
}
@@ -0,0 +1,119 @@
//! Deterministic, WASM-safe pseudo-random generator.
//!
//! VEIL never draws from OS entropy: every stochastic quantity in the
//! experiment (identity signatures, environmental nuisance, per-session
//! precoder rotations) seeds from an explicit `u64`. Same seed in → same
//! bytes out, on any platform including `wasm32-unknown-unknown`. This is
//! what makes [`crate::proof`] a byte-stable witness rather than a flaky
//! statistical assertion.
//!
//! The core is SplitMix64 (Steele, Lea & Flood 2014) — a well-mixed
//! finalizer that is more than adequate for synthetic-data generation and
//! keyed subspace rotation. It is **not** a cryptographic RNG and must not
//! be used to derive real key material; in a deployment the per-session
//! rotation key comes from the negotiated link secret, not from this PRNG.
/// A deterministic SplitMix64 stream.
#[derive(Debug, Clone)]
pub struct Rng {
state: u64,
}
impl Rng {
/// Seed the stream. Distinct seeds yield independent streams.
#[must_use]
pub fn new(seed: u64) -> Self {
Self {
state: seed ^ 0x9E37_79B9_7F4A_7C15,
}
}
/// Next raw 64-bit word.
pub fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform `f32` in `[0, 1)` using the top 24 mantissa bits.
pub fn next_f32(&mut self) -> f32 {
// 24 bits of precision keeps the value exactly representable.
((self.next_u64() >> 40) as f32) / ((1u64 << 24) as f32)
}
/// Uniform `f32` in `[lo, hi)`.
pub fn next_range(&mut self, lo: f32, hi: f32) -> f32 {
lo + (hi - lo) * self.next_f32()
}
/// Standard-normal `f32` via the BoxMuller transform.
pub fn next_gaussian(&mut self) -> f32 {
let u1 = self.next_f32().max(1e-7);
let u2 = self.next_f32();
(-2.0 * u1.ln()).sqrt() * (core::f32::consts::TAU * u2).cos()
}
}
/// FNV-1a 64-bit hash — a dependency-free, deterministic byte folder used to
/// derive per-session keys from `(scene_seed, phase, index)` tuples and to
/// build the [`crate::proof`] witness. Not cryptographic.
#[must_use]
pub fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xCBF2_9CE4_8422_2325;
for &b in bytes {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01B3);
}
h
}
/// Fold a label and two indices into a stable `u64` key.
#[must_use]
pub fn derive_key(scene_seed: u64, label: &[u8], a: u64, b: u64) -> u64 {
let mut buf = Vec::with_capacity(label.len() + 24);
buf.extend_from_slice(&scene_seed.to_le_bytes());
buf.extend_from_slice(label);
buf.extend_from_slice(&a.to_le_bytes());
buf.extend_from_slice(&b.to_le_bytes());
fnv1a_64(&buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_is_deterministic() {
let mut a = Rng::new(42);
let mut b = Rng::new(42);
for _ in 0..1000 {
assert_eq!(a.next_u64(), b.next_u64());
}
}
#[test]
fn distinct_seeds_diverge() {
let mut a = Rng::new(1);
let mut b = Rng::new(2);
assert_ne!(a.next_u64(), b.next_u64());
}
#[test]
fn uniform_in_range() {
let mut r = Rng::new(7);
for _ in 0..10_000 {
let x = r.next_f32();
assert!((0.0..1.0).contains(&x));
}
}
#[test]
fn gaussian_mean_near_zero() {
let mut r = Rng::new(9);
let n = 100_000;
let mean: f64 = (0..n).map(|_| f64::from(r.next_gaussian())).sum::<f64>() / f64::from(n);
assert!(mean.abs() < 0.02, "mean {mean} not near 0");
}
}
@@ -0,0 +1,82 @@
//! Deterministic proof bundle — the byte-stable witness for VEIL.
//!
//! Mirrors the `nvsim` / `archive/v1` proof pattern: run a fixed reference
//! experiment, fold its salient outputs into a single FNV-1a witness, and pin
//! that witness as a constant. If any constant drifts — the PRNG stream, the
//! rotation schedule, the throughput formula, the scene geometry — the witness
//! changes and the test fails loudly.
//!
//! The witness is derived from **quantized** outputs (accuracies to 1e-4,
//! throughput to 1e-6) so that legitimate cross-platform f32 round-off in the
//! last bits does not spuriously break the proof, while any real change to the
//! experiment's behavior still does.
use crate::experiment::{run, ExperimentConfig, ExperimentReport};
use crate::prng::fnv1a_64;
/// Deterministic-proof harness.
pub struct Proof;
impl Proof {
/// Pinned witness over the reference experiment. Re-derived by
/// [`Proof::witness`]; asserted by the test below.
pub const EXPECTED_WITNESS: u64 = 0x350D_7CDF_95D9_F448;
/// The reference configuration. Uses every default so the proof tracks the
/// shipped behavior of the crate.
#[must_use]
pub fn reference_config() -> ExperimentConfig {
ExperimentConfig::default()
}
/// Run the reference experiment.
#[must_use]
pub fn run_reference() -> ExperimentReport {
run(&Self::reference_config())
}
/// Fold a report's salient outputs into a stable witness.
#[must_use]
pub fn witness(report: &ExperimentReport) -> u64 {
let mut buf = Vec::new();
buf.extend_from_slice(&(report.identities as u64).to_le_bytes());
// Quantize floats before folding so last-bit round-off is not part of
// the witness.
let q4 = |x: f32| (f64::from(x) * 10_000.0).round() as i64;
let q6 = |x: f64| (x * 1_000_000.0).round() as i64;
buf.extend_from_slice(&q4(report.chance_level).to_le_bytes());
buf.extend_from_slice(&q4(report.accuracy_shield_off).to_le_bytes());
buf.extend_from_slice(&q4(report.accuracy_shield_on).to_le_bytes());
buf.extend_from_slice(&q6(report.throughput_ratio).to_le_bytes());
buf.extend_from_slice(&q4(report.compliance.energy_ratio).to_le_bytes());
fnv1a_64(&buf)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reference_experiment_passes() {
assert!(Proof::run_reference().passed());
}
#[test]
fn witness_is_stable() {
let a = Proof::witness(&Proof::run_reference());
let b = Proof::witness(&Proof::run_reference());
assert_eq!(a, b, "witness must be reproducible");
}
#[test]
fn witness_matches_pinned() {
let w = Proof::witness(&Proof::run_reference());
assert_eq!(
w,
Proof::EXPECTED_WITNESS,
"witness drifted to {w:#018x}; update EXPECTED_WITNESS only if the \
change to the reference experiment is intentional"
);
}
}
@@ -0,0 +1,298 @@
//! The VEIL protector: compliant waveform controls that hide identity.
//!
//! # What it does (and does not do)
//!
//! The protector shapes the node's **own** beamforming feedback before it goes
//! on air. It applies a per-session, key-derived **orthogonal rotation** to the
//! fine block of the report, composed from extra Givens rotations — the same
//! angle primitive the report already carries. Because the rotation is:
//!
//! - **orthogonal** → it preserves the report's energy exactly (no added
//! transmit power, no out-of-mask emission → **not jamming**, see
//! [`crate::compliance`]);
//! - **keyed per session** → the legitimate AP/STA, which shares the session
//! key, inverts it and recovers the true precoder (throughput preserved,
//! see [`crate::throughput`]);
//! - **fresh each session** → an external sniffer sees a different rotation of
//! the identity signature every session and cannot average them back to the
//! signature, so cross-session re-identification collapses toward chance.
//!
//! This is the shared-secret precoding idea (cf. MIMOCrypt, NSDI-adjacent work)
//! specialized to the identity-bearing fine subspace.
//!
//! # Scope limit (stated honestly)
//!
//! VEIL defends against a **third-party passive sniffer**. It does *not* hide
//! identity from the AP the node is associated with (that party holds the key
//! by construction). Protecting against a malicious AP is a different problem
//! handled by the BFLD detection layer and privacy-class policy (ADR-118/141),
//! not by this shield. VEIL never jams and never touches another station's
//! frames.
use crate::identity::BfiSample;
use crate::linalg::{apply_givens, 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 {
/// Master switch. When `false`, [`Protector::protect`] is the identity map
/// (used to model the "shield off" baseline).
pub enabled: bool,
/// Number of keyed Givens rotations composed per session. Enough passes
/// approximate a Haar-random rotation of the fine block, which is what
/// drives the attacker to chance. The optimal value is found by
/// [`crate::optimize`] (not hand-tuned); more passes cost compute but no
/// throughput, since the rotation is keyed rather than signaled.
pub givens_passes: usize,
/// Bits used to quantize each reported angle (802.11 uses 59). Higher
/// resolution ⇒ smaller uncompensated residual at the legitimate receiver
/// ⇒ smaller throughput cost. See [`crate::throughput`].
pub feedback_bits: u32,
/// Fractional airtime overhead from sounding-cadence randomization
/// (jittering NDP intervals so an eavesdropper under-samples motion).
pub sounding_overhead: f64,
/// 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 {
fn default() -> Self {
// These values are the output of `optimize::hyper_optimize` on the
// default scene (ADR-288 §opt), not hand-picked: 96 = 2× the proven-
// 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. `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,
}
}
}
/// Applies compliant waveform controls to outgoing beamforming feedback.
#[derive(Debug, Clone)]
pub struct Protector {
cfg: ShieldConfig,
}
impl Protector {
/// Build a protector.
#[must_use]
pub fn new(cfg: ShieldConfig) -> Self {
Self { cfg }
}
/// The configuration.
#[must_use]
pub fn config(&self) -> &ShieldConfig {
&self.cfg
}
/// Build the list of `(i, j, theta)` Givens rotations for a session. The
/// legitimate receiver derives the identical list from the shared session
/// key and applies the inverse (negated angles, reversed order).
fn session_rotation(&self, fine_dims: usize, session_key: u64) -> Vec<(usize, usize, f32)> {
let mut rng = Rng::new(session_key);
let mut ops = Vec::with_capacity(self.cfg.givens_passes);
for _ in 0..self.cfg.givens_passes {
// Draw a distinct coordinate pair in the fine block.
let i = (rng.next_u64() as usize) % fine_dims;
let mut j = (rng.next_u64() as usize) % fine_dims;
if j == i {
j = (j + 1) % fine_dims;
}
let theta = rng.next_range(0.0, core::f32::consts::TAU);
ops.push((i, j, theta));
}
ops
}
/// Protect an outgoing report for the given session. When the shield is
/// disabled this clones the input unchanged.
///
/// 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();
if !self.cfg.enabled {
return out;
}
let fine_dims = out.fine().len();
let ops = self.session_rotation(fine_dims, session_key);
let fine = out.fine_mut();
for (i, j, theta) in ops {
apply_givens(fine, i, j, theta);
}
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
/// throughput claim), not part of the attacker's world.
#[must_use]
pub fn recover(&self, sample: &BfiSample, session_key: u64) -> BfiSample {
let mut out = sample.clone();
if !self.cfg.enabled {
return out;
}
let fine_dims = out.fine().len();
let ops = self.session_rotation(fine_dims, session_key);
let fine = out.fine_mut();
for (i, j, theta) in ops.into_iter().rev() {
apply_givens(fine, i, j, -theta);
}
out
}
}
/// A minimal detector for unsolicited sensing activity. In a deployment this
/// watches the rate of NDP/sensing-sounding solicitations; here it exposes the
/// decision rule so the control plane (ADR-280) can engage the shield only when
/// sensing is actually observed, rather than perturbing continuously.
#[derive(Debug, Clone)]
pub struct SensingDetector {
/// Solicitations per second above which the shield engages.
pub threshold_hz: f32,
}
impl Default for SensingDetector {
fn default() -> Self {
Self { threshold_hz: 5.0 }
}
}
impl SensingDetector {
/// Should the shield engage given the observed solicitation rate?
#[must_use]
pub fn should_engage(&self, observed_hz: f32) -> bool {
observed_hz >= self.threshold_hz
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{Channel, SceneConfig};
use crate::linalg::{dist_sq, norm};
#[test]
fn protection_preserves_energy() {
let ch = Channel::new(SceneConfig::default());
let s = ch.observe(0, b"enroll", 1);
let p = Protector::new(ShieldConfig::default());
let out = p.protect(&s, 12345);
assert!((norm(&s.values) - norm(&out.values)).abs() < 1e-3);
}
#[test]
fn protection_leaves_comm_block_untouched() {
let ch = Channel::new(SceneConfig::default());
let s = ch.observe(0, b"enroll", 1);
let p = Protector::new(ShieldConfig::default());
let out = p.protect(&s, 999);
assert_eq!(s.comm(), out.comm());
}
#[test]
fn protection_scrambles_fine_block() {
let ch = Channel::new(SceneConfig::default());
let s = ch.observe(0, b"enroll", 1);
let p = Protector::new(ShieldConfig::default());
let out = p.protect(&s, 42);
assert!(dist_sq(s.fine(), out.fine()).sqrt() > 0.5);
}
#[test]
fn legitimate_receiver_recovers() {
let ch = Channel::new(SceneConfig::default());
let s = ch.observe(0, b"enroll", 1);
let p = Protector::new(ShieldConfig::default());
let out = p.protect(&s, 7);
let back = p.recover(&out, 7);
assert!(dist_sq(s.fine(), back.fine()).sqrt() < 1e-2);
}
#[test]
fn disabled_shield_is_identity() {
let ch = Channel::new(SceneConfig::default());
let s = ch.observe(0, b"enroll", 1);
let cfg = ShieldConfig {
enabled: false,
..ShieldConfig::default()
};
let p = Protector::new(cfg);
assert_eq!(s, p.protect(&s, 7));
}
#[test]
fn detector_engages_above_threshold() {
let d = SensingDetector::default();
assert!(d.should_engage(10.0));
assert!(!d.should_engage(1.0));
}
}
@@ -0,0 +1,179 @@
//! Link-throughput model for the protected node.
//!
//! The claim under test is "throughput stays above 95% with the shield on".
//! The model is intentionally transparent and errs toward *charging* the
//! shield, not flattering it. Three costs are charged:
//!
//! - **Beamforming residual.** The legitimate receiver shares the session key
//! and inverts the protector's rotation, so it does not pay the rotation
//! itself — only the residual from quantizing the extra angles at
//! `feedback_bits` resolution. Per-angle mean-square quantization error is
//! `Δ²/12` for step `Δ = (π/2)/2^bits`; this fraction of beamforming gain is
//! lost. It shrinks fast with more bits.
//! - **Feedback airtime.** Reporting the angles at higher resolution costs more
//! uplink airtime — charged as `feedback_overhead_per_bit · feedback_bits`.
//! It grows with more bits.
//! - **Sounding overhead.** Randomizing the NDP sounding cadence costs airtime
//! directly; a flat `sounding_overhead` fraction.
//!
//! The residual (falling) and the feedback airtime (rising) pull `feedback_bits`
//! in opposite directions, so throughput has a genuine **interior optimum** in
//! the number of feedback bits — the quantity [`crate::optimize`] searches for.
//! The optimum lands at coarse-to-moderate resolution because the receiver
//! compensates the keyed rotation, so extra bits mostly buy airtime, not gain —
//! echoing the DySPAN-2026 finding that ~3-bit feedback is near the sweet spot.
//!
//! Throughput ratio =
//! `(1 sounding feedback_airtime) · C(SNR·(1−ρ)) / C(SNR)` where
//! `C(x) = log2(1 + x)`. The comm block is never perturbed, so its geometry is
//! intact; only the SNR is nudged by the residual `ρ`.
use crate::protector::ShieldConfig;
/// A single-stream link model.
#[derive(Debug, Clone)]
pub struct LinkModel {
/// Operating SNR of the data-carrying beam, in dB.
pub snr_db: f64,
/// Uplink airtime charged per feedback bit, as a fraction of throughput.
/// Larger values push the throughput-optimal `feedback_bits` lower.
pub feedback_overhead_per_bit: f64,
}
impl Default for LinkModel {
fn default() -> Self {
Self {
snr_db: 20.0,
feedback_overhead_per_bit: 0.0008,
}
}
}
impl LinkModel {
/// Linear SNR.
#[must_use]
pub fn snr_linear(&self) -> f64 {
10f64.powf(self.snr_db / 10.0)
}
/// Baseline Shannon capacity (bits/s/Hz) with no shield.
#[must_use]
pub fn baseline_capacity(&self) -> f64 {
(1.0 + self.snr_linear()).log2()
}
/// Uncompensated beamforming-gain residual from finite feedback resolution.
#[must_use]
pub fn beamforming_residual(shield: &ShieldConfig) -> f64 {
if !shield.enabled {
return 0.0;
}
let step = (core::f64::consts::FRAC_PI_2) / f64::from(1u32 << shield.feedback_bits);
// Mean-square quantization error of a uniform quantizer, as a fraction
// of unit gain. Clamp for safety at absurdly low resolutions.
(step * step / 12.0).min(0.5)
}
/// Uplink airtime cost of reporting angles at `feedback_bits` resolution.
#[must_use]
pub fn feedback_airtime(&self, shield: &ShieldConfig) -> f64 {
if !shield.enabled {
return 0.0;
}
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]
pub fn throughput_ratio(&self, shield: &ShieldConfig) -> f64 {
if !shield.enabled {
return 1.0;
}
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);
((1.0 - airtime) * capacity_ratio).clamp(0.0, 1.0)
}
}
/// 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::*;
#[test]
fn baseline_ratio_is_one() {
let cfg = ShieldConfig {
enabled: false,
..ShieldConfig::default()
};
assert!((LinkModel::default().throughput_ratio(&cfg) - 1.0).abs() < 1e-9);
}
#[test]
fn default_config_preserves_throughput() {
let ratio = LinkModel::default().throughput_ratio(&ShieldConfig::default());
assert!(ratio > 0.95, "ratio {ratio}");
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
// airtime. The optimum is strictly interior — neither extreme wins.
let link = LinkModel::default();
let at = |bits: u32| {
link.throughput_ratio(&ShieldConfig {
feedback_bits: bits,
..ShieldConfig::default()
})
};
let lo = at(1);
let hi = at(12);
let best_bits = (1..=12)
.max_by(|&a, &b| at(a).partial_cmp(&at(b)).unwrap())
.unwrap();
assert!(
best_bits > 1 && best_bits < 12,
"optimum at edge: {best_bits}"
);
assert!(at(best_bits) > lo && at(best_bits) > hi);
}
}
@@ -0,0 +1,980 @@
<title>WiFi Veil Console — WiFi-Sensing Privacy Shield</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
/* ---- Theme tokens: light is the bare :root; dark redefined twice ---- */
:root {
--ground: #e8eeee;
--surface: #ffffff;
--surface-2: #f1f6f6;
--ink: #0b1a1c;
--muted: #5b6d6f;
--faint: #8b9c9d;
--line: #d6e0e0;
--grid: #e3ebeb;
--accent: #0fb5a6;
--accent-2: #0a897e;
--accent-soft: rgba(15, 181, 166, 0.12);
--threat: #dd6f26;
--threat-soft: rgba(221, 111, 38, 0.12);
--good: #1f9d63;
--crit: #d5383d;
--shadow: 0 1px 2px rgba(11, 26, 28, 0.06), 0 8px 24px rgba(11, 26, 28, 0.07);
--shadow-sm: 0 1px 2px rgba(11, 26, 28, 0.06);
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--r: 14px;
--r-sm: 10px;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #071211;
--surface: #0d1b19;
--surface-2: #122423;
--ink: #e3efee;
--muted: #8ba09f;
--faint: #5f7473;
--line: #1e3634;
--grid: #16302e;
--accent: #20d3c0;
--accent-2: #5eebdc;
--accent-soft: rgba(32, 211, 192, 0.14);
--threat: #f59e4b;
--threat-soft: rgba(245, 158, 75, 0.15);
--good: #3ecf8e;
--crit: #f26b6f;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 30px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
}
}
:root[data-theme="dark"] {
--ground: #071211;
--surface: #0d1b19;
--surface-2: #122423;
--ink: #e3efee;
--muted: #8ba09f;
--faint: #5f7473;
--line: #1e3634;
--grid: #16302e;
--accent: #20d3c0;
--accent-2: #5eebdc;
--accent-soft: rgba(32, 211, 192, 0.14);
--threat: #f59e4b;
--threat-soft: rgba(245, 158, 75, 0.15);
--good: #3ecf8e;
--crit: #f26b6f;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 30px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
h1, h2, h3 { margin: 0; text-wrap: balance; letter-spacing: -0.02em; }
a { color: var(--accent-2); }
.wrap { max-width: 1120px; margin: 0 auto; padding: 14px 14px 64px; }
.label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ---- top bar ---- */
.topbar {
display: flex; align-items: center; gap: 10px;
padding: 6px 2px 16px;
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.glyph {
width: 34px; height: 34px; flex: none; border-radius: 9px;
background: var(--accent-soft);
display: grid; place-items: center;
border: 1px solid var(--line);
}
.glyph svg { width: 20px; height: 20px; }
.brand-txt { display: flex; flex-direction: column; line-height: 1.05; min-width: 0; }
.brand-txt b { font-size: 15px; letter-spacing: 0.02em; }
.brand-txt span { font-family: var(--mono); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); }
.spacer { flex: 1; }
.icon-btn {
width: 38px; height: 38px; flex: none; border-radius: 10px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
display: grid; place-items: center; cursor: pointer; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease;
}
.icon-btn:hover { border-color: var(--accent); transform: translateY(-1px); }
.icon-btn svg { width: 18px; height: 18px; }
.icon-btn .sun { display: none; }
:root[data-theme="dark"] .icon-btn .sun { display: block; }
:root[data-theme="dark"] .icon-btn .moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .icon-btn .sun { display: block; }
:root:not([data-theme="light"]) .icon-btn .moon { display: none; }
}
/* status pill */
.status {
display: inline-flex; align-items: center; gap: 7px;
font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
padding: 7px 11px; border-radius: 999px; border: 1px solid var(--line);
background: var(--surface); text-transform: uppercase; white-space: nowrap;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.status.good { color: var(--good); border-color: color-mix(in srgb, var(--good) 40%, var(--line)); background: color-mix(in srgb, var(--good) 10%, var(--surface)); }
.status.good .dot { background: var(--good); box-shadow: 0 0 0 0 color-mix(in srgb, var(--good) 60%, transparent); animation: pulse 2s infinite; }
.status.watch { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: var(--accent-soft); }
.status.watch .dot { background: var(--accent); }
.status.bad { color: var(--crit); border-color: color-mix(in srgb, var(--crit) 40%, var(--line)); background: color-mix(in srgb, var(--crit) 10%, var(--surface)); }
.status.bad .dot { background: var(--crit); animation: pulse-bad 1.1s infinite; }
@keyframes pulse { 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
@keyframes pulse-bad { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
/* ---- grid ---- */
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 900px) {
.grid { grid-template-columns: 1.15fr 1fr; align-items: start; }
.col-span { grid-column: 1 / -1; }
}
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
box-shadow: var(--shadow); padding: 16px; opacity: 0; transform: translateY(10px);
animation: rise .55s cubic-bezier(.2,.7,.3,1) forwards;
}
@keyframes rise { to { opacity: 1; transform: none; } }
.card > .head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.card h2 { font-size: 14px; font-weight: 650; }
.card .sub { font-family: var(--mono); font-size: 11px; color: var(--muted); }
/* ---- hero ---- */
.hero { padding: 0; overflow: hidden; position: relative; }
.hero-canvas-wrap { position: relative; aspect-ratio: 16 / 11; width: 100%; background:
radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, var(--accent) 6%, var(--surface)), var(--surface)); }
@media (min-width: 900px) { .hero-canvas-wrap { aspect-ratio: 16 / 10; } }
canvas { display: block; width: 100%; height: 100%; }
.hero-overlay { position: absolute; inset: 0; padding: 16px; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
.hero-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
.hero h1 { font-size: clamp(20px, 5.4vw, 30px); font-weight: 720; line-height: 1.04; max-width: 15ch; }
.hero h1 em { font-style: normal; color: var(--accent-2); }
.hero .kbig { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
.hero .kbig b { display: block; font-size: clamp(26px, 8vw, 44px); font-weight: 680; letter-spacing: -0.03em; line-height: 1; }
.hero .kbig small { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
.hero-bottom { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.legend { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; color: var(--muted); }
.legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; margin-right: 5px; vertical-align: middle; }
.switch-row { pointer-events: auto; display: inline-flex; gap: 4px; padding: 4px; border-radius: 12px; background: color-mix(in srgb, var(--surface) 80%, transparent); border: 1px solid var(--line); backdrop-filter: blur(6px); }
.seg { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; border: none; background: transparent; color: var(--muted); padding: 7px 12px; border-radius: 9px; cursor: pointer; transition: color .15s ease, background .15s ease; }
.seg[aria-pressed="true"] { background: var(--accent); color: #04201d; }
:root[data-theme="dark"] .seg[aria-pressed="true"] { color: #04201d; }
.seg.off[aria-pressed="true"] { background: var(--crit); color: #fff; }
/* ---- KPI row ---- */
.kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(150px, 1fr); gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 4px; margin: 0 -2px; }
.kpis::-webkit-scrollbar { height: 0; }
@media (min-width: 640px) { .kpis { grid-auto-flow: row; grid-template-columns: repeat(4, 1fr); grid-auto-columns: auto; overflow: visible; } }
.kpi { scroll-snap-align: start; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 13px 14px; box-shadow: var(--shadow-sm); position: relative; overflow: hidden; }
.kpi .label { display: flex; align-items: center; gap: 6px; }
.kpi .v { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 26px; font-weight: 650; letter-spacing: -0.02em; margin-top: 8px; line-height: 1; }
.kpi .v small { font-size: 13px; color: var(--muted); font-weight: 500; }
.kpi .foot { font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 6px; }
.kpi .strip { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
.kpi.warnstrip .strip { background: var(--threat); }
.kpi.goodstrip .strip { background: var(--good); }
.tag { font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; padding: 2px 6px; border-radius: 5px; background: var(--surface-2); color: var(--muted); }
.tag.good { color: var(--good); background: color-mix(in srgb, var(--good) 12%, var(--surface)); }
.tag.warn { color: var(--threat); background: var(--threat-soft); }
.chart-box { width: 100%; aspect-ratio: 16 / 9; position: relative; }
.chart-box.short { aspect-ratio: 20 / 6; }
figcaption { font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 8px; display: flex; justify-content: space-between; gap: 8px; }
/* ---- tools ---- */
.tool { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; }
.tool:last-child { margin-bottom: 0; }
.tool .trow { display: flex; justify-content: space-between; align-items: baseline; }
.tool .tval { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 14px; font-weight: 650; color: var(--accent-2); }
.tool .tval .note { font-size: 10px; color: var(--muted); font-weight: 400; margin-left: 6px; }
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 999px; background: linear-gradient(to right, var(--accent) var(--pct,50%), var(--surface-2) var(--pct,50%)); outline: none; margin: 8px 0 2px; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--surface); border: 2px solid var(--accent); box-shadow: var(--shadow-sm); cursor: pointer; transition: transform .12s ease; }
input[type="range"]::-webkit-slider-thumb:active { transform: scale(1.15); }
input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: var(--surface); border: 2px solid var(--accent); cursor: pointer; }
input[type="range"]:focus-visible { box-shadow: 0 0 0 3px var(--accent-soft); }
.presets { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { font-family: var(--mono); font-size: 12px; font-weight: 600; padding: 9px 13px; border-radius: 10px; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink); cursor: pointer; transition: all .15s ease; display: flex; flex-direction: column; gap: 2px; align-items: flex-start; }
.chip small { font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); font-weight: 500; }
.chip:hover { border-color: var(--accent); transform: translateY(-1px); }
.chip[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.chip[aria-pressed="true"] small { color: var(--accent-2); }
.btn { font-family: var(--mono); font-size: 13px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; width: 100%; padding: 13px; border-radius: 11px; border: none; background: var(--accent); color: #04201d; cursor: pointer; box-shadow: var(--shadow-sm); transition: transform .12s ease, filter .15s ease; }
.btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn:disabled { opacity: .6; cursor: default; transform: none; }
/* experiment bars */
.exp { display: flex; flex-direction: column; gap: 12px; }
.bar { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; align-items: center; }
.bar .bl { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.bar .bv { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 650; }
.track { grid-column: 1 / -1; height: 9px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.fill { height: 100%; width: 0; border-radius: 999px; transition: width 1.1s cubic-bezier(.2,.7,.3,1); }
.fill.threat { background: linear-gradient(90deg, var(--threat), color-mix(in srgb, var(--threat) 70%, #c0392b)); }
.fill.accent { background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
.fill.good { background: linear-gradient(90deg, var(--good), color-mix(in srgb,var(--good) 70%, var(--accent))); }
.verdict { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; }
.verdict .pass { color: var(--good); font-weight: 700; }
.note { font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--muted); }
.note b { color: var(--ink); font-weight: 650; }
.foot-links { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; font-family: var(--mono); font-size: 11px; }
.compliance-meter { display: flex; align-items: center; gap: 10px; font-family: var(--mono); }
.cm-bar { flex: 1; height: 34px; border-radius: 8px; border: 1px dashed color-mix(in srgb, var(--good) 45%, var(--line)); background: color-mix(in srgb, var(--good) 8%, var(--surface)); display: flex; align-items: center; justify-content: center; font-size: 11px; color: var(--good); font-weight: 650; letter-spacing: .06em; }
/* ---- "how it protects you" explainer ---- */
.how-grid { display: grid; gap: 16px; grid-template-columns: 1fr; }
@media (min-width: 680px) { .how-grid { grid-template-columns: repeat(3, 1fr); } }
.how-step { display: flex; flex-direction: column; gap: 8px; }
.how-step .ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; border: 1px solid var(--line); background: var(--surface-2); }
.how-step .ic svg { width: 18px; height: 18px; }
.how-step h3 { font-size: 14px; font-weight: 650; letter-spacing: -0.01em; }
.how-step .eyebrow { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; }
.how-step p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.55; }
.how-step p b { color: var(--ink); font-weight: 600; }
.how-threat .ic { border-color: color-mix(in srgb, var(--threat) 45%, var(--line)); background: var(--threat-soft); }
.how-threat .eyebrow { color: var(--threat); }
.how-shield .ic { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); background: var(--accent-soft); }
.how-shield .eyebrow { color: var(--accent-2); }
.how-honest .ic { border-color: color-mix(in srgb, var(--good) 45%, var(--line)); }
.how-honest .eyebrow { color: var(--good); }
/* ---- info buttons ---- */
.head-r { display: flex; align-items: center; gap: 9px; }
.info-btn {
width: 22px; height: 22px; flex: none; border-radius: 50%;
border: 1px solid var(--line); background: var(--surface-2); color: var(--muted);
font-family: var(--mono); font-size: 12px; font-weight: 700; line-height: 1;
cursor: pointer; display: grid; place-items: center; transition: all .15s ease;
}
.info-btn:hover { border-color: var(--accent); color: var(--accent-2); transform: translateY(-1px); }
.info-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-soft); }
.mini-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 2px 2px -6px; }
/* ---- modal / bottom sheet ---- */
.backdrop {
position: fixed; inset: 0; z-index: 80; display: flex; align-items: flex-end; justify-content: center;
background: color-mix(in srgb, #041110 64%, transparent); backdrop-filter: blur(4px);
transition: opacity .2s ease;
}
.backdrop[hidden] { display: none; }
@media (min-width: 560px) { .backdrop { align-items: center; padding: 20px; } }
.modal {
width: 100%; max-width: 480px; max-height: 88vh; overflow-y: auto;
background: var(--surface); color: var(--ink);
border: 1px solid var(--line); border-top: 3px solid var(--accent);
border-radius: 20px 20px 0 0; box-shadow: var(--shadow);
padding: 18px 18px calc(20px + env(safe-area-inset-bottom));
animation: sheet-up .38s cubic-bezier(.2,.8,.25,1);
-webkit-overflow-scrolling: touch;
}
@media (min-width: 560px) { .modal { border-radius: 16px; border-top-width: 1px; padding: 22px; animation: modal-pop .3s cubic-bezier(.2,.8,.25,1); } }
@keyframes sheet-up { from { transform: translateY(100%); } }
@keyframes modal-pop { from { transform: translateY(12px) scale(.97); opacity: 0; } }
.grip { width: 42px; height: 4px; border-radius: 999px; background: var(--line); margin: 0 auto 15px; }
@media (min-width: 560px) { .grip { display: none; } }
.modal-h { display: flex; align-items: flex-start; gap: 12px; }
.mglyph { width: 42px; height: 42px; flex: none; border-radius: 12px; background: var(--accent-soft); display: grid; place-items: center; border: 1px solid var(--line); }
.mglyph svg { width: 23px; height: 23px; }
.modal h3 { font-size: 19px; font-weight: 720; line-height: 1.15; }
.eyebrow { font-family: var(--mono); font-size: 10.5px; letter-spacing: .15em; text-transform: uppercase; color: var(--accent-2); }
.modal p { margin: 12px 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.modal p b, .points b { color: var(--ink); }
.m-close { margin-left: auto; width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--line); background: var(--surface-2); color: var(--muted); cursor: pointer; font-size: 17px; line-height: 1; flex: none; }
.m-close:hover { border-color: var(--accent); color: var(--ink); }
.points { list-style: none; margin: 15px 0 4px; padding: 0; display: flex; flex-direction: column; gap: 11px; }
.points li { display: flex; gap: 11px; align-items: flex-start; }
.points .pi { width: 30px; height: 30px; flex: none; border-radius: 9px; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--line); color: var(--accent-2); font-family: var(--mono); font-weight: 700; font-size: 13px; }
.points .pi svg { width: 16px; height: 16px; }
.points b { font-size: 13.5px; display: block; }
.points small { display: block; font-size: 12.5px; color: var(--muted); line-height: 1.5; margin-top: 2px; }
.modal-actions { display: flex; flex-direction: column; gap: 9px; margin-top: 18px; }
@media (min-width: 420px) { .modal-actions { flex-direction: row-reverse; } .modal-actions .btn { flex: 1; } }
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); box-shadow: none; }
.btn.ghost:hover { border-color: var(--accent); filter: none; }
/* ---- guided tour ---- */
.tour-dim { position: fixed; inset: 0; z-index: 70; background: color-mix(in srgb, #041110 66%, transparent); }
.tour-dim[hidden] { display: none; }
.tour-focus {
position: relative !important; z-index: 74; border-radius: 14px;
box-shadow: 0 0 0 3px var(--accent), 0 0 0 8px var(--accent-soft), 0 12px 34px rgba(0,0,0,.4);
transition: box-shadow .2s ease;
}
.tour-tip {
position: fixed; left: 12px; right: 12px; z-index: 78; margin: 0 auto; max-width: 440px;
background: var(--surface); border: 1px solid var(--line); border-radius: 15px;
box-shadow: var(--shadow); padding: 15px 16px calc(15px + env(safe-area-inset-bottom));
animation: modal-pop .22s ease;
}
.tour-tip[hidden] { display: none; }
.tour-tip h4 { margin: 5px 0 6px; font-size: 15.5px; font-weight: 700; }
.tour-tip p { margin: 0; color: var(--muted); font-size: 13.5px; line-height: 1.55; }
.tour-nav { display: flex; align-items: center; gap: 8px; margin-top: 15px; }
.tour-dots { display: flex; gap: 5px; flex: 1; align-items: center; }
.tour-dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--line); transition: background .2s, width .2s; }
.tour-dots i.on { background: var(--accent); width: 16px; border-radius: 3px; }
.tour-nav button { font-family: var(--mono); font-size: 12px; font-weight: 650; padding: 9px 14px; border-radius: 9px; cursor: pointer; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink); }
.tour-nav button:disabled { opacity: .4; cursor: default; }
.tour-nav .next { background: var(--accent); color: #04201d; border-color: transparent; }
.tour-nav .skip { border: none; background: transparent; color: var(--muted); padding: 9px 4px; }
@media (prefers-reduced-motion: reduce) {
.card { animation: none; opacity: 1; transform: none; }
.status.good .dot, .status.bad .dot { animation: none; }
.fill { transition: none; }
.modal, .tour-tip { animation: none; }
.tour-focus { transition: none; }
}
</style>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent-2)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"/><path d="M8.5 12.2l2.2 2.2 4.8-4.8"/></svg>
</span>
<span class="brand-txt">
<b>WiFi Veil&nbsp;Console</b>
<span>WiFi-sensing shield</span>
</span>
</div>
<span class="spacer"></span>
<span id="statusPill" class="status watch"><span class="dot"></span><span id="statusTxt">Monitoring</span></span>
<button id="helpBtn" class="icon-btn" aria-label="Introduction and help" title="Introduction &amp; help">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.4 2.4 0 114 1.9c-1 .7-1.6 1.2-1.6 2.4"/><path d="M12 17h.01"/></svg>
</button>
<button id="themeBtn" class="icon-btn" aria-label="Toggle light / dark theme" title="Toggle theme">
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3 7 7 0 0021 12.8z"/></svg>
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
</div>
<div class="grid">
<!-- HERO -->
<section id="heroCard" class="card hero col-span" style="animation-delay:0s">
<div class="hero-canvas-wrap">
<canvas id="hero" aria-label="Live view of identity clusters collapsing toward the chance floor when the shield engages"></canvas>
<div class="hero-overlay">
<div class="hero-top">
<h1>Identity inference,<br/>collapsed to&nbsp;<em>chance</em>.</h1>
<div class="kbig"><b id="heroReid">4.7%</b><small>re-id &middot; shield on</small></div>
</div>
<div class="hero-bottom">
<div class="switch-row" role="group" aria-label="Shield mode">
<button class="seg" id="mAuto" aria-pressed="true">Auto</button>
<button class="seg" id="mOn" aria-pressed="false">On</button>
<button class="seg off" id="mOff" aria-pressed="false">Off</button>
</div>
<div class="legend">
<span><i style="background:var(--threat)"></i>exposed</span>
<span><i style="background:var(--accent)"></i>shielded</span>
</div>
</div>
</div>
</div>
</section>
<!-- KPIs -->
<div class="mini-head col-span"><span class="label">Live scorecard</span><button class="info-btn" data-explain="kpis" aria-label="What do these numbers mean?">?</button></div>
<section id="kpisSec" class="kpis col-span" aria-label="Live metrics">
<div class="kpi warnstrip"><span class="strip"></span><div class="label">Re-ID · off</div><div class="v" id="kOff">100<small>%</small></div><div class="foot">attacker unhindered</div></div>
<div class="kpi goodstrip"><span class="strip"></span><div class="label">Re-ID · on</div><div class="v" id="kOn">4.7<small>%</small></div><div class="foot" id="kChance">chance 6.25%</div></div>
<div class="kpi"><span class="strip"></span><div class="label">Throughput</div><div class="v" id="kTput">97.6<small>%</small></div><div class="foot">of baseline link</div></div>
<div class="kpi goodstrip"><span class="strip"></span><div class="label">Emission</div><div class="v mono" id="kEnergy" style="font-size:22px">1.000<small>×</small></div><div class="foot"><span class="tag good">not jamming</span></div></div>
</section>
<!-- HOW IT PROTECTS YOU -->
<section class="card col-span" style="animation-delay:.04s; margin:0">
<div class="head"><h2>How this protects you</h2><div class="head-r"><span class="sub">plain language</span><button class="info-btn" data-explain="protect" aria-label="More detail on how it protects you">?</button></div></div>
<div class="how-grid">
<div class="how-step how-threat">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="var(--threat)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="eyebrow">The threat</span>
<p>Your Wi-Fi constantly sends the router fine signal details — <b>in the clear</b>. A stranger nearby can capture them and recognise <b>individual people by their radio "fingerprint"</b>: through walls, with no camera, and nothing on you.</p>
</div>
<div class="how-step how-shield">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="var(--accent-2)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"/><path d="M8.5 12.2l2.2 2.2 4.8-4.8"/></svg></span>
<span class="eyebrow">The shield</span>
<p>WiFi Veil <b>scrambles that fingerprint</b> on every report with a secret twist only your own router can undo. An outside listener sees a <b>different scramble each time</b> and can't tie it to a person — their guess of "who's here" drops to <b>pure chance</b>.</p>
</div>
<div class="how-step how-honest">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="var(--good)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"/></svg></span>
<span class="eyebrow">Kept honest</span>
<p>It shapes <b>only your own signal — it never jams</b>, and your Wi-Fi speed stays ~98%. It stops <b>outside snoops</b>, not the router you connect to. Figures here are <b>simulated (L0)</b>, pending real-hardware tests.</p>
</div>
</div>
</section>
<!-- COLLAPSE CURVE -->
<figure id="curveCard" class="card" style="animation-delay:.06s; margin:0">
<div class="head"><h2>Collapse curve</h2><div class="head-r"><span class="sub">re-ID vs mixing</span><button class="info-btn" data-explain="curve" aria-label="Explain this chart">?</button></div></div>
<div class="chart-box"><canvas id="curve"></canvas></div>
<figcaption><span>Givens passes →</span><span id="curveOp">op: 96 · re-ID 4.7%</span></figcaption>
</figure>
<!-- THROUGHPUT CURVE -->
<figure class="card" style="animation-delay:.12s; margin:0">
<div class="head"><h2>Throughput optimum</h2><div class="head-r"><span class="sub">vs feedback bits</span><button class="info-btn" data-explain="tput" aria-label="Explain this chart">?</button></div></div>
<div class="chart-box"><canvas id="tput"></canvas></div>
<figcaption><span>Feedback resolution (bits) →</span><span id="tputOp">5-bit · 97.6%</span></figcaption>
</figure>
<!-- SENSING DETECTOR -->
<figure class="card col-span" style="animation-delay:.16s; margin:0">
<div class="head"><h2>Sensing activity</h2><div class="head-r"><span class="sub" id="detTxt">solicitations / s</span><button class="info-btn" data-explain="detector" aria-label="Explain this chart">?</button></div></div>
<div class="chart-box short"><canvas id="detector"></canvas></div>
<figcaption><span>threshold 5.0 Hz — shield auto-engages above</span><span id="detNow">0.0 Hz</span></figcaption>
</figure>
<!-- TOOLS -->
<section id="controlsCard" class="card" style="animation-delay:.2s; margin:0">
<div class="head"><h2>Shield controls</h2><div class="head-r"><span class="sub">live model</span><button class="info-btn" data-explain="controls" aria-label="Explain these controls">?</button></div></div>
<div class="tool">
<div class="trow"><span class="label">Givens passes</span><span class="tval" id="vPass">96<span class="note">min robust 48</span></span></div>
<input type="range" id="sPass" min="8" max="128" step="8" value="96" aria-label="Givens rotation passes" />
</div>
<div class="tool">
<div class="trow"><span class="label">Feedback resolution</span><span class="tval" id="vBits">5<span class="note">bits · 802.11 {5,7,9}</span></span></div>
<input type="range" id="sBits" min="1" max="12" step="1" value="5" aria-label="Feedback resolution in bits" />
</div>
<div class="tool">
<div class="trow"><span class="label">Candidate identities</span><span class="tval" id="vN">16<span class="note">chance 6.25%</span></span></div>
<input type="range" id="sN" min="4" max="64" step="4" value="16" aria-label="Number of candidate identities" />
</div>
<div class="tool">
<div class="trow"><span class="label">Link SNR</span><span class="tval" id="vSnr">20<span class="note">dB</span></span></div>
<input type="range" id="sSnr" min="5" max="40" step="1" value="20" aria-label="Link signal-to-noise ratio in dB" />
</div>
<div style="height:8px"></div>
<div class="compliance-meter" aria-label="Compliance">
<span class="label" style="white-space:nowrap">Energy in</span>
<div class="cm-bar" id="cmBar">= energy out · 1.000×</div>
<span class="label" style="white-space:nowrap">out</span>
</div>
</section>
<!-- EXPERIMENT + PRESETS -->
<section id="expCard" class="card" style="animation-delay:.26s; margin:0">
<div class="head"><h2>Attacker vs. protector</h2><div class="head-r"><span class="sub">synthetic · L0</span><button class="info-btn" data-explain="experiment" aria-label="Explain this tool">?</button></div></div>
<div class="exp">
<div class="bar">
<span class="bl">Passive re-ID — shield off</span><span class="bv" id="eOff" style="color:var(--threat)"></span>
<div class="track"><div class="fill threat" id="fOff"></div></div>
</div>
<div class="bar">
<span class="bl">Passive re-ID — shield on</span><span class="bv" id="eOn" style="color:var(--accent-2)"></span>
<div class="track"><div class="fill accent" id="fOn"></div></div>
</div>
<div class="bar">
<span class="bl">Link throughput retained</span><span class="bv" id="eTput" style="color:var(--good)"></span>
<div class="track"><div class="fill good" id="fTput"></div></div>
</div>
<div class="verdict"><span id="verdict"></span></div>
</div>
<div style="height:14px"></div>
<button class="btn" id="runBtn">▶ Run experiment</button>
<div style="height:20px"></div>
<div class="label" style="margin-bottom:9px">Deployment presets · adaptive shield</div>
<div class="presets" id="presets">
<button class="chip" data-p="scif" aria-pressed="false">Defence / SCIF<small>N64 · 96p · 5b</small></button>
<button class="chip" data-p="board" aria-pressed="true">Boardroom<small>N16 · 96p · 5b</small></button>
<button class="chip" data-p="ward" aria-pressed="false">Hospital ward<small>N32 · 96p · 5b</small></button>
<button class="chip" data-p="hotel" aria-pressed="false">Hotel floor<small>N48 · 64p · 5b</small></button>
</div>
</section>
<section class="col-span note" style="padding:2px 4px">
<b>Prefer the terminal?</b> The same instrument ships as <b>veil</b> — a dependency-free
TUI &amp; scriptable harness inside the crate
(<span class="mono">cargo run -p wifi-densepose-privshield --bin veil</span>). Live-steer the
shield with <span class="mono">on/off · passes · bits · preset · optimize</span>, or run
<span class="mono">veil doctor</span> in CI.
</section>
<section class="col-span note" style="padding:2px 4px">
<b>Compliant waveform controls only — never jamming.</b> The shield rotates its own beamforming
feedback with keyed Givens rotations (energy-preserving), so a sniffer can't average out a stable
identity while the associated receiver, holding the key, decodes normally. All figures are
<b>SYNTHETIC / evidence-level&nbsp;L0</b> from the reference model — not measured on hardware.
<div class="foot-links">
<a href="#" onclick="return false">ADR-288 · WiFi Veil</a>
<a href="#" onclick="return false">ADR-289 · harness</a>
<a href="#" onclick="return false">docs/research/privacy-shield</a>
</div>
</section>
</div>
</div>
<!-- onboarding modal / bottom sheet -->
<div id="backdrop" class="backdrop" role="dialog" aria-modal="true" aria-label="Introduction" hidden>
<div class="modal" id="modal"><div class="grip" aria-hidden="true"></div><div id="modalBody"></div></div>
</div>
<!-- guided tour -->
<div id="tourDim" class="tour-dim" hidden></div>
<div id="tourTip" class="tour-tip" role="dialog" aria-live="polite" aria-label="Guided tour" hidden>
<span class="eyebrow" id="tourStep">Step 1 of 5</span>
<h4 id="tourTitle"></h4>
<p id="tourBody"></p>
<div class="tour-nav">
<span class="tour-dots" id="tourDots" aria-hidden="true"></span>
<button class="skip" id="tourSkip">Skip</button>
<button id="tourBack">Back</button>
<button class="next" id="tourNext">Next</button>
</div>
</div>
<script>
(() => {
"use strict";
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const $ = (id) => document.getElementById(id);
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
const css = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
/* ---------- WiFi Veil model (mirrors the crate) ---------- */
// re-ID collapse vs mixing passes: piecewise-linear over the crate's measured
// N=16 shield-on points (optimize.rs). Collapse is ~N-independent (finding),
// so other N just shift by the chance-floor difference (1/N 1/16).
const REID16 = [[8,0.98],[16,0.75],[24,0.50],[32,0.20],[48,0.12],[64,0.078],[96,0.047],[112,0.078],[128,0.06]];
function interp(pts, xq) {
if (xq <= pts[0][0]) return pts[0][1];
if (xq >= pts[pts.length-1][0]) return pts[pts.length-1][1];
for (let i=0;i<pts.length-1;i++){ const [x0,y0]=pts[i],[x1,y1]=pts[i+1]; if (xq>=x0 && xq<=x1) return y0+(y1-y0)*(xq-x0)/(x1-x0); }
return pts[pts.length-1][1];
}
function reidOn(passes, N) {
const shifted = interp(REID16, passes) + (1/N - 1/16);
return clamp(shifted, Math.max(0, 1/N - 0.02), 1);
}
// throughput ratio: falling quantization residual vs rising feedback airtime.
function throughput(bits, snrDb) {
const snr = Math.pow(10, snrDb / 10);
const step = (Math.PI / 2) / Math.pow(2, bits);
const rho = Math.min(0.5, step * step / 12);
const capRatio = Math.log2(1 + snr * (1 - rho)) / Math.log2(1 + snr);
const airtime = 0.02 + 0.0008 * bits;
return clamp((1 - airtime) * capRatio, 0, 1);
}
function specOptimalBits(snrDb) { // best of {5,7,9}
return [5,7,9].reduce((b,x)=> throughput(x,snrDb) > throughput(b,snrDb) ? x : b, 5);
}
/* ---------- state ---------- */
const S = { mode:"auto", passes:96, bits:5, N:16, snr:20, sensing:0, engaged:true };
function engaged() { return S.mode==="on" || (S.mode==="auto" && S.sensing >= 5); }
/* ---------- shared count-up ---------- */
function animateNum(el, from, to, dur, fmt) {
if (reduce) { el.textContent = fmt(to); return; }
const t0 = performance.now();
(function step(t){
const k = clamp((t - t0)/dur, 0, 1);
const e = 1 - Math.pow(1-k, 3);
el.textContent = fmt(from + (to-from)*e);
if (k < 1) requestAnimationFrame(step);
})(t0);
}
/* ---------- readouts ---------- */
function refreshReadouts(animate) {
const chance = 1/S.N;
const on = reidOn(S.passes, S.N)*100;
const tp = throughput(S.bits, S.snr)*100;
$("vPass").firstChild.textContent = S.passes + " ";
$("vBits").firstChild.textContent = S.bits + " ";
$("vN").firstChild.textContent = S.N + " ";
$("vSnr").firstChild.textContent = S.snr + " ";
$("vN").querySelector(".note").textContent = "chance " + (chance*100).toFixed(2) + "%";
$("kChance").textContent = "chance " + (chance*100).toFixed(2) + "%";
$("curveOp").textContent = "op: " + S.passes + " · re-ID " + on.toFixed(1) + "%";
$("tputOp").textContent = S.bits + "-bit · " + tp.toFixed(1) + "%";
// slider fills
for (const [id,mn,mx] of [["sPass",8,128],["sBits",1,12],["sN",4,64],["sSnr",5,40]]) {
const el=$(id); el.style.setProperty("--pct", ((el.value-mn)/(mx-mn)*100)+"%");
}
$("kOn").innerHTML = on.toFixed(1)+'<small>%</small>';
$("kTput").innerHTML = tp.toFixed(1)+'<small>%</small>';
$("heroReid").textContent = (engaged()? on : 100).toFixed(1)+"%";
drawCurve(); drawTput();
}
/* ---------- status pill ---------- */
function refreshStatus() {
const pill=$("statusPill"), txt=$("statusTxt");
pill.className = "status";
if (S.mode==="off") { pill.classList.add("bad"); txt.textContent = S.sensing>=5 ? "Exposed" : "Shield off"; }
else if (engaged()) { pill.classList.add("good"); txt.textContent = "Protected"; }
else { pill.classList.add("watch"); txt.textContent = "Monitoring"; }
$("heroReid").textContent = (engaged()? reidOn(S.passes,S.N)*100 : 100).toFixed(1)+"%";
}
/* ---------- hi-dpi canvas ---------- */
function fit(c) {
const r = c.getBoundingClientRect(), dpr = Math.min(devicePixelRatio||1, 2);
c.width = Math.max(1, r.width*dpr); c.height = Math.max(1, r.height*dpr);
const x = c.getContext("2d"); x.setTransform(dpr,0,0,dpr,0,0);
return { x, w:r.width, h:r.height };
}
/* ---------- HERO: identity clusters collapsing ---------- */
const hero = $("hero");
let pts = [], collapse = engaged()?1:0, sessionAng = 0, lastSession = 0;
function seedPts() {
pts = [];
const groups = 16;
for (let g=0; g<groups; g++) {
const a = (g/groups)*Math.PI*2 + 0.2;
const rad = 0.30 + (g%3)*0.045;
for (let k=0;k<5;k++){
const jr = rad + (Math.random()-0.5)*0.05, ja = a + (Math.random()-0.5)*0.16;
pts.push({ a:ja, r:jr, ba:ja, br:jr, g });
}
}
}
seedPts();
function drawHero(t) {
const { x, w, h } = fit(hero);
const cx = w/2, cy = h*0.52, R = Math.min(w,h)*0.9;
x.clearRect(0,0,w,h);
const accent = css("--accent"), threat = css("--threat"), line = css("--grid");
// faint concentric grid (chance rings)
x.strokeStyle = line; x.lineWidth = 1;
for (let i=1;i<=3;i++){ x.beginPath(); x.arc(cx,cy,R*0.12*i,0,7); x.stroke(); }
// chance core
const coreR = R*0.06;
x.fillStyle = accent + "22";
x.beginPath(); x.arc(cx,cy,coreR*(1+0.15*Math.sin(t/600)),0,7); x.fill();
// session rotation cadence (shielded => fresh keyed rotation)
if (engaged() && !reduce && t-lastSession > 1300) { sessionAng += (Math.random()*2-1)*1.2; lastSession = t; }
const col = collapse;
for (const p of pts) {
const ang = p.ba + sessionAng*col;
const rr = p.br*(1-col) + (coreR/R + (Math.random()-0.5)*0.006)*col; // collapse toward core
const px = cx + Math.cos(ang)*rr*R, py = cy + Math.sin(ang)*rr*R;
const mix = col;
// colour lerps threat->accent as it shields
x.fillStyle = mix > 0.5 ? accent : threat;
x.globalAlpha = 0.55 + 0.35*(1-Math.abs(0.5-mix)*2*0.3);
x.beginPath(); x.arc(px,py, 2.4 + (1-col)*0.8, 0, 7); x.fill();
}
x.globalAlpha = 1;
// ease collapse toward target
const target = engaged()?1:0;
if (!reduce) collapse += (target-collapse)*0.06;
else collapse = target;
}
/* ---------- CURVE: re-ID vs passes ---------- */
function drawCurve() {
const { x, w, h } = fit($("curve"));
x.clearRect(0,0,w,h);
const padL=34, padR=10, padT=12, padB=22;
const gw = w-padL-padR, gh = h-padT-padB;
const grid=css("--grid"), muted=css("--muted"), accent=css("--accent"), threat=css("--threat"), good=css("--good");
const P0=8, P1=128, chance=1/S.N;
const X = p => padL + (p-P0)/(P1-P0)*gw;
const Y = v => padT + (1-v)*gh; // v in 0..1
// grid + y labels
x.strokeStyle=grid; x.fillStyle=muted; x.font="10px "+css("--mono").split(",")[0];
x.textAlign="right"; x.textBaseline="middle";
for (const v of [0,0.25,0.5,0.75,1]) { const yy=Y(v); x.strokeStyle=grid; x.beginPath(); x.moveTo(padL,yy); x.lineTo(w-padR,yy); x.stroke(); x.fillText((v*100)+"%", padL-6, yy); }
// chance band
x.fillStyle = good+"22"; x.fillRect(padL, Y(chance*2.48), gw, (h-padB)-Y(chance*2.48));
x.strokeStyle=good; x.setLineDash([4,4]); x.beginPath(); x.moveTo(padL,Y(chance)); x.lineTo(w-padR,Y(chance)); x.stroke(); x.setLineDash([]);
x.textAlign="left"; x.fillStyle=good; x.fillText("chance", padL+4, Y(chance)-7);
// curve
x.beginPath();
for (let p=P0;p<=P1;p+=2){ const yy=Y(reidOn(p,S.N)); const xx=X(p); p===P0?x.moveTo(xx,yy):x.lineTo(xx,yy); }
const grad=x.createLinearGradient(padL,0,w-padR,0); grad.addColorStop(0,threat); grad.addColorStop(1,accent);
x.strokeStyle=grad; x.lineWidth=2.4; x.lineJoin="round"; x.stroke();
// op point
const opx=X(S.passes), opy=Y(reidOn(S.passes,S.N));
x.fillStyle=css("--surface"); x.strokeStyle=accent; x.lineWidth=2.5;
x.beginPath(); x.arc(opx,opy,5,0,7); x.fill(); x.stroke();
// x ticks
x.fillStyle=muted; x.textAlign="center"; x.textBaseline="top";
for (const p of [8,48,96,128]){ x.fillText(p, X(p), h-padB+5); }
}
/* ---------- TPUT: throughput vs bits ---------- */
function drawTput() {
const { x, w, h } = fit($("tput"));
x.clearRect(0,0,w,h);
const padL=40, padR=10, padT=12, padB=22, gw=w-padL-padR, gh=h-padT-padB;
const grid=css("--grid"), muted=css("--muted"), accent=css("--accent"), good=css("--good");
const B0=1,B1=12, lo=0.95, hi=0.985;
const X=b=>padL+(b-B0)/(B1-B0)*gw;
const Y=v=>padT+(1-(v-lo)/(hi-lo))*gh;
x.font="10px "+css("--mono").split(",")[0]; x.textAlign="right"; x.textBaseline="middle";
for (const v of [0.95,0.96,0.97,0.98]){ const yy=Y(v); x.strokeStyle=grid; x.beginPath(); x.moveTo(padL,yy); x.lineTo(w-padR,yy); x.stroke(); x.fillStyle=muted; x.fillText((v*100).toFixed(0)+"%", padL-6, yy); }
// area + line
x.beginPath(); let started=false;
for (let b=B0;b<=B1;b++){ const yy=Y(clamp(throughput(b,S.snr),lo,hi)), xx=X(b); started?x.lineTo(xx,yy):(x.moveTo(xx,yy),started=true); }
x.lineTo(X(B1), Y(lo)); x.lineTo(X(B0), Y(lo)); x.closePath();
x.fillStyle=accent+"1e"; x.fill();
x.beginPath();
for (let b=B0;b<=B1;b++){ const yy=Y(clamp(throughput(b,S.snr),lo,hi)), xx=X(b); b===B0?x.moveTo(xx,yy):x.lineTo(xx,yy); }
x.strokeStyle=accent; x.lineWidth=2.4; x.lineJoin="round"; x.stroke();
// dots at 1..12
for (let b=B0;b<=B1;b++){ const xx=X(b), yy=Y(clamp(throughput(b,S.snr),lo,hi)); x.fillStyle=(b===S.bits)?accent:css("--faint"); x.beginPath(); x.arc(xx,yy,(b===S.bits)?4.5:2.2,0,7); x.fill(); }
// spec optimum marker
const sb=specOptimalBits(S.snr);
x.strokeStyle=good; x.setLineDash([3,3]); x.beginPath(); x.moveTo(X(sb),padT); x.lineTo(X(sb),h-padB); x.stroke(); x.setLineDash([]);
x.fillStyle=good; x.textAlign="center"; x.textBaseline="top"; x.font="9px "+css("--mono").split(",")[0];
x.fillText("spec opt", X(sb), padT-1);
x.fillStyle=muted; x.textBaseline="top"; x.textAlign="center"; x.font="10px "+css("--mono").split(",")[0];
for (const b of [1,5,9,12]) x.fillText(b, X(b), h-padB+5);
}
/* ---------- DETECTOR sparkline (live) ---------- */
const det = $("detector"); const buf = new Array(120).fill(0);
let detPhase = 0, spikeUntil = 0;
function stepDetector(t) {
// simulate sporadic sensing bursts
if (!reduce) {
if (t > spikeUntil && Math.random() < 0.006) spikeUntil = t + 2600 + Math.random()*3000;
const active = t < spikeUntil;
const base = active ? 6.5 + Math.sin(t/180)*2.2 : 1.0 + Math.sin(t/400)*0.6;
const val = clamp(base + (Math.random()-0.5)*1.2, 0, 12);
buf.push(val); buf.shift();
S.sensing = val;
}
const now = S.sensing;
$("detNow").textContent = now.toFixed(1)+" Hz";
const wasEng = S._eng;
S._eng = engaged();
if (S._eng !== wasEng) { refreshStatus(); }
drawDetector();
}
function drawDetector() {
const { x, w, h } = fit(det);
x.clearRect(0,0,w,h);
const padB=4, gh=h-padB-4, top=4, maxV=12;
const accent=css("--accent"), threat=css("--threat"), grid=css("--grid");
const Y=v=>top+(1-v/maxV)*gh, X=i=>i/(buf.length-1)*w;
// threshold
x.strokeStyle=threat; x.setLineDash([4,4]); x.lineWidth=1; x.beginPath(); x.moveTo(0,Y(5)); x.lineTo(w,Y(5)); x.stroke(); x.setLineDash([]);
// area
const active = S.sensing>=5, colr = active?threat:accent;
x.beginPath(); x.moveTo(0,h);
for (let i=0;i<buf.length;i++) x.lineTo(X(i), Y(buf[i]));
x.lineTo(w,h); x.closePath();
x.fillStyle = colr+"22"; x.fill();
x.beginPath();
for (let i=0;i<buf.length;i++){ const xx=X(i),yy=Y(buf[i]); i===0?x.moveTo(xx,yy):x.lineTo(xx,yy); }
x.strokeStyle=colr; x.lineWidth=2; x.lineJoin="round"; x.stroke();
// endpoint
const ex=X(buf.length-1), ey=Y(buf[buf.length-1]);
x.fillStyle=colr; x.beginPath(); x.arc(ex,ey,3.2,0,7); x.fill();
}
/* ---------- main loop ---------- */
let raf=0;
function loop(t){ drawHero(t); stepDetector(t); raf=requestAnimationFrame(loop); }
if (!reduce) raf=requestAnimationFrame(loop);
else { drawHero(0); drawDetector(); }
/* ---------- experiment ---------- */
function runExperiment() {
const on = reidOn(S.passes,S.N)*100, tp = throughput(S.bits,S.snr)*100, chance=1/S.N*100;
$("fOff").style.width="0%"; $("fOn").style.width="0%"; $("fTput").style.width="0%";
$("eOff").textContent="—"; $("eOn").textContent="—"; $("eTput").textContent="—"; $("verdict").textContent="";
const set=()=>{ $("fOff").style.width="100%"; $("fOn").style.width=on+"%"; $("fTput").style.width=tp+"%"; };
if (reduce) { set(); $("eOff").textContent="100.0%"; $("eOn").textContent=on.toFixed(1)+"%"; $("eTput").textContent=tp.toFixed(1)+"%"; showVerdict(on,chance,tp); return; }
setTimeout(set, 60);
animateNum($("eOff"),0,100,1100,v=>v.toFixed(1)+"%");
setTimeout(()=>animateNum($("eOn"),100,on,1100,v=>v.toFixed(1)+"%"),120);
animateNum($("eTput"),0,tp,1100,v=>v.toFixed(1)+"%");
setTimeout(()=>showVerdict(on,chance,tp),1180);
}
function showVerdict(on,chance,tp){
const pass = on <= chance*2+3 && tp >= 95;
$("verdict").innerHTML = pass
? '<span class="pass">✓ PASS</span> re-ID at chance · throughput ≥ 95% · energy 1.000× (compliant)'
: '<span style="color:var(--crit);font-weight:700">△ OUT OF SPEC</span> raise passes / bits to re-enter the chance band';
}
$("runBtn").addEventListener("click", runExperiment);
/* ---------- presets ---------- */
const PRE = {
scif:{N:64,passes:96,bits:5,snr:20}, board:{N:16,passes:96,bits:5,snr:25},
ward:{N:32,passes:96,bits:5,snr:15}, hotel:{N:48,passes:64,bits:5,snr:20},
};
$("presets").addEventListener("click", (e)=>{
const b=e.target.closest(".chip"); if(!b) return;
document.querySelectorAll("#presets .chip").forEach(c=>c.setAttribute("aria-pressed", c===b));
const p=PRE[b.dataset.p]; Object.assign(S,p);
$("sPass").value=p.passes; $("sBits").value=p.bits; $("sN").value=p.N; $("sSnr").value=p.snr;
refreshReadouts(true); refreshStatus();
});
/* ---------- sliders ---------- */
const bind=(id,key)=>{ $(id).addEventListener("input", e=>{ S[key]=+e.target.value; document.querySelectorAll("#presets .chip").forEach(c=>c.setAttribute("aria-pressed","false")); refreshReadouts(false); refreshStatus(); }); };
bind("sPass","passes"); bind("sBits","bits"); bind("sN","N"); bind("sSnr","snr");
/* ---------- mode ---------- */
function setMode(m){ S.mode=m; ["mAuto","mOn","mOff"].forEach(id=>$(id).setAttribute("aria-pressed", ($(id)===({auto:$("mAuto"),on:$("mOn"),off:$("mOff")})[m]))); refreshStatus(); refreshReadouts(false); }
$("mAuto").onclick=()=>setMode("auto"); $("mOn").onclick=()=>setMode("on"); $("mOff").onclick=()=>setMode("off");
/* ---------- theme ---------- */
$("themeBtn").addEventListener("click", ()=>{
const cur = document.documentElement.getAttribute("data-theme")
|| (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", cur==="dark"?"light":"dark");
refreshReadouts(false); drawDetector();
});
matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", ()=>{ refreshReadouts(false); drawDetector(); });
/* ---------- onboarding: welcome, explainers, guided tour ---------- */
const backdrop=$("backdrop");
let lastFocus=null;
function openModal(html){
$("modalBody").innerHTML=html;
backdrop.hidden=false; document.body.style.overflow="hidden";
lastFocus=document.activeElement;
const f=backdrop.querySelector("button"); if(f) f.focus();
}
function closeModal(){ backdrop.hidden=true; document.body.style.overflow=""; if(lastFocus&&lastFocus.focus) lastFocus.focus(); }
backdrop.addEventListener("click", (e)=>{
if(e.target===backdrop){ closeModal(); return; }
const b=e.target.closest("[data-act]"); if(!b) return;
if(b.dataset.act==="close") closeModal();
else if(b.dataset.act==="tour"){ closeModal(); startTour(); }
});
const SHIELD='<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent-2)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"/><path d="M8.5 12.2l2.2 2.2 4.8-4.8"/></svg>';
function welcomeHTML(){
return '<div class="modal-h"><span class="mglyph">'+SHIELD+'</span>'
+'<div><div class="eyebrow">Welcome</div><h3>Your Wi-Fi privacy shield</h3></div>'
+'<button class="m-close" data-act="close" aria-label="Close">×</button></div>'
+'<p>Ordinary Wi-Fi can quietly <b>recognise who is in a room</b> — no camera, no app, nothing you carry. WiFi Veil scrambles that leak while your Wi-Fi keeps working normally. This console lets you <b>see it, tune it, and prove it</b>.</p>'
+'<ul class="points">'
+'<li><span class="pi">'+SHIELD+'</span><div><b>Watch it work</b><small>The panel up top shows people as dots. Shield on → they blur together and can\'t be told apart.</small></div></li>'
+'<li><span class="pi">%</span><div><b>Read the scorecard</b><small>Live numbers for how well you\'re hidden and how much Wi-Fi speed you keep.</small></div></li>'
+'<li><span class="pi">⚙</span><div><b>Tune with confidence</b><small>Sliders plus one-tap presets for real rooms — a ward, a boardroom, a secure facility.</small></div></li>'
+'</ul>'
+'<div class="modal-actions"><button class="btn" data-act="tour">Take the 30-second tour</button>'
+'<button class="btn ghost" data-act="close">Explore on my own</button></div>';
}
function openWelcome(){ openModal(welcomeHTML()); markSeen(); }
const EXPLAIN={
protect:{t:"How this protects you",p:"Unauthorized Wi-Fi surveillance, and what WiFi Veil does about it — in plain terms.",pts:[
["What can spy on you","Since Wi-Fi 5, your device tells the router how to aim its signal by sending back “beamforming feedback” — sent unencrypted. Anyone in range can capture it passively and, from the tiny stable details, tell people apart (published work re-identifies individuals, detects occupancy through walls, even reads activity)."],
["Why you can't tell","There's no light, no app, no device on you, and the snoop only listens — so this happens silently. The Wi-Fi standard (802.11bf, 2025) added the sensing feature but no privacy protection."],
["What WiFi Veil changes","Your router already reshapes its signal legitimately. WiFi Veil adds a secret, per-session “twist” to that feedback (built from the same math the report already uses). Your own router shares the key and undoes it instantly; an outside listener sees a fresh random twist every session and can't average it into a stable fingerprint."],
["The result","Across sessions the snoop's identity guess collapses to chance (~1-in-N, no better than guessing), while your link keeps ~98% of its speed — because the twist preserves the signal's energy and the data beam is untouched."],
["Not jamming","WiFi Veil only shapes your own, standards-legal transmissions. It never floods the air or blocks anyone (that would be illegal jamming). The energy in = energy out meter proves it."],
["Honest limits","It defends against outside snoops — not the router you're connected to (that party holds the key). It doesn't yet hide coarse motion, and every number here is simulated (evidence level L0) until validated on real hardware."]]},
kpis:{t:"Your live scorecard",p:"Four numbers that update the moment you change a setting.",pts:[
["Re-ID · off","How often an eavesdropper picks the right person with no shield. 100% = they always win."],
["Re-ID · on","The same attacker with the shield running. You want this down at “chance” — pure guessing."],
["Throughput","How much of your normal Wi-Fi speed you keep. Above 95% is the goal."],
["Emission","Proof the shield only reshapes your own signal (energy in = energy out). It never jams."]]},
curve:{t:"Collapse curve",p:"How hard the shield scrambles the hidden identity fingerprint.",pts:[
["Left → right","More “mixing passes” means stronger scrambling."],
["Green band","The guessing floor — once the line drops in here, people are indistinguishable."],
["The dot","Your current setting. Drag Givens passes and watch it slide down."]]},
tput:{t:"Throughput optimum",p:"Privacy shouldn't cost much speed — this finds the sweet spot.",pts:[
["The curve","Wi-Fi speed kept for each feedback-detail setting (bits)."],
["Green dashed line","The best standards-allowed value. Higher isn't better — it just wastes airtime."]]},
detector:{t:"Sensing activity",p:"A live meter of how hard someone is probing the room.",pts:[
["The line","Detected sensing attempts per second."],
["Dashed threshold","Cross it and, in Auto mode, the shield switches itself on."]]},
controls:{t:"Shield controls",p:"Adjust the shield yourself, or let a preset do it.",pts:[
["Givens passes","Scrambling strength. More is safer — and essentially free."],
["Feedback resolution","Signal detail; the console marks the speed-optimal value."],
["Candidate identities","How many people could be present (sets the guessing floor)."],
["Link SNR","Signal quality of the room's Wi-Fi."],
["Presets","One tap configures everything for a room type — start here if unsure."]]},
experiment:{t:"Attacker vs. protector",p:"A one-tap proof of the whole thing.",pts:[
["Run experiment","Simulates a real eavesdropper twice — shield off, then on."],
["The bars","Their success crashes to chance while your speed stays high."],
["PASS","Means hidden, fast, and compliant — never jamming."]]},
};
function explainHTML(k){
const e=EXPLAIN[k]; if(!e) return "";
const rows=e.pts.map(p=>'<li><span class="pi"></span><div><b>'+p[0]+'</b><small>'+p[1]+'</small></div></li>').join("");
return '<div class="modal-h"><div><div class="eyebrow">What am I looking at?</div><h3>'+e.t+'</h3></div>'
+'<button class="m-close" data-act="close" aria-label="Close">×</button></div>'
+'<p>'+e.p+'</p><ul class="points">'+rows+'</ul>'
+'<div class="modal-actions"><button class="btn" data-act="close">Got it</button></div>';
}
document.addEventListener("click",(e)=>{ const ib=e.target.closest("[data-explain]"); if(ib) openModal(explainHTML(ib.dataset.explain)); });
$("helpBtn").addEventListener("click", openWelcome);
/* ----- guided tour ----- */
const STEPS=[
{el:"heroCard",t:"Watch the shield work",b:"Each dot is a person the room's Wi-Fi could secretly recognise. Tap On below and they blur into the centre — an eavesdropper can't tell them apart."},
{el:"kpisSec",t:"Your live scorecard",b:"“Re-ID · on” is the eavesdropper's success rate — you want it at chance (guessing). “Throughput” is the Wi-Fi speed you keep."},
{el:"curveCard",t:"Why it works",b:"More scrambling pushes the eavesdropper's success down into the green “guessing” band. The dot marks your setting."},
{el:"controlsCard",t:"Tune it — or don't",b:"These sliders fine-tune the shield. Not sure? Tap a room type under Deployment presets and it's all set for you."},
{el:"expCard",t:"Prove it",b:"Press Run experiment to simulate an attacker and watch the shield drop their success to chance while speed stays above 95%."},
];
const tour={active:false,i:0}; let focused=null;
function startTour(){ tour.active=true; tour.i=0; $("tourDim").hidden=false; $("tourDots").innerHTML=STEPS.map((_,i)=>'<i class="'+(i===0?'on':'')+'"></i>').join(""); showStep(); }
function clearFocus(){ if(focused){ focused.classList.remove("tour-focus"); focused=null; } }
function placeTip(){ const tip=$("tourTip"); if(tip.hidden||!focused) return; const r=focused.getBoundingClientRect(); tip.style.top=""; tip.style.bottom=""; if(r.top+r.height/2 < innerHeight*0.5) tip.style.bottom="14px"; else tip.style.top="14px"; }
function showStep(){
const s=STEPS[tour.i]; clearFocus();
focused=$(s.el); if(focused) focused.classList.add("tour-focus");
$("tourStep").textContent="Step "+(tour.i+1)+" of "+STEPS.length;
$("tourTitle").textContent=s.t; $("tourBody").textContent=s.b;
[...$("tourDots").children].forEach((d,i)=>d.classList.toggle("on",i===tour.i));
$("tourBack").disabled=tour.i===0;
$("tourNext").textContent=tour.i===STEPS.length-1?"Done":"Next";
$("tourTip").hidden=false;
if(focused) focused.scrollIntoView({behavior:reduce?"auto":"smooth",block:"center"});
if(reduce) placeTip(); else setTimeout(placeTip,320);
}
function endTour(){ tour.active=false; clearFocus(); $("tourDim").hidden=true; $("tourTip").hidden=true; markSeen(); }
$("tourNext").addEventListener("click",()=>{ tour.i>=STEPS.length-1 ? endTour() : (tour.i++, showStep()); });
$("tourBack").addEventListener("click",()=>{ if(tour.i>0){ tour.i--; showStep(); } });
$("tourSkip").addEventListener("click", endTour);
$("tourDim").addEventListener("click", endTour);
addEventListener("keydown",(e)=>{
if(e.key==="Escape"){ if(!backdrop.hidden) closeModal(); else if(tour.active) endTour(); }
else if(tour.active&&e.key==="ArrowRight"){ $("tourNext").click(); }
else if(tour.active&&e.key==="ArrowLeft"){ $("tourBack").click(); }
});
function markSeen(){ try{ localStorage.setItem("veil_seen","1"); }catch(_){} }
function seen(){ try{ return localStorage.getItem("veil_seen")==="1"; }catch(_){ return false; } }
/* ---------- init + resize ---------- */
let rt; addEventListener("resize", ()=>{ clearTimeout(rt); rt=setTimeout(()=>{ refreshReadouts(false); drawDetector(); if(reduce)drawHero(0); placeTip(); }, 120); });
refreshReadouts(false); refreshStatus();
if(!seen()) setTimeout(openWelcome, reduce?0:650);
// first-load flourish
if (!reduce) { animateNum($("kOff"),0,100,700,v=>v.toFixed(0)); setTimeout(()=>{$("kOff").innerHTML='100<small>%</small>';},720); setTimeout(runExperiment, 500); }
else { $("kOff").innerHTML='100<small>%</small>'; runExperiment(); }
})();
</script>
+62
View File
@@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
guard:
name: Honesty / anti-slop guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enforce honesty / anti-slop invariants
run: bash scripts/ci-guard.sh
rust:
name: Rust (test + lint + wasm)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal
rustup component add clippy rustfmt
rustup target add wasm32-unknown-unknown
- name: Format
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Test (crate + proof witness)
run: cargo test
- name: WASM leaf builds
run: cargo build --lib --target wasm32-unknown-unknown
c-core:
name: Firmware C core (host test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build + test portable core
run: make -C firmware/core test
harness:
name: Harness (smoke)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Guidance runs dependency-free
run: node harness/bin/cli.js guidance --topic overview
- name: Install + unit tests
working-directory: harness
run: |
npm ci --ignore-scripts || npm install --ignore-scripts
npm test --if-present
+44
View File
@@ -0,0 +1,44 @@
name: Pages
# Publish the WiFi Veil Console (ui/veil-console.html) to GitHub Pages.
# The console is a single self-contained file (inline CSS/JS, no network), so the
# "build" is just staging it as the site's index.html. Enable once under
# Settings → Pages → Source: "GitHub Actions".
on:
push:
branches: [main]
paths:
- "ui/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment; don't cancel an in-progress one.
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Stage the console as the site
run: |
mkdir -p _site
cp ui/veil-console.html _site/index.html
cp ui/veil-console.html _site/veil-console.html
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: _site
- id: deployment
uses: actions/deploy-pages@v4
+29
View File
@@ -0,0 +1,29 @@
# Rust
/target
**/*.rs.bk
# Library crate: lockfile not committed
Cargo.lock
# C firmware host builds
firmware/**/*.o
firmware/core/test_veil_shield
# ESP-IDF example build output
firmware/esp32/examples/*/build/
firmware/esp32/examples/*/managed_components/
firmware/esp32/examples/*/sdkconfig
firmware/esp32/examples/*/sdkconfig.old
firmware/esp32/examples/*/dependencies.lock
# Node / harness
node_modules/
harness/dist/
# Agent/tooling telemetry — never commit
.claude-flow/
*.log
# OS / editor
.DS_Store
*.swp
+27
View File
@@ -0,0 +1,27 @@
# Changelog
All notable changes to WiFi Veil are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project aims to
follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Standalone repository layout extracted from the RuView monorepo: the
dependency-free `wifi-veil` Rust crate at the repo root, the `veil` terminal
TUI, the self-contained WiFi Veil Console (`ui/veil-console.html`), the
end-to-end `firmware/` hardware program (host-validated portable C core plus
per-provider scaffolds), and the `wifi-veil-harness` npm MetaHarness.
- Continuous integration: Rust build/test/clippy/fmt + WASM leaf build, the C
core host test, and the harness smoke run.
### Notes
- All defense figures remain `SYNTHETIC` / evidence level **L0**. No result is
`MEASURED` until a two-node hardware capture with a witness exists (roadmap
**P5**). Compliant waveform controls only — never jamming.
## [0.1.0]
- Initial VEIL reference: keyed Givens-rotation shield, passive re-identification
attacker, throughput/compliance models, optimizer, and a pinned deterministic
proof witness (ADR-288). npm MetaHarness (ADR-289). E2E hardware program and
portable C core (ADR-290).
+55
View File
@@ -0,0 +1,55 @@
# Contributing to WiFi Veil
Thanks for your interest. WiFi Veil is a privacy-defense project with a strict
honesty and safety contract — please read this before opening a PR.
## Non-negotiable rules
- **Compliant waveform controls only — never jamming.** Do not add, suggest, or
scaffold interference-based "defenses." Every control must shape the node's
*own* standards-conformant emission and preserve its energy.
- **Never present WiFi sensing as camera-grade.** Accuracy/defense statements
must be tagged `SYNTHETIC`, `CLAIMED`, or `MEASURED`. A number is only
`MEASURED` with a reproducer; hardware claims require a captured real-silicon
log. Everything in this repo today is `SYNTHETIC / L0`.
- **The proof witness is load-bearing.** The default scene is pinned by a
deterministic FNV-1a witness (`src/proof.rs`). If a change intentionally moves
it, re-pin the constant *in the same PR* and explain why; an accidental change
is a failing test, not a witness to bump.
## Development
The Rust crate is dependency-free and builds offline.
```bash
cargo test # 43 tests + the pinned witness
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo build --lib --target wasm32-unknown-unknown # WASM leaf must stay green
cd firmware/core && make test # portable C core host test
node harness/bin/cli.js guidance --topic overview # harness (dependency-free)
```
Run the honesty / anti-slop guard before pushing (CI runs it too):
```bash
bash scripts/ci-guard.sh
```
It statically enforces the invariants that keep this project honest: no
telemetry / build artifacts / lockfile / scratch files committed; no debug or
mock-probe markers in source; the `SYNTHETIC` evidence label present on every
firmware provider README; the "never jamming" compliance disclaimer present; no
dishonest hardware-validation claims (honest negated/`TODO(hw)` mentions are
fine); and no stale monorepo identifiers in the code surface.
CI (`.github/workflows/ci.yml`) runs the same gates. Keep changes the smallest
coherent unit, read before editing, and never commit telemetry (`.claude-flow/`),
build artifacts, credentials, or CSI/person data.
## Architecture decisions
Substantive design changes should reference or add an ADR under
[`docs/adr/`](docs/adr/). Treat source, tests, and accepted ADRs as
authoritative over comments and generated text.
+46
View File
@@ -0,0 +1,46 @@
# WiFi Veil — standalone Rust package (extracted from the RuView monorepo).
# Dependency-free by design: no `rand`, no `std::time`/`fs`/`env`/threads, so it
# builds unchanged for `wasm32-unknown-unknown` and can never emit RF or touch a
# radio. The shield *models* compliant waveform controls; it does not drive
# hardware. Every number it prints is SYNTHETIC and reproduced by `cargo test`.
# Empty [workspace] table marks this directory as its own workspace root so it is
# self-contained even when nested inside another repository during extraction.
[workspace]
[package]
name = "wifi-veil"
description = "WiFi Veil (codename VEIL): compliant-waveform countermeasure against unauthorized WiFi sensing. Deterministic attacker-vs-protector experiment that drives beamforming-feedback identity inference toward chance while preserving link throughput. Std-only pure-compute leaf, no async/server/RF-hardware deps; SYNTHETIC data only."
version = "0.1.0"
edition = "2021"
rust-version = "1.82"
authors = ["rUv <ruv@ruv.net>", "WiFi Veil Contributors"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/wifi-veil"
documentation = "https://docs.rs/wifi-veil"
homepage = "https://github.com/ruvnet/wifi-veil"
keywords = ["wifi", "privacy", "beamforming", "sensing", "security"]
categories = ["science", "simulation", "wasm"]
readme = "README.md"
# Intentionally dependency-free (see the module docs in `src/lib.rs`).
[dependencies]
[dev-dependencies]
[lib]
name = "wifi_veil"
path = "src/lib.rs"
# `veil` — the custom, dependency-free terminal harness + TUI. Native counterpart
# to the npm metaharness under `harness/`. Std-only; builds without extra deps.
# Excluded from the wasm leaf story (that stays `cargo build --lib`).
[[bin]]
name = "veil"
path = "src/bin/veil.rs"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 rUv and WiFi Veil Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 rUv
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+130
View File
@@ -0,0 +1,130 @@
![WiFi Veil Console — the shield engaged, with the room's WiFi identity clusters collapsed to the chance floor (re-ID 4.7%, throughput preserved, compliant)](docs/assets/veil-console.png)
# WiFi Veil
**A privacy firewall against unauthorized WiFi sensing — compliant waveform
controls only, never jamming.**
**WiFi Veil** (codename **VEIL** — Verifiable Emission-shaping for
Identity-Leakage prevention) shapes a node's own outgoing WiFi beamforming
feedback so that an unauthorized passive sniffer cannot re-identify people or
infer activity, while a legitimate receiver — which shares a per-session key —
sees an essentially unchanged link.
> **Evidence discipline (read first).** Every defense number here is
> `SYNTHETIC` / evidence level **L0** — reproduced by `cargo test`, not measured
> on a radio. Nothing claims camera-grade accuracy, and no result becomes
> `MEASURED` without a captured hardware log (roadmap **P5**). WiFi Veil uses
> **compliant waveform controls only — never jamming.**
---
## How this protects you from unauthorized WiFi surveillance
**The threat — silent, device-free identification.** Since WiFi 5, your device
tells the router how to aim its signal by sending back *beamforming feedback*
and it goes out **unencrypted**. Anyone within radio range can passively capture
those reports and, from the tiny stable details in them, **tell individual
people apart by their radio "fingerprint"** — through walls, with no camera, no
app, and nothing you carry. Published research re-identifies individuals, counts
occupancy through walls, and reads activity this way, and the 2025 sensing
standard (802.11bf) added the capability but **no privacy protection**. Because
the attacker only listens, you get no indication it is happening.
**The defense — scramble the fingerprint, keep the link.** WiFi Veil adds a
secret, **per-session "twist"** to your own outgoing feedback, built from the
same rotation math (Givens rotations) the report already uses:
- Your **own router shares the key** and undoes the twist instantly, so it
decodes normally — **your WiFi keeps ~98% of its speed.**
- An **outside listener sees a *different* twist every session** and cannot
average many captures into one stable fingerprint. Its guess of *who is in the
room* **collapses to chance.**
- The twist only **reshapes your own, standards-legal signal** — it preserves
the signal's energy exactly (`energy in = energy out`), so it is **compliant,
never jamming.**
## The idea
Identity leaks through the **fine** cross-subcarrier phase structure of a
compressed beamforming report; data throughput rides the **dominant** beam
direction. These live in (mostly) separable subspaces. WiFi Veil composes extra
**keyed Givens rotations** over the *fine* subspace only:
| Property | Consequence |
|---|---|
| **Orthogonal** (energy-preserving) | No added transmit power ⇒ **not jamming** (47 U.S.C. §333/§302a) |
| **Keyed per session** | The legitimate AP inverts it ⇒ throughput preserved |
| **Fresh each session** | A sniffer sees a different rotation every time and can't average it back ⇒ re-identification collapses to chance |
## Result (hyper-optimized default scene, N = 16 identities)
| Metric | Shield off | Shield on |
|---|---|---|
| Passive re-ID accuracy | **100%** | **4.7%** (chance = 6.25%) |
| Link throughput ratio | 100% | **97.6%** |
| Emission energy ratio | — | **1.000000** (compliant) |
All figures are `SYNTHETIC / L0`, byte-reproducible via a pinned FNV-1a witness
(`cargo test`).
## Repository layout
| Path | What it is | Status |
|---|---|---|
| [`src/`](src/) + [`Cargo.toml`](Cargo.toml) | The `wifi-veil` Rust crate — deterministic, dependency-free, WASM-ready reference & experiment (attacker vs. protector, compliance audit, optimizer, proof witness) | **validated** (`cargo test`) |
| [`src/bin/veil.rs`](src/bin/veil.rs) | `veil` — the dependency-free terminal harness + ANSI TUI | validated |
| [`ui/veil-console.html`](ui/veil-console.html) | The graphical **WiFi Veil Console** — self-contained, no build, no network | — |
| [`firmware/`](firmware/) | End-to-end hardware program: a host-validated portable **C core** + honest per-provider scaffolds (openwifi / openwrt / nexmon / esp32) | C core validated; adapters `SYNTHETIC / L0` build-only |
| [`harness/`](harness/) | `wifi-veil-harness` — npm MetaHarness (read-only guidance, router, flywheel) | — |
| [`docs/adr/`](docs/adr/) | Architecture decisions (ADR-288 shield, ADR-289 harness, ADR-290 hardware program) | — |
| [`docs/research/privacy-shield/`](docs/research/privacy-shield/) | SOTA survey, threat model, countermeasure design, compliance, experiment protocol, market, roadmap | — |
## Quickstart
```bash
# 1. The reference model + proof (dependency-free; builds offline)
cargo test # 43 tests + the pinned witness
cargo run --bin veil # interactive TUI (one-shot report when piped)
cargo run --bin veil -- optimize # derive the shipped shield config
# 2. The portable C shield core (host test, no radio)
cd firmware/core && make test # energy conservation, reversibility, PRNG parity
# 3. The console UI — just open it
open ui/veil-console.html # (or double-click; no build, no network)
# 4. The npm harness (read-only guidance needs no install)
node harness/bin/cli.js guidance --topic overview
```
The crate is **dependency-free** and **WASM-ready**:
```bash
cargo build --lib --target wasm32-unknown-unknown
```
## Does this run on real WiFi hardware?
Partially today, fully on an open PHY — see [`firmware/`](firmware/) for the
per-provider feasibility matrix. In short: **openwifi** (SDR/FPGA) is the only
platform that can host the full keyed-reversible design end-to-end; **OpenWRT**
and **Nexmon** reach partial/coarse controls (the exact angles are locked in the
WiFi MCU firmware blob on commodity parts); and **ESP32 cannot shield its own
feedback** — it helps only as a sensing detector or an external-RIS controller.
All firmware is build-only `SYNTHETIC / L0`; no adapter has run on silicon.
## Threat model & scope (stated plainly)
WiFi Veil defends against a **third-party passive sniffer** capturing plaintext
beamforming feedback. It does **not** hide identity from the AP a node is
associated with (that party holds the key by construction). It is **compliant by
construction** — it only shapes the node's own standards-conformant frames,
never transmits to interfere with another station, and never operates an
unauthorized emitter. It is not jamming, not RF denial, and not a claim of
camera-grade anything.
## License
Dual-licensed under either of [Apache License 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option.
@@ -0,0 +1,231 @@
# ADR-288: VEIL — a compliant-waveform privacy shield against unauthorized WiFi sensing
| Field | Value |
|-------|-------|
| **Status** | Proposed — implemented (P1 reference model) |
| **Date** | 2026-08-09 |
| **Deciders** | ruv |
| **Codename** | **VEIL** — Verifiable Emission-shaping for Identity-Leakage prevention |
| **Codebase target** | new leaf crate `v2/crates/wifi-densepose-privshield` |
| **Parent** | ADR-118 (BFLD — the detection layer VEIL is the countermeasure to), ADR-282 (mandatory L0L5 evidence ladder) |
| **Relates to** | ADR-120/121 (BFLD privacy class + identity-risk scoring — the trigger source), ADR-141 (privacy control plane / runtime attestation — the audit consumer), ADR-280 (active sensing / governed actuation — VEIL is a defensive sensing action), ADR-185 §13 (`wifi-densepose-aether` — the pure-compute leaf pattern this crate follows) |
| **Research bundle** | [`docs/research/privacy-shield/`](../research/privacy-shield/) (9 files) |
| **Tracking issue** | TBD |
## 0. PROOF discipline
Every defense number this crate produces is **SYNTHETIC / evidence level L0**
(ADR-282): generated by the crate's own model (`identity::Channel`), attacked by
the crate's own classifier (`attacker::NearestCentroidAttacker`), and scored
against its own known labels. Nothing here has been validated against real WiFi
silicon, and the crate contains no radio integration and cannot emit RF. External
attack/defense results cited from the literature (BFId, LeakyBeam, DySPAN-2026,
IRShield, FCC statutes) are **EXTERNAL** evidence and labelled MEASURED/CLAIMED in
the research bundle. The single measured claim about *our own behavior* is the
pinned deterministic witness in `proof.rs`.
## 1. Context
### 1.1 The gap
IEEE 802.11ac/ax beamforming feedback (BFI) — the compressed Givens-rotation
angle matrices (φ/ψ) a client sends the AP — is transmitted **unencrypted on the
management plane**. Any device in monitor mode can capture it for every station
at once, no network access, and the target need carry no device. The literature
establishes the severity: **BFId** (ACM CCS 2025) re-identifies individuals from
BFI; **LeakyBeam** (NDSS 2025) detects occupancy through walls at 20 m from BFI;
**BeamSense** recognizes activities at up to 99.28%. IEEE Std **802.11bf-2025**
(published 26 Sep 2025) standardizes the sensing measurement/feedback surface
these attacks abuse — and a 2023 proposal for a BFI secure-transmission mechanism
(802.11-23/0782) was **withdrawn**, so the standard shipped with no privacy
protections.
RuView already has a *detection* layer for this: **BFLD** (ADR-118/121) measures
the identity-leakage of each frame and gates what leaves the node. But BFLD
protects *RuView's own outputs*; it does nothing about a **third-party sniffer**
capturing the room's plaintext BFI off the air. There is no RuView component, and
per our market survey no shipping product anywhere, that prevents that.
### 1.2 Constraint: compliant waveform controls, never jamming
The defense must preserve normal communications and must not interfere with any
other station. Jamming (47 U.S.C. §333/§302a) is defined by *adding energy to
interfere with others' transmissions*. Any acceptable control must shape only the
node's **own** standards-conformant emission.
### 1.3 The separability insight
Identity leaks through the *fine* cross-subcarrier phase structure of a
beamforming report; data throughput rides the *dominant* beam direction. These
are (mostly) separable subspaces — so a transform confined to the fine subspace
can wreck re-identification while sparing the beam the link depends on. DySPAN-2026
independently MEASURED that shaping fine-resolution feedback is near-free in
throughput, corroborating the insight.
## 2. Decision
Ship **`wifi-densepose-privshield`** (VEIL) as a standalone pure-compute leaf
crate (the `wifi-densepose-aether`/`nvsim` pattern: dependency-free, deterministic,
WASM-ready, zero coupling to any radio or ingestion path), implementing:
1. **A SYNTHETIC two-subspace BFI model** (`identity.rs`): each identity owns a
stable fine-block signature; sessions add environmental nuisance; the comm
block is identity-free and carries throughput.
2. **The protector** (`protector.rs`): compliant waveform controls, primarily a
**per-session keyed orthogonal rotation of the fine subspace, composed from
extra Givens rotations** — the report's native primitive. Plus feedback
quantization/dither, sounding-cadence randomization, and a `SensingDetector`
that engages the shield only when sensing activity is observed.
3. **The adversary** (`attacker.rs`): a passive nearest-centroid re-identifier
modeling the BFId threat, with selectable Euclidean/Cosine metrics.
4. **A throughput model** (`throughput.rs`):
`(1 sounding feedback_airtime) · C(SNR·(1−ρ))/C(SNR)`, where the residual
`ρ` falls with feedback bits and the feedback airtime rises with them — giving
a genuine interior throughput optimum in feedback resolution.
5. **A compliance audit** (`compliance.rs`): the rotation is orthogonal ⇒
energy-preserving ⇒ adds no interfering energy ⇒ **not jamming**, turned into a
checked `ComplianceReport` (energy ratio ≈ 1.0).
6. **The experiment** (`experiment.rs`): runs the attacker against unprotected and
protected traffic and reports both accuracies vs. chance, plus throughput and
compliance, with a single `passed()` verdict.
7. **The hyper-optimizer** (`optimize.rs`): derives the shipped shield config
rather than hand-picking it — the throughput-optimal feedback resolution and
the minimum rotation-mixing budget that collapses re-ID robustly (across both
attacker metrics and N∈{16,32}), plus a Pareto frontier.
8. **A deterministic proof** (`proof.rs`): a pinned FNV-1a witness over the
reference experiment (the `nvsim`/`verify.py` discipline).
### 2.1 Why the keyed Givens rotation
It is simultaneously **orthogonal** (energy-preserving ⇒ compliant),
**key-reversible** (the associated AP shares the session key and recovers the true
precoder ⇒ throughput preserved), and **fresh per session** (a sniffer sees a new
random rotation of the signature each session and cannot average it back ⇒ the
enrollment attack collapses; over unknown rotations the signature carries no
stable discriminative information ⇒ re-ID → chance). It is the shared-secret
precoding idea (cf. MIMOCrypt) specialized to the identity-bearing subspace.
### 2.2 Measured behavior (SYNTHETIC / L0)
Reference experiment at the hyper-optimized operating point (§opt), default
scene, N=16 identities, `cargo test`:
| Metric | Shield off | Shield on |
|---|---|---|
| Passive re-ID accuracy | 100.0% | **4.7%** (chance 6.25%) |
| Link throughput ratio | 100% | **97.6%** |
| Emission energy ratio | — | **1.000000** (compliant) |
All 35 unit/proof tests + doctest pass; the crate builds for
`wasm32-unknown-unknown` and is clippy-clean.
### opt. Hyper-optimization (`optimize.rs`)
The shipped shield config is the optimizer's output, not a guess, and
`ShieldConfig::default()` is asserted equal to it:
- **Feedback resolution = 5 bits.** Throughput has an interior optimum in
feedback bits (residual falls, feedback airtime rises); the unconstrained
optimum is 3 bits (matching DySPAN-2026), and 5 is the throughput-best value in
the spec-allowed 802.11 {5,7,9} set.
- **Givens passes = 96.** The proven minimum for robust collapse — across both
attacker metrics *and* N∈{16,32} — is **48**; the shipped 96 is a free 2×
privacy margin, since the keyed rotation is derived from the shared secret and
never signaled (extra passes cost compute, not airtime). The original
hand-picked 112 was 2.3× over-provisioned.
Net vs. the original hand-picked (112 passes / 7 bits): the optimum is strictly
better on **both** privacy (re-ID 0.047 vs 0.078) and throughput (0.976 vs 0.974),
and is now verified rather than assumed. See
`docs/research/privacy-shield/08-optimization.md`.
### harness. Native terminal harness + TUI (`src/bin/veil.rs`)
A custom, dependency-free binary (`veil`) ships with the crate — the in-repo,
native counterpart to the npm metaharness (ADR-289). It drives the same public
API the tests use, as an interactive ANSI dashboard plus scriptable subcommands
(`report`, `sweep`, `optimize`, `adaptive <N>`, `proof`, `doctor`, `tui`).
Std-only (no `crossterm`/`ratatui`): the TUI is a command-driven redraw loop, so
it runs in any terminal, pipe, or CI and keeps the crate a pure leaf. It reports
only SYNTHETIC/L0 numbers and never relabels them. The wasm leaf story is
unchanged (validated with `--lib`; the bin is native-only).
### sota. 20252026 evidence update (verified)
A cited, adversarially-verified SOTA sweep
(`docs/research/privacy-shield/09-sota-update-2026.md`) refines the threat and
positioning. Load-bearing points for this ADR:
- **Threat is broader and cheaper than §1.1 stated.** A passive, keyless,
single-antenna sniffer at ~20 m and *through walls* can identify people
(BFId, 99.5%/N=197, `MEASURED`), read **breathing** from stationary occupants
and **keystrokes/PINs** (LeakyBeam / WiKI-Eve / SThief, `MEASURED`), and —
decisively — **reconstruct full CSI from the sniffed BFI** (BFIAttack,
≥93% single-antenna, `MEASURED`). VEIL's obfuscation must therefore degrade
*reconstructed-CSI* utility, not merely raw-BFI feature noise; because VEIL's
rotation is a **secret orthogonal** transform, the attacker has no key and no
closed-form to invert — this is now a claim to **test**, not assume.
- **VEIL's family is independently validated.** AP-side per-packet random
unitary on the LTF (LeakyBeam defense, 89.7%→~51%, `MEASURED`) and RIS
obfuscation (PrivISAC, 93%→~30%, robust to a retrained multi-location
attacker, `MEASURED`) confirm standard-permitted beamforming-surface
obfuscation works; DP-Givens quantization (`SYNTHETIC`) offers a formal ε knob.
- **Compliance precedent.** BeamDancer (IEEE TWC 2024, `MEASURED`) argues
native-beamforming obfuscation is 802.11-compliant while jamming/geofencing
are not — cite it as precedent. (Its ">96% PDR" figure was **refuted** in
verification; do not cite it.)
- **Security honesty.** Obfuscation shields have published counter-attacks
("Defeating CSI obfuscation", SnoopFi), so VEIL's own shield security is
`CLAIMED`, not proven-secure, until it withstands learned de-obfuscation.
- **Governance gap.** No claim on 802.11bf-2025 privacy provisions survived
verification; that pillar remains an open question, not an asserted fact.
The derived, prioritized improvement backlog lives in the SOTA-update file (§4).
## 3. What this explicitly is NOT
- **Not a radio driver.** No RF frontend, no transmit path, no
`wifi-densepose-hardware` coupling. VEIL cannot emit and cannot jam.
- **Not a defense against the associated AP.** That party holds the session key by
construction (threat class A3); protecting against a malicious AP is BFLD's
detection/privacy-class problem (ADR-118/141), not this shield's.
- **Not a full motion-obfuscation claim.** A fixed per-session rotation does not
hide coarse within-session motion; identity *re-ID* is the guaranteed target,
motion is partial/future work.
- **Not a real-hardware performance claim.** All defense numbers are SYNTHETIC/L0
until a two-node capture with a boot/runtime-log witness exists (CLAUDE.md
hardware rule; roadmap P5).
- **Not RF denial or camera-grade anything.**
## 4. Simplifications (honesty boundary)
- The two-subspace split is an abstraction; on real radios comm and identity
information are only *approximately* separable, so the real throughput cost of
fully hiding identity may exceed the model's ~2%. DySPAN-2026's MEASURED curve
bounds it as *small* at fine resolution, not zero.
- The attacker is nearest-centroid. The collapse argument is classifier-independent
(it is about the marginalized signal), but P2/P5 must confirm a learned attacker
also collapses.
- The crate's PRNG is SplitMix64 — deterministic and WASM-safe but **not
cryptographic**; a deployment derives the rotation key from the negotiated link
secret, never from this PRNG.
## 5. Consequences
- RuView gains the *countermeasure* half of its RF-privacy story: BFLD detects
leakage, VEIL acts on it — a defensible, standards-anchored, gap-filling
position (see `docs/research/privacy-shield/06-market-and-buyers.md`).
- The compliance audit gives regulators/auditors a machine-checkable "not jamming"
artifact that composes with ADR-141 attestation.
- Future integration (BFLD `identity_risk``SensingDetector`, ADR-280 governed
actuation, firmware feedback shaping, two-node hardware measurement) is staged in
the research bundle roadmap and deliberately deferred so the model validates in
isolation first.
## 6. Validation
```bash
cargo test -p wifi-densepose-privshield --no-default-features
cargo build -p wifi-densepose-privshield --target wasm32-unknown-unknown
cargo clippy -p wifi-densepose-privshield --all-targets
```
@@ -0,0 +1,95 @@
# ADR-289: `wifi-densepose-privshield-harness` — a MetaHarness for the VEIL privacy shield
| Field | Value |
|-------|-------|
| **Status** | Proposed — implemented (P1) |
| **Date** | 2026-08-09 |
| **Parent** | ADR-288 (`wifi-densepose-privshield` / VEIL, the crate this harness assists development on) |
| **Relates to** | ADR-286 (`wifi-densepose-sar-harness`, the per-crate harness scaffold this one mirrors), ADR-285 (`harness/homecore/`, the WASM-first `@metaharness/kernel` pattern), ADR-182 (`harness/ruview/`, the first minted harness), ADR-282 (L0L5 evidence ladder) |
| **Location** | `harness/wifi-densepose-privshield/` |
## 0. PROOF discipline
Every claim below about what is "real" versus "illustrative"/"SYNTHETIC" is
checked by a test in this harness's own suite (router + flywheel + install-smoke
+ guidance). The dependency-free `guidance` surface is covered by
`__tests__/guidance.test.ts`, which runs even before `npm install`. Nothing here
asserts a MEASURED defense result — the harness surfaces the VEIL crate's
SYNTHETIC/L0 numbers with that label intact.
## 1. Context
`wifi-densepose-privshield` (ADR-288) is the VEIL privacy shield — a new,
narrowly-scoped crate. Following the pattern ADR-286 set for
`wifi-densepose-sar`, it gets a dedicated per-crate MetaHarness rather than a
bespoke setup: the `vertical:coding` scaffold (architect/implementer/reviewer/
test-writer, `doctor`) with `@metaharness/router`, `@metaharness/flywheel`, and
Darwin Mode wired in, plus a VEIL-specific, dependency-free `guidance` surface.
## 2. Decision
Land the harness at `harness/wifi-densepose-privshield/`, mirroring
`wifi-densepose-sar-harness`, with two deliberate improvements:
1. **Dynamic dependency imports.** `bin/cli.js` imports the `@metaharness/*`
packages *inside* the commands that need them, not at module top. So
`guidance`, `--help`, and the guidance test run with **zero dependencies
installed** — useful for offline/air-gapped review and for this repo's CI
before `npm install`. Only `init`/`doctor`/`route`/`flywheel` touch the
kernel/host/router/flywheel packages.
2. **A VEIL `guidance` command.** A self-contained, source-cited, read-only
capability map (topics: `overview`, `threat`, `countermeasure`,
`compliance`, `optimization`, `experiment`), each entry carrying a summary,
repo-relative source citations, focused validation commands, and explicit
limitations — the `ruview_guidance` shape, specialized to VEIL. It labels all
defense evidence `SYNTHETIC/L0` and states plainly that guidance is
navigation, not authority.
The standard three self-improvement/cost pieces are wired as real npm
dependencies (not stubs):
- **`@metaharness/darwin`** (devDependency) — `npm run evolve` / `evolve:dry`
mutates the harness's own operating config, keeping only measurable gains.
- **`@metaharness/router`** — `src/router.ts` wires a real cost-optimal `Router`
(`qualityBar: 0.8`, k=1) over two model tiers, with four VEIL-shaped task axes
(threatModeling / complianceReview / optimizerTuning / docWriting). Labelled
examples are illustrative seed data (honesty note in-file).
- **`@metaharness/flywheel`** — `src/flywheel.ts` wires the real
`runFlywheelGenerations` promotion loop (propose → evaluate → gate → promote,
Ed25519-signed, independently replayable) with a SYNTHETIC proposer/evaluator
(`dataSource: 'SYNTHETIC'`, no model call), over VEIL policy levers
(`complianceReview`, `threatTriage`).
## 3. What this explicitly is NOT
- **Not a VEIL runtime.** The harness does not run a radio, emit RF, or jam. It
assists *development* on the crate; it cannot execute the shield on hardware.
- **Not evolving the crate.** Darwin/Flywheel mutate the harness's own policy
(agent prompts, review-checklist depth), not VEIL's Rust code. The crate's
actual hyper-optimization (ADR-288 §opt) was done directly, in the crate.
- **Not a live routing/promotion system.** The router's examples are seed data;
the flywheel's proposer/evaluator are deterministic stand-ins — both honestly
labelled in-source and in `CLAUDE.md`.
- **Not a replacement for the crate's gates.** The authoritative check for a
VEIL change remains `cargo test -p wifi-densepose-privshield`.
- **Not a re-labeller.** The harness must never present VEIL's SYNTHETIC results
as MEASURED, and never scaffold interference-based ("jamming") defenses — both
are hard rules in the harness `CLAUDE.md`.
## 4. Consequences
- The harness ships `guidance`/`doctor`/`init`/`route`/`flywheel`; `guidance`
and `--help` work offline (validated here via `node bin/cli.js`), the rest
after `npm install` + `npm run build` (CI).
- `.harness/manifest.json` + `manifest.sha256` are generated with real per-file
hashes at creation (unlike ADR-286's scaffold, whose manifest was historical).
- Scoped to its own name: its plugin, permissions, and (future) MCP surface only
read/assist on `wifi-densepose-privshield`. No risk to other harnesses/crates.
## 5. Validation
```bash
cd harness/wifi-densepose-privshield
node bin/cli.js guidance --topic overview # dependency-free
npm ci && npm run build && npm test # full suite (CI; needs registry access)
```
@@ -0,0 +1,94 @@
# ADR-290: VEIL end-to-end hardware implementation program (multi-provider firmware)
| Field | Value |
|-------|-------|
| **Status** | Proposed — P4 scaffolding (build-only); portable core validated on host |
| **Date** | 2026-08-09 |
| **Parent** | ADR-288 (VEIL shield), ADR-289 (harness), ADR-282 (L0L5 evidence ladder) |
| **Location** | `firmware/privshield/` |
| **Relates to** | `firmware/esp32-csi-node/` (the CSI sensor/attacker node), ADR-280 (governed actuation), ADR-141 (attestation) |
## 0. PROOF discipline
The **only** artifact validated here is the portable C core
(`firmware/privshield/core/`): a host test (`make test`) checks energy
conservation, reversibility, wrong-key failure, and — pinned — that its
SplitMix64 key schedule is **byte-identical to the Rust crate's** PRNG. That is
`build`/host-level evidence, not silicon. Every per-provider adapter is a
**build-only scaffold** with `TODO(hw)` markers: `SYNTHETIC / L0`, no captured
log, no `MEASURED` claim. Nothing in this ADR asserts VEIL works on real
hardware; it asserts a *plan and a shared core* to get there (P5).
## 1. Context
ADR-288 shipped VEIL as a deterministic, no-radio Rust model, and the 20252026
SOTA sweep (ADR-288 §sota) confirmed the mechanism's family is real and
standard-permitted. The open question left was **"does this run on real WiFi
hardware, and on which?"** — including the user asks: *can OpenWRT / open WiFi
software implement it, and can ESP32 help scramble signals?* Answering requires
committing to the platform reality rather than assuming a uniform "firmware"
target.
## 2. Decision
Stand up `firmware/privshield/` as a **multi-provider E2E program** around one
shared, validated core:
1. **A portable C shield core** (`core/veil_shield.{h,c}`) — the keyed
Givens-rotation obfuscation, `no_std`-friendly C99 (no malloc/libc I/O), with
a SplitMix64 key schedule matching the Rust crate so on-air behavior is
identical everywhere and every adapter links the *same* math. Host-tested.
2. **Per-provider adapters**, each built and graded by a hardware research
agent, honest about what its stack can actually touch:
- **`openwifi/`** (open PHY/MAC on SDR/FPGA) — the highest-capability path and
the one that can host the **keyed-reversible** design end-to-end
(protector + AP-side compensation). Carries the **P5 measurement protocol**
(`MEASUREMENT.md`) that yields the first `MEASURED` result with a witness.
- **`openwrt/`** (Linux `mac80211`, mt76/ath9k…) — the commodity path.
Sounding-cadence randomization, MU-group and stream-mapping control are
feasible from the driver/hostapd; the per-packet unitary on the LTF spatial
mapping is firmware-deep on most parts. Partial.
- **`nexmon/`** (Broadcom/Cypress C firmware patches) — the commodity
C-firmware route; the read path is proven (Wi-BFI/nexmon_csi), the transmit
report-shaping path is research-grade/partial.
- **`esp32/`** (ESP-IDF) — **not** a feedback protector (the beamforming path
is a closed blob): ESP32 shapes CSI *read*, not transmitted feedback. Its
legitimate roles are a **sensing detector** (trigger the AP-side shield) and
an **RIS controller** (drive an external reconfigurable surface to scramble
the sensing direction — the honest way ESP32 "helps scramble", via an
external surface, not its own PHY).
3. **Compliance stance carried into hardware:** every control shapes the node's
own standards-conformant emission and preserves energy; the ESP32
decoy/cover-traffic idea is documented as *legally sensitive / not
recommended* precisely because it edges toward the interference line.
Per-provider feasibility grades live in each subdir README and the top-level
feasibility matrix; they are the answer to the "which hardware" question.
## 3. What this explicitly is NOT
- **Not validated firmware.** No adapter has run on silicon; there is no witness.
The scaffolds compile-*shaped*, not compile-*guaranteed* on their toolchains
(which are absent in this environment).
- **Not a claim that ESP32 can shield beamforming feedback** — it cannot; it is a
detector/RIS-controller only.
- **Not jamming, on any platform.** Compliant waveform shaping only.
- **Not a MEASURED result.** That is P5, gated on a captured log.
## 4. Consequences
- One validated core, four honest provider scaffolds, and a concrete P5
measurement plan — a real path from model to silicon, with the effort/blocker
reality made explicit per platform.
- The shared core keeps every future hardware result consistent with the crate
and with each other.
- Scope stays inside `firmware/privshield/`; no other crate/firmware is touched
(the existing `esp32-csi-node` remains the sensor/attacker node).
## 5. Validation
```bash
cd firmware/privshield/core && make test # host: energy/reversibility/PRNG parity
# per-provider builds require their toolchains (ESP-IDF, OpenWRT SDK, Nexmon,
# Vivado) and real hardware — see each subdir's BUILD/INTEGRATION notes.
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,141 @@
# 01 — State of the Art
Scope: what a passive or active adversary can extract about *who* is in a space
and *what they are doing* from WiFi, the standard that broadens that surface, and
the countermeasures that try to prevent it. Claims are tagged **MEASURED** (from
a primary source, with metric), **CLAIMED** (asserted without an independent
measurement), or analytical inference (flagged).
---
## 1. The attack surface: beamforming feedback (BFI)
Since WiFi 5 (802.11ac), a client (beamformee) measures the downlink channel,
compresses the steering matrix **V** into **Givens-rotation angles φ/ψ**, and
transmits them **in cleartext** so the AP can steer beams. Anyone in monitor
mode can capture these frames for *every* client simultaneously — no network
access, and the target need carry no device. Quantization is coarse (802.11ac
angle steps of π/4…π/32 rad) yet retains rich motion and body information.
| Work | Venue / year | Result | Label |
|---|---|---|---|
| **BFId** — identity inference from BFI | ACM CCS 2025 (KIT/KASTEL) | Re-identifies individuals from BFI alone; novel 197-person dataset. Press reports **99.5%** in a controlled study (ACM full text was not openable to confirm class count/split) | MEASURED (paper); 99.5% is CLAIMED via press |
| **LeakyBeam** — occupancy through walls | NDSS 2025 | Occupancy detection **TPR 82.7% / TNR 96.7%** at **20 m, through walls**, from plaintext BFI. Proposes a BFI-obfuscation defense | MEASURED (attack); defense overhead CLAIMED |
| **BFIAttack** — CSI reconstruction from BFI | arXiv 2026 (USF) | Reconstructs CSI from BFI, then defeats CSI defenses. ASR: device auth 95.5% / user auth 92.6% / key-gen 94.2% (single-antenna), 1.56 m | MEASURED |
| **BeamSense** — activity recognition from BFI | Computer Networks vol. 258, 2025 (Northeastern) | Human activity recognition **up to 99.28%** on commodity 802.11ac, no firmware mod, ~10% better than CSI | MEASURED |
| **Wi-BFI** — capture tooling | arXiv 2309.04408, 2023 | Pip-installable extraction of 802.11 BFI from commercial devices | tooling |
**Takeaway for the defender.** BFI is the highest-leverage surface: unencrypted,
management-plane, device-free, capturable en masse with off-the-shelf tools. It
is also a *stepping stone* — BFIAttack shows BFI can reconstruct the CSI that all
older attacks assume.
---
## 2. The older adjacent surface: CSI identity/gait/activity
CSI requires special extraction (Intel 5300 / Atheros / ESP32) but is the
foundation the BFI attacks build on. Person-ID exploits **gait** as a biometric.
Representative MEASURED results (commodity WiFi, CSI amplitude):
| System | Accuracy | N (candidates) | Note |
|---|---|---|---|
| WiWho (IPSN 2016) | 92%→80% | 2→6 | 23 m straight walk |
| WiFi-ID (2016) | 93%→77% | 2→6 | wavelet features |
| WiPIN (2018) | 92100% | ≤30 | operation-free |
| Deep-WiID (2019) | 92.599.7% | 6→15 | GRU |
| WiNet / LWID (2020) | 98.5% / 98.8% | 40 / 50 | CNN |
**Pattern the defender must exploit and not overstate:** accuracy is high in
small closed sets but *degrades as N grows and conditions become realistic*
(cross-day, cross-location, cross-walking-style). Chance is **1/N**; a 99% result
on N=5 is far weaker evidence than 99% on N=197. Open-world scale is largely
unproven (see *SoK: Security Evaluation of Wi-Fi CSI Biometrics*, 2025).
---
## 3. The standard: IEEE 802.11bf-2025
IEEE Std **802.11bf-2025** (Amendment 4: *Enhancements for WLAN Sensing*) was
published **26 September 2025**. It standardizes WLAN sensing in 17.125 GHz and
above 45 GHz, defining sensing capability signaling, measurement/sounding
setup, feedback types, and both passive (ambient-traffic) and active
(dedicated null-packet) sensing modes.
- **Attack-surface implication (analytical).** 802.11bf turns CSI/measurement
acquisition from proprietary hacks into open, vendor-agnostic, machine-readable
MAC signaling across heterogeneous devices — institutionalizing exactly the
measurements the BFI attacks abuse. The standard frames sensing as a feature,
not a threat.
- **The privacy gap (MEASURED from standards minutes).** A 2023 proposal for a
BFI "secure transmission mechanism" (IEEE 802.11-23/0782) was **withdrawn**;
"the group did not align on the characterization of [the] privacy problem."
The standard shipped without privacy protections, and its own analysis admits
passive eavesdroppers can extract location, respiration, heart rate, and
identity.
---
## 4. Countermeasures (the defense literature)
All operate on the defender's *own* transmissions; none are jamming.
| Countermeasure | Venue / year | Mechanism | Effect | Label |
|---|---|---|---|---|
| **IRShield** | IEEE S&P 2022 | IRS/reconfigurable surface randomizes reflected paths | Attacker motion-detection **≤5%** | MEASURED |
| **PhyCloak** | USENIX NSDI 2016 | Full-duplex obfuscator injects Doppler/phase distortion into sensing only | **88.69%** gesture-spoof; throughput can rise (whitelist legit sensors) | MEASURED (spoof); throughput CLAIMED |
| **DP-Givens dithering** | IEEE DySPAN 2026 | Differentially-private stochastic quantization of BFI φ/ψ angles | Attacker speed-class error 19%→~73% (chance); **fine (3-bit) resolution ≈ non-private baseline throughput** | MEASURED |
| **MIMOCrypt / WiShield** | 2023 / IEEE JSAC 2024 | Secret precoding / MIMO CSI manipulation so only the intended RX decodes | Anti-tracking | CLAIMED/formal |
| **CSI Fuzzing / DP feature release** | IEEE 202425 | Randomized CSI features with DP budget | Formal DP guarantee | CLAIMED/formal |
| **ScatterShield** | ACM IMWUT 2025 | Backscatter tags inject controlled clutter | Defeats unauthorized sensing | MEASURED |
| **Adversarial packet perturbation** | ACM MobiCom 2024 | Small in-spec packet perturbations degrade attacker model | Symmetric defense | MEASURED |
**The fundamental tradeoff (MEASURED, DySPAN 2026).** Perturbing precoding/
feedback that an attacker exploits also degrades legitimate beamforming gain —
*but the cost collapses at fine feedback resolution*:
| Randomization | Attacker error | Beamforming gain retained |
|---|---|---|
| none | 19% | 100% |
| moderate (p=0.3) | >50% | median >90% |
| maximum (p≥0.9) | ~73% (≈chance) | median ~58% |
At **high (3-bit) feedback resolution, privacy was "nearly indistinguishable
from the non-private baseline"** in link performance. This is the empirical basis
for VEIL's design choice (compliant fine-resolution feedback shaping — see
[03-countermeasure-design.md](03-countermeasure-design.md)).
---
## 5. Where VEIL sits
The literature has two families: **external** obfuscation (IRShield/ScatterShield
— extra hardware, perturbs the channel) and **transmitter-side** feedback/precoder
shaping (DP-Givens, MIMOCrypt — no extra hardware, perturbs your own report).
VEIL is in the second family and adds the missing property the others do not all
combine: a transform that is simultaneously **energy-preserving** (provably
compliant), **key-reversible** (throughput-preserving for the legitimate link),
and **session-fresh** (defeats cross-session re-identification), unified around
the Givens-rotation primitive the report already uses.
---
## Sources
- BFId — ACM CCS 2025: https://dl.acm.org/doi/10.1145/3719027.3765062 · KIT record: https://publikationen.bibliothek.kit.edu/1000185756
- LeakyBeam — NDSS 2025: https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/
- BFIAttack — arXiv 2604.04179: https://arxiv.org/html/2604.04179v1
- BeamSense — Computer Networks 2025: https://dl.acm.org/doi/10.1016/j.comnet.2024.111020 · arXiv 2303.09687: https://arxiv.org/pdf/2303.09687
- Wi-BFI — arXiv 2309.04408: https://arxiv.org/pdf/2309.04408
- SoK: Security Evaluation of Wi-Fi CSI Biometrics — arXiv 2511.11381: https://arxiv.org/pdf/2511.11381
- WiWho (IPSN 2016): https://dl.acm.org/doi/10.5555/2959355.2959359 · WiPIN — arXiv 1810.04106: https://arxiv.org/pdf/1810.04106
- Survey on Wi-Fi Sensing for Human Identity — MDPI Electronics 2023: https://www.mdpi.com/2079-9292/12/23/4858
- IEEE Std 802.11bf-2025: https://standards.ieee.org/ieee/802.11bf/11574/ · Overview — IEEE COMST 2024: https://ieeexplore.ieee.org/document/10547188/ · NIST: https://www.nist.gov/publications/ieee-80211bf-enabling-widespread-adoption-wi-fi-sensing
- 802.11bf privacy proposal withdrawal (802.11-23/0782), summarized: https://pascalpiron.substack.com/p/wifi-sensing-and-the-privacy-fix
- IRShield — IEEE S&P 2022 / arXiv 2112.01967: https://arxiv.org/abs/2112.01967 · https://ieeexplore.ieee.org/document/9833676/
- PhyCloak — USENIX NSDI 2016: https://www.usenix.org/conference/nsdi16/technical-sessions/presentation/qiao
- Protecting Human Activity Signatures in Compressed 802.11 CSI Feedback — DySPAN 2026 / arXiv 2512.18529: https://arxiv.org/abs/2512.18529
- MIMOCrypt — arXiv 2309.00250: https://arxiv.org/pdf/2309.00250 · WiShield — IEEE JSAC 2024: https://dl.acm.org/doi/abs/10.1109/JSAC.2024.3414597
- ScatterShield — ACM IMWUT 2025: https://dl.acm.org/doi/abs/10.1145/3770653
- Practical Adversarial Attack on WiFi Sensing — ACM MobiCom 2024: https://dx.doi.org/10.1145/3636534.3649367
- Privacy-Preserving Wi-Fi Data Generation via DP — INFOCOM 2025: https://www.eng.auburn.edu/~szm0001/papers/INFOCOM25.pdf
@@ -0,0 +1,94 @@
# 02 — Threat Model
VEIL protects a physical space (a room, a ward, a boardroom, a SCIF) from
*unauthorized* WiFi-based inference of **who is present** and **what they are
doing**, without denying the space its own working WiFi. This file states the
adversary classes, exactly what VEIL defends, and — just as importantly — what
it does **not**.
---
## 1. Assets
| Asset | Why it matters |
|---|---|
| **Identity linkage** | Re-identifying a specific person across time/sessions from their RF signature (BFId-class attack) |
| **Occupancy / presence** | Whether the space is occupied, and by how many (LeakyBeam-class, through-wall) |
| **Activity / motion** | Gait, gestures, keystrokes, respiration inferred from channel dynamics (BeamSense-class) |
| **Communication utility** | The legitimate WiFi link must keep working (≥95% throughput bar) |
---
## 2. Adversary classes
| Class | Position | Capability | In VEIL scope? |
|---|---|---|---|
| **A1 — external passive sniffer** | Outside the trust boundary (adjacent room, van, hallway), monitor mode | Captures plaintext BFI/CSI for every station; runs BFId/LeakyBeam/BeamSense offline | **Primary target — yes** |
| **A2 — external active sensor** | Nearby, transmits its own probing/sounding to solicit measurable responses | Elicits sensing responses; 802.11bf "active" mode | **Partial** — cadence randomization + non-response policy help; full defense needs MAC-layer policy |
| **A3 — associated but curious AP** | Inside the link; the party VEIL shares keys with | Sees the un-rotated report by construction | **Out of scope** — this is BFLD's detection/privacy-class problem (ADR-118/141) |
| **A4 — supply-chain / firmware** | Compromised radio firmware | Can bypass any transmit-side control | Out of scope (integrity problem, not a waveform problem) |
| **A5 — physical / RF-denial** | Wants to *block* WiFi | — | Explicitly rejected: VEIL never jams |
VEIL's design centers on **A1**, the attacker the literature demonstrates and
the one no shipping product addresses.
---
## 3. What VEIL guarantees (and the evidence class)
1. **Cross-session identity unlinkability against A1.** Because the fine-subspace
signature is rotated by a fresh secret orthogonal transform each session, an
A1 attacker cannot average captures back to a stable per-person template.
*Evidence: SYNTHETIC — re-ID collapses from 100% to ~chance in the reference
experiment (`cargo test`); real-silicon witness is future work.*
2. **Communication preservation.** The transform is key-reversible by the
legitimate receiver, and acts only on the identity-bearing fine subspace, so
link throughput stays ≥95%. *Evidence: SYNTHETIC model + MEASURED external
corroboration (DySPAN 2026: fine-resolution feedback shaping is near-free).*
3. **Compliance.** The transform is orthogonal ⇒ energy-preserving ⇒ adds no
interfering emission ⇒ not jamming. *Evidence: machine-checked energy ratio =
1.000000 in the `compliance` module; statutory analysis in
[04-compliance-and-regulatory.md](04-compliance-and-regulatory.md).*
---
## 4. What VEIL does NOT do (non-goals, stated to prevent over-claiming)
- **It does not hide identity from the associated AP (A3).** That party holds the
session key. Protecting against a malicious AP requires detection and policy
(BFLD), not waveform shaping.
- **It is not RF denial or jamming.** It never degrades another station's link.
- **It does not, by itself, defeat within-session motion detection.** A single
session's rotation is fixed, so coarse presence/motion may still be inferable
within one capture window; sounding-cadence randomization mitigates but does
not eliminate this. Identity *re-ID* (the brief's metric) is the guaranteed
target; motion obfuscation is partial and tracked as future work.
- **It is not a camera-grade or medical-grade claim in any direction.**
- **It is not validated on hardware yet.** All quantitative defense results are
SYNTHETIC until a captured boot/runtime log exists (CLAUDE.md hardware rule).
---
## 5. Trust boundary
```
┌────────────────────── protected space ──────────────────────┐
│ │
│ [person] [person] legitimate STA ⇄ AP (VEIL) │
│ │ │ │ shares session key │
│ └──── RF ──────┘ │ rotates fine subspace│
│ reflections ▼ of its own BFI │
│ compliant, key-reversible, │
│ energy-preserving emission │
└───────────────────────────────────────┬──────────────────────┘
│ plaintext BFI on air
A1 external passive sniffer (monitor mode)
sees a freshly-rotated signature each session
→ cannot build a stable per-person template
→ re-identification → chance
```
The key never crosses the boundary to A1. The AP inside the boundary is trusted
for key-sharing (A3 out of scope). No emission crosses the boundary with intent
or effect of interfering with another station (A5 rejected).
@@ -0,0 +1,136 @@
# 03 — Countermeasure Design
How VEIL prevents unauthorized sensing with compliant waveform controls, and how
the design maps to [`wifi-veil`](../../..).
---
## 1. The separable-subspace principle
A compressed beamforming report is not homogeneous. Two blocks carry different
information:
- **Dominant beam direction (comm block).** The coarse steering the AP uses to
aim data at the client. It varies with position and traffic and carries **no**
stable identity. **Throughput rides here.**
- **Fine cross-subcarrier phase structure (fine block).** The high-order
multipath detail. It is *stable per person* across sessions and is what
re-identification exploits (BFId). **Identity leaks here.** Communication
barely uses it.
The whole design rests on this: **identity leakage and data throughput live in
(mostly) separable subspaces.** A transform confined to the fine block can wreck
re-identification while sparing the beam the link depends on. This is consistent
with the DySPAN-2026 MEASURED result that shaping fine-resolution feedback is
nearly free in throughput.
---
## 2. The four compliant waveform controls
VEIL alters "channel sounding, phase, or beam schedules" — exactly the levers the
brief names — all within the 802.11 waveform envelope:
| Control | What it varies | Purpose |
|---|---|---|
| **Keyed precoder rotation** (primary) | A fresh secret orthogonal transform of the *fine* subspace each session, composed from extra Givens rotations | Destroys cross-session identity linkage; energy-preserving; key-reversible |
| **Feedback quantization / dither** | Sub-step noise on reported φ/ψ angles | Adds report-level uncertainty; tunes the privacythroughput point via `feedback_bits` |
| **Sounding-cadence randomization** | Jitter on NDP sounding intervals | Under-samples motion for an eavesdropper; charged as the throughput overhead |
| **MU-group / stream-mapping shuffle** | Which STAs are grouped, stream-to-antenna mapping | Rotates the spatial signature over time |
All four modify the node's **own** standards-conformant frames. None adds energy
on top of another station (see [04](04-compliance-and-regulatory.md)).
---
## 3. Why the keyed Givens rotation is the right primitive
The compressed beamforming report is *already* a product of Givens rotations
(the φ/ψ angles). VEIL composes **additional keyed Givens rotations** over the
fine block. This choice gives three properties at once:
1. **Orthogonal ⇒ energy-preserving.** A Givens rotation preserves the vector's
L2 norm exactly. Composing many still preserves it. So the emission carries
the same power it always would — **no added energy, no interference, not
jamming.** The `compliance` module checks this: energy ratio = 1.000000.
2. **Keyed & reversible ⇒ throughput-preserving.** The legitimate AP/STA shares
the per-session key, derives the identical rotation schedule, and applies the
inverse (negated angles, reversed order) to recover the true precoder. It pays
only the tiny residual from quantizing the extra angles at `feedback_bits`
resolution — negligible across the 802.11 59-bit range — plus the sounding
overhead. (The throughput-optimal resolution is derived in
[08-optimization.md](08-optimization.md).)
3. **Fresh per session ⇒ unlinkable.** A different rotation each session means an
A1 sniffer sees `R_e · signature` for a new random `R_e` every time. Averaging
over sessions (the natural enrollment attack) drives
`mean_e(R_e · signature) → 0` for *every* identity, so all templates collapse
toward the origin and become indistinguishable — re-identification → chance.
This is the marginalized-mutual-information argument: over unknown rotations,
the signature carries no stable discriminative information.
This is the shared-secret precoding idea (cf. MIMOCrypt) specialized to the
identity-bearing subspace and unified around the report's native primitive.
---
## 4. Detect-then-act
Per the brief ("detect sensing activity and alter…"), VEIL need not perturb
continuously. The `SensingDetector` exposes the decision rule: when the observed
rate of sensing/NDP solicitations crosses a threshold, the control plane
(ADR-280) engages the shield. Continuous operation is also valid; gating just
saves the (already small) overhead when no sensing is present.
---
## 5. Module map
| Concept above | Crate module | Key items |
|---|---|---|
| Deterministic, WASM-safe randomness + keys | `prng` | `Rng` (SplitMix64), `fnv1a_64`, `derive_key` |
| Givens algebra, energy conservation | `linalg` | `apply_givens`, `norm`, `dist_sq` |
| SYNTHETIC two-subspace BFI model | `identity` | `SceneConfig`, `Channel`, `BfiSample` (`comm()`/`fine()`) |
| The four controls (shield) | `protector` | `ShieldConfig`, `Protector::protect`/`recover`, `SensingDetector` |
| Passive re-ID adversary | `attacker` | `NearestCentroidAttacker`, `Metric` |
| Privacythroughput tradeoff | `throughput` | `LinkModel::throughput_ratio`, `beamforming_residual`, `feedback_airtime` |
| "Not jamming" audit | `compliance` | `ComplianceReport::audit`/`is_compliant` |
| Attacker-vs-protector head-to-head | `experiment` | `ExperimentConfig`, `run`, `ExperimentReport` |
| Config hyper-optimization | `optimize` | `hyper_optimize`, `min_givens_passes`, `pareto_frontier` |
| Byte-stable deterministic witness | `proof` | `Proof::EXPECTED_WITNESS`, `Proof::witness` |
---
## 6. The privacythroughput knobs (and which the optimizer turns)
- **`feedback_bits`:** the only knob with a genuine throughput tradeoff —
residual falls with bits, feedback airtime rises with them, so there is an
interior optimum (3 bits unconstrained; 5 bits within the 802.11-allowed set).
Privacy is unaffected by bits (the rotation is fresh regardless).
- **`givens_passes`:** the privacy/robustness knob. More mixing lowers re-ID at
**no throughput cost** (the keyed rotation is never signaled), so it trades
only compute. The optimizer finds the minimum for robust collapse and ships a
free 2× margin.
- **`sounding_overhead`:** a flat throughput cost from cadence randomization;
trades motion-obfuscation strength against airtime (outside the re-ID metric).
The `optimize` module turns these knobs deterministically — see
[08-optimization.md](08-optimization.md). It is what replaced the original
hand-picked config.
The `throughput` module computes the ratio from these, so the tradeoff is
inspectable rather than asserted (`cargo test throughput`).
---
## 7. Honest limitations of the model
- The two-subspace split is an abstraction; on real hardware comm and identity
information are only *approximately* separable, so the real throughput cost of
fully hiding identity may be higher than the model's ~2%. The DySPAN-2026
MEASURED curve is the external sanity check that it is *small* at fine
resolution, not zero.
- The nearest-centroid attacker is deliberately simple. The collapse argument is
classifier-independent (it is about the signal, not the model), but a hardware
study must confirm a strong learned attacker also collapses.
- Within-session motion is not addressed by the rotation alone (see threat
model §4).

Some files were not shown because too many files have changed in this diff Show More