diff --git a/docs/adr/ADR-290-veil-e2e-hardware-implementation-program.md b/docs/adr/ADR-290-veil-e2e-hardware-implementation-program.md new file mode 100644 index 00000000..6ea20a9e --- /dev/null +++ b/docs/adr/ADR-290-veil-e2e-hardware-implementation-program.md @@ -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 (L0–L5 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 2025–2026 +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. +``` diff --git a/docs/adr/README.md b/docs/adr/README.md index 4a426feb..284888ee 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -147,6 +147,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [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) | --- diff --git a/firmware/privshield/.gitignore b/firmware/privshield/.gitignore new file mode 100644 index 00000000..4c3b6bdc --- /dev/null +++ b/firmware/privshield/.gitignore @@ -0,0 +1,2 @@ +core/test_veil_shield +*.o diff --git a/firmware/privshield/README.md b/firmware/privshield/README.md new file mode 100644 index 00000000..0826f483 --- /dev/null +++ b/firmware/privshield/README.md @@ -0,0 +1,104 @@ +# VEIL privacy shield — end-to-end hardware implementation + +This tree is the **hardware/firmware realization** of the VEIL compliant-waveform +privacy shield (crate `wifi-densepose-privshield`, ADR-288; hardware program +ADR-290). It takes 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 ``. **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 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 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 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** (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`. diff --git a/firmware/privshield/core/Makefile b/firmware/privshield/core/Makefile new file mode 100644 index 00000000..c117129b --- /dev/null +++ b/firmware/privshield/core/Makefile @@ -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 diff --git a/firmware/privshield/core/test/test_veil_shield.c b/firmware/privshield/core/test/test_veil_shield.c new file mode 100644 index 00000000..a049d800 --- /dev/null +++ b/firmware/privshield/core/test/test_veil_shield.c @@ -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 +#include + +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; +} diff --git a/firmware/privshield/core/veil_shield.c b/firmware/privshield/core/veil_shield.c new file mode 100644 index 00000000..b018667c --- /dev/null +++ b/firmware/privshield/core/veil_shield.c @@ -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 + +/* 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); +} diff --git a/firmware/privshield/core/veil_shield.h b/firmware/privshield/core/veil_shield.h new file mode 100644 index 00000000..f97eeccf --- /dev/null +++ b/firmware/privshield/core/veil_shield.h @@ -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 (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 +#include + +#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 */ diff --git a/firmware/privshield/esp32/README.md b/firmware/privshield/esp32/README.md new file mode 100644 index 00000000..15513d81 --- /dev/null +++ b/firmware/privshield/esp32/README.md @@ -0,0 +1,130 @@ +# 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 +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 VEIL node": F (infeasible).** +The one waveform we need to touch is behind a blob with no hook. + +**Feasibility grade for "ESP32 as a VEIL supporting device": B (feasible, +build-only).** Three legitimate roles below, best-first. + +--- + +## What the ESP32 can and cannot do + +| Capability | ESP-IDF surface | 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 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* 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): + +- ESP-IDF Wi-Fi CSI (Vendor Features — `esp_wifi_set_csi*`, promiscuous CSI): + +- ESP32-C6 beamforming-feedback limitations (IDFGH-15163): + +- Closed Wi-Fi PHY blob (`esp-phy-lib`, object-only, NDA): + +- ESP32 Wi-Fi binary-blob reverse-engineering context (why the PHY is not modifiable): + +- Raw 802.11 TX capability/limits reference (`esp32-80211-tx`): + +- PrivISAC — RIS-based privacy-preserving ISAC (sensing vs. comm direction): + +- Wi-BFI — beamforming-feedback extraction (why unprotected BF reports leak): + diff --git a/firmware/privshield/esp32/veil_ris_controller/CMakeLists.txt b/firmware/privshield/esp32/veil_ris_controller/CMakeLists.txt new file mode 100644 index 00000000..7f087900 --- /dev/null +++ b/firmware/privshield/esp32/veil_ris_controller/CMakeLists.txt @@ -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 +) diff --git a/firmware/privshield/esp32/veil_ris_controller/include/veil_ris_controller.h b/firmware/privshield/esp32/veil_ris_controller/include/veil_ris_controller.h new file mode 100644 index 00000000..ef7f33c0 --- /dev/null +++ b/firmware/privshield/esp32/veil_ris_controller/include/veil_ris_controller.h @@ -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 +#include +#include +#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 */ diff --git a/firmware/privshield/esp32/veil_ris_controller/veil_ris_controller.c b/firmware/privshield/esp32/veil_ris_controller/veil_ris_controller.c new file mode 100644 index 00000000..0a57064f --- /dev/null +++ b/firmware/privshield/esp32/veil_ris_controller/veil_ris_controller.c @@ -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 + +#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, 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; } diff --git a/firmware/privshield/esp32/veil_sensing_detector/CMakeLists.txt b/firmware/privshield/esp32/veil_sensing_detector/CMakeLists.txt new file mode 100644 index 00000000..b3cc18b5 --- /dev/null +++ b/firmware/privshield/esp32/veil_sensing_detector/CMakeLists.txt @@ -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 esp_mqtt +) diff --git a/firmware/privshield/esp32/veil_sensing_detector/include/veil_sensing_detector.h b/firmware/privshield/esp32/veil_sensing_detector/include/veil_sensing_detector.h new file mode 100644 index 00000000..c66d17fa --- /dev/null +++ b/firmware/privshield/esp32/veil_sensing_detector/include/veil_sensing_detector.h @@ -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 +#include +#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 */ diff --git a/firmware/privshield/esp32/veil_sensing_detector/veil_sensing_detector.c b/firmware/privshield/esp32/veil_sensing_detector/veil_sensing_detector.c new file mode 100644 index 00000000..6d86ccd3 --- /dev/null +++ b/firmware/privshield/esp32/veil_sensing_detector/veil_sensing_detector.c @@ -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 + +#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", + 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; } diff --git a/firmware/privshield/nexmon/BUILD.md b/firmware/privshield/nexmon/BUILD.md new file mode 100644 index 00000000..7a22902b --- /dev/null +++ b/firmware/privshield/nexmon/BUILD.md @@ -0,0 +1,116 @@ +# Building the 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////`. 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 `` +(`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 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 -b -l8 -v +``` + +**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). diff --git a/firmware/privshield/nexmon/README.md b/firmware/privshield/nexmon/README.md new file mode 100644 index 00000000..5e90e42b --- /dev/null +++ b/firmware/privshield/nexmon/README.md @@ -0,0 +1,124 @@ +# VEIL protector — Nexmon (Broadcom/Cypress) path + +C-firmware-patch adapter that would call the portable 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 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 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 — +- `nexmon_csi` (chips: bcm4339, bcm43455c0, bcm4358, bcm4366c0) — + +- Wi-BFI (reads BFAs/BFI from captured compressed-beamforming action frames) — + , paper arXiv:2309.04408 + +- BCM43455c0 patches / D11 headers (`d11.h`) — + +- D11 real-time core / ucode reverse engineering (SEEMOO, Quarkslab) — + , + +- 802.11ac VHT NDP sounding & compressed beamforming report structure (context) — + + +> 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. diff --git a/firmware/privshield/nexmon/patch/veil_patch.c b/firmware/privshield/nexmon/patch/veil_patch.c new file mode 100644 index 00000000..cf268341 --- /dev/null +++ b/firmware/privshield/nexmon/patch/veil_patch.c @@ -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 /* FW_VER_7_45_189, CHIP_VER_BCM43455c0 (Nexmon) */ +#include /* BPatch / GPatch / __attribute__((at(...))) */ +#include /* struct sk_buff, struct wlc_info, etc. */ +#include /* 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 (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. + * --------------------------------------------------------------------------- + */ diff --git a/firmware/privshield/openwifi/HDL_NOTES.md b/firmware/privshield/openwifi/HDL_NOTES.md new file mode 100644 index 00000000..87ec0389 --- /dev/null +++ b/firmware/privshield/openwifi/HDL_NOTES.md @@ -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 diff --git a/firmware/privshield/openwifi/MEASUREMENT.md b/firmware/privshield/openwifi/MEASUREMENT.md new file mode 100644 index 00000000..ad360c5c --- /dev/null +++ b/firmware/privshield/openwifi/MEASUREMENT.md @@ -0,0 +1,103 @@ +# P5 measurement protocol — openwifi 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 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**: 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 diff --git a/firmware/privshield/openwifi/README.md b/firmware/privshield/openwifi/README.md new file mode 100644 index 00000000..1d06c997 --- /dev/null +++ b/firmware/privshield/openwifi/README.md @@ -0,0 +1,122 @@ +# 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 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 diff --git a/firmware/privshield/openwifi/veil_openwifi.c b/firmware/privshield/openwifi/veil_openwifi.c new file mode 100644 index 00000000..012d5def --- /dev/null +++ b/firmware/privshield/openwifi/veil_openwifi.c @@ -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 +#include +#include +#include + +/* ------------------------------------------------------------------------- + * 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 +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 +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 diff --git a/firmware/privshield/openwrt/INTEGRATION.md b/firmware/privshield/openwrt/INTEGRATION.md new file mode 100644 index 00000000..1941df4e --- /dev/null +++ b/firmware/privshield/openwrt/INTEGRATION.md @@ -0,0 +1,95 @@ +# 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/`): + `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 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 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) | diff --git a/firmware/privshield/openwrt/Makefile b/firmware/privshield/openwrt/Makefile new file mode 100644 index 00000000..9a8155d4 --- /dev/null +++ b/firmware/privshield/openwrt/Makefile @@ -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 diff --git a/firmware/privshield/openwrt/README.md b/firmware/privshield/openwrt/README.md new file mode 100644 index 00000000..6d33a4f3 --- /dev/null +++ b/firmware/privshield/openwrt/README.md @@ -0,0 +1,112 @@ +# 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 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.** 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 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 + +| 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 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): +- hostapd beamforming/sounding knobs (`[SU-BEAMFORMER]`, `[MU-BEAMFORMER]`, + `[SOUNDING-DIMENSION-4]`, `he_su_beamformer`): + and + +- mt76 beamforming lives in firmware (mt7622/mt7615 performance/beamforming + discussion): +- Qualcomm firmware closedness (ath11k/ath12k no open firmware; regulatory + + features firmware-enforced): ath10k mailing-list thread + + and CodeLinaro ath firmware +- ath11k reports VHT beamformee spatial streams *from firmware*: + diff --git a/firmware/privshield/openwrt/openwrt.mk b/firmware/privshield/openwrt/openwrt.mk new file mode 100644 index 00000000..5b073ecb --- /dev/null +++ b/firmware/privshield/openwrt/openwrt.mk @@ -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 . +endef + +$(eval $(call BuildPackage,veil-shieldd)) diff --git a/firmware/privshield/openwrt/veil_shieldd.c b/firmware/privshield/openwrt/veil_shieldd.c new file mode 100644 index 00000000..9de40efe --- /dev/null +++ b/firmware/privshield/openwrt/veil_shieldd.c @@ -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 +#include +#include +#include +#include +#include +#include + +/* 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 +#include +#include +#include + +#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/): 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 [-k ] [-p ]\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; +}