feat: add authenticated ESP32 BLE fusion path

This commit is contained in:
rUv
2026-08-23 20:45:37 -04:00
parent 0df48df7b2
commit 87ce7bdd5f
34 changed files with 8318 additions and 32 deletions

View File

@@ -0,0 +1,394 @@
# ADR-341: Authenticated BLE anchors and external Channel Sounding fusion
- **Status**: Accepted for implementation, hardware validation pending
- **Date**: 2026-08-23
- **Deciders**: ruv
- **Tags**: BLE, ESP32-S3, Channel Sounding, identity, sensor fusion, privacy
## Context
RuView needs a low-cost way to add short-horizon identity evidence and
micromotion research inputs to ESP32-S3 CSI nodes. Three premises require
correction before choosing the architecture:
1. ESP32-S3 can scan ordinary BLE advertisements and RSSI, but the supported
ESP-IDF interface does not expose raw Bluetooth CTE IQ samples. Firmware
cannot turn the S3 into a coherent CTE radar by configuration.
2. An iPhone's background BLE traffic is neither a stable identifier nor a
RuView authentication token. Private addresses rotate, RSSI is not position,
and a phone on a table is not proof of which body produced a CSI signal.
3. ESP32-S3 cannot acquire Bluetooth 6 Channel Sounding phase and RTT. Those
primitives require a capable companion radio. Monotonic timestamps from two
independent chips cannot be compared without synchronization.
This ADR adds two strictly separated paths:
1. A bounded-duty, passive S3 scanner for a RuView-specific authenticated,
rotating BLE service token. It emits only a pseudonym, RSSI, TTL and evidence
quality. It emits no BLE MAC, civil identity, raw advertisement or vital
sign.
2. A default-off UART ingress for a separate Bluetooth 6 Channel
Sounding-capable radio. The companion sends calibrated phase and timing
primitives in an authenticated fixed frame. The S3 validates and forwards
primitives. Host fusion may estimate respiration but must abstain under
motion, incoherence, expiry or cross-source conflict.
All simulator output is labelled **SYNTHETIC**. No hardware or clinical
performance is asserted by this implementation.
## Decision
### BLE identity scanning
`CONFIG_BLE_IDENTITY_SCAN_ENABLE` is compile-time default off. A compiled image
also requires `ble_enable=1`, `ble_key_id`, and an exact 32-byte `ble_secret` in
NVS. Missing key material fails closed.
The scanner uses the ESP-IDF NimBLE passive extended-scan event path. The
default 50 ms window per 1000 ms interval is 5 percent controller scan duty.
Firmware rejects a configuration above 25 percent. It does not request scan
responses, keep BLE addresses, or accept general phone advertisements.
Controller duplicate suppression is disabled because a long-running scan must
observe repeated authenticated tokens to refresh the three-second evidence
TTL. The bounded scan window limits receive work, and unauthenticated payloads
are discarded before telemetry emission.
ESP-IDF 5.4 gates the extended discovery event structure behind extended
advertising support and otherwise defaults the NimBLE transport event buffer
to 70 bytes. The scanner therefore additionally requires
`CONFIG_BT_NIMBLE_EXT_ADV=y` and
`CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE=257`. It rejects NimBLE reports
whose data status is incomplete or truncated, so a partial authenticated field
can never be parsed as a complete token. RuView advertisers keep the complete
advertising payload at or below 200 bytes; the canonical token-only payload is
52 bytes including its AD length and type octets.
Operators compare CSI packet yield with BLE off and on; a regression beyond the
deployment budget is a rollback condition.
The advertiser carries the vendor UUID
`6f31a840-5d65-4d69-9f09-c511b1e00100`. UUID bytes appear little endian on the
air. The 50-byte service record requires extended advertising; it cannot fit in
either a legacy 31-byte advertisement or its 31-byte scan response.
#### BLE service token v1
The AD element uses type `0x21`, Service Data with 128-bit UUID. The AD length
and type bytes precede this payload and are not included below.
| Payload offset | Size | Field | Validation |
|---:|---:|---|---|
| 0 | 16 | RuView service UUID, little endian | Exact match |
| 16 | 1 | token version | Must be 1 |
| 17 | 1 | key id | Must match provisioned key selector |
| 18 | 4 | Unix epoch minute, little endian | Host and scanner freshness window |
| 22 | 4 | advertiser nonce, little endian | Covered by HMAC, not forwarded |
| 26 | 8 | rotating pseudonym | Not a BLE MAC or civil identity |
| 34 | 16 | HMAC-SHA256 tag, first 128 bits | Constant-time comparison |
The HMAC covers the raw 16 UUID bytes plus offsets 16 through 33. A deployment
rotates the pseudonym at least once per token epoch and uses separate keys from
the Channel Sounding companion. A shared scanner key authenticates membership
in the provisioned deployment; it does not prevent relay and a compromised
scanner can forge advertiser tokens.
#### BLE telemetry v1
The scanner forwards a fixed 36-byte little-endian record with magic
`0xC51100B1`:
| Offset | Size | Field | Host mapping |
|---:|---:|---|---|
| 0 | 4 | magic | rvCSI packet discriminator |
| 4 | 1 | version | `source_contract.version = 1` |
| 5 | 1 | gateway node id | authenticated source id after ADR-305 verification |
| 6 | 1 | flags | bit 0 authenticated token, bit 1 scanner time verified, bit 2 extended advert |
| 7 | 1 | key id | `identity.key_id` |
| 8 | 4 | sequence | short-horizon gateway replay guard |
| 12 | 4 | observed boot ms | diagnostic only, not Unix time |
| 16 | 2 | TTL ms | `identity.ttl_ms`, maximum 5000 |
| 18 | 2 | quality permille | `identity.confidence`, evidence quality rather than identity probability |
| 20 | 1 | RSSI dBm | `identity.rssi_dbm` |
| 21 | 1 | TX power dBm | `identity.tx_power_dbm`, 127 means unavailable |
| 22 | 2 | reserved | Must be zero |
| 24 | 8 | rotating pseudonym | `identity.pseudonymous_token` |
| 32 | 4 | token epoch minute | host freshness recheck |
rvCSI maps the record to capability fields
`source_capability = { ble_scan: true, cte_iq: false, channel_sounding: false,
identity_kind: rotating_pseudonym }`. It must not map the token to a person
name, account, phone MAC or stable device identifier. The BLE telemetry no
longer contains the advertiser HMAC. It is accepted only inside the
authenticated gateway envelope defined below. The host verifies the envelope
before parsing the inner record and requires its gateway node id to match the
telemetry node id. TTL lifetimes are half open: evidence is live at host time
`t` only when `received_at <= t < received_at + ttl`.
### Authenticated gateway envelope
BLE telemetry and Channel Sounding primitives share the existing UDP data
plane, so neither is sent as a bare record. A bounded sender task wraps each
sanitized payload in this variable-length little-endian envelope. BLE and UART
callbacks use a nonblocking queue and never perform UDP I/O directly.
| Offset | Size | Field | Validation |
|---:|---:|---|---|
| 0 | 4 | magic `RVAE`, numeric `0x45415652` | Exact match |
| 4 | 1 | version | Must be 1 |
| 5 | 1 | payload type | 1 is BLE telemetry, 2 is Channel Sounding |
| 6 | 1 | flags | bit 0 means gateway monotonic receive time; other bits rejected |
| 7 | 1 | gateway key id | Exact enrolled selector |
| 8 | 2 | total frame length | Must equal datagram length |
| 10 | 2 | payload length | Exactly 36 for type 1 or 72 for type 2 |
| 12 | 1 | gateway node id | Exact enrolled node when configured |
| 13 | 3 | reserved | Must be zero |
| 16 | 4 | gateway sequence | Nonzero and strictly newer within the boot session |
| 20 | 8 | random gateway boot nonce | Nonzero authenticated replay namespace |
| 28 | 8 | gateway receive time in boot microseconds | Kept distinct from host receipt time |
| 36 | 4 | receive timing uncertainty in microseconds | Policy bounded on the host |
| 40 | N | sanitized payload | Exact type-specific size |
| 40 plus N | 16 | HMAC-SHA256 tag, first 128 bits | Constant-time comparison |
The tag covers the 12-byte domain `RuView/GW/v1` followed by offsets 0 through
`39 + N`. A third, independent 32-byte `radio_secret` protects this boundary.
The random boot nonce makes a legitimate gateway sequence reset explicit. The
host keys replay state by gateway node, key id and boot nonce, and separately
retains companion source session state. Host receipt time is never substituted
for the authenticated gateway receive time.
The default queue holds 16 records. The scanner admits at most 40 parsed RuView
tokens per second before advertiser HMAC work, and UART admits at most 100 valid
frames per second before companion HMAC work. Queue overflow drops evidence and
increments a rate-limited counter. Radio evidence uses normal UDP backpressure,
not the small low-rate priority control path.
This symmetric envelope supplies online source authentication and integrity.
It does not provide ADR-305 nonrepudiation and does not encrypt the payload.
Production evidence chains still add the ADR-305 signature or authenticated
transport witness. Deployments that treat rotating pseudonyms or phase samples
as confidential use WireGuard, DTLS, or an equivalent encrypted network path.
### External Channel Sounding companion
`CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE` is compile-time default off and limited
to ESP32-S3 in this implementation. NVS additionally requires `cs_enable=1`,
`cs_key_id`, a distinct exact 32-byte `cs_secret`, and an exact nonzero enrolled
`cs_source_id`. UART2 is the default to avoid the existing UART1 mmWave probe;
UART0 is rejected because it carries console and provisioning traffic. Pin
selection must be checked against the actual board before enabling.
The contract uses `sample_age_us`, not the companion monotonic clock. On receipt,
the gateway assigns its own monotonic time and approximates capture time as
`receive_time - sample_age_us`. `timing_uncertainty_us` remains attached to the
measurement. This does not claim synchronized clocks.
#### Authenticated Channel Sounding frame v1
The fixed 72-byte little-endian layout is:
| Offset | Size | Field | Validation and host mapping |
|---:|---:|---|---|
| 0 | 4 | magic `RVCS`, numeric `0x53435652` | packet discriminator |
| 4 | 1 | version | Must be 1 |
| 5 | 1 | flags | bit 0 calibrated, bit 1 gross motion; other bits rejected |
| 6 | 1 | key id | Must match separate companion key |
| 7 | 1 | reserved | Must be zero |
| 8 | 2 | frame length | Must be 72 |
| 10 | 2 | Bluetooth RF channel index | 0 through 78 |
| 12 | 4 | source sequence | strictly newer per source, wrap aware |
| 16 | 4 | sample age microseconds | maximum configured age, default 2 seconds |
| 20 | 4 | opaque companion source id | nonzero, provisioned capability join |
| 24 | 2 | quality permille | 0 through 1000, minimum default 600 |
| 26 | 2 | timing uncertainty microseconds | maximum 10000 |
| 28 | 4 | signed phase milliradians | minus 3142 through 3142 |
| 32 | 4 | signed RTT picoseconds | 0 through 250000 |
| 36 | 4 | signed frequency offset Hz | minus 500000 through 500000 |
| 40 | 4 | companion source session id | Nonzero authenticated boot or rekey namespace |
| 44 | 4 | Channel Sounding procedure id | Nonzero grouping key |
| 48 | 2 | procedure step index | Less than step count |
| 50 | 2 | procedure step count | 4 through 79 |
| 52 | 16 | HMAC-SHA256 tag, first 128 bits | constant-time comparison |
| 68 | 4 | IEEE CRC32 over offsets 0 through 67 | framing corruption check |
The HMAC input is the 12-byte domain string `RuView/CS/v1` followed by frame
offsets 0 through 51. Domain separation prevents a valid BLE token or another
protocol object from being reused as a companion measurement. CRC is not an
authenticator; it allows cheap corruption rejection before HMAC. The host maps
the source to
`source_capability = { ble_scan: false, cte_iq: false,
channel_sounding: true, phase: calibrated_flag, rtt: true }` only after HMAC,
freshness, sequence and capability enrollment checks pass.
The companion sends primitives only. A procedure is a coherent group of steps,
not proof that every advertised step was observed. Host estimators require
an exact complete set of step indexes, unique channels, and consistent step
counts. A valid procedure contains 4 through 79 steps because Bluetooth RF
channel indexes are limited to 0 through 78. Estimators reject mixed source
sessions, mixed gateway boot scopes, duplicate channels, duplicate steps,
incomplete procedures, or inconsistent metadata. The companion does not send
`respiration_bpm`, `heart_rate_bpm`, identity or clinical labels.
### Host fusion and deterministic replay
`ruview-fusion::radio_fusion` repeats all Channel Sounding bounds, CRC and HMAC
checks. It assigns gateway receive time, retains timing uncertainty, circularly
centres calibrated phase, and estimates a nonclinical respiratory component
only from complete coherent procedures within one enrolled source session and
gateway boot scope. It abstains under gross motion, insufficient duration,
incomplete procedures, source mixing or weak spectral concentration.
The host must pass parsed BLE and Channel Sounding records through the bounded
`RadioReplayGuard` before fusion. Gateway and BLE sequences are strictly newer;
only the companion Channel Sounding sequence uses serial-number wrap semantics.
All maps are bounded. A companion sequence reset requires a new authenticated
nonzero source session id. A gateway sequence reset requires a new authenticated
random boot nonce.
The sensing server snapshots replay high-water marks in versioned private JSON.
Raw eight-byte BLE pseudonyms are replaced by one-way replay fingerprints before
serialization. A separate private lock file gives the runtime exclusive
ownership. Missing replay state fails closed unless the operator passes the
explicit one-shot initialization option; the runtime rejects that option once
a snapshot exists, so it must be removed after creation. Deletion of an
established snapshot requires rotation of all enrolled keys. Secret and replay
files are opened once without following symbolic links and validated from their
file descriptors.
HMAC, estimation, and storage run in a dedicated ordered worker behind a bounded
256-record queue. The worker group commits at most once per second or every 256
records. The shared UDP loop admits only the exact 92-byte BLE and 128-byte
Channel Sounding RVAE envelopes before allocation and queueing. The worker
releases no update until the replay snapshot is durable. P4 and P5 WebSocket
export is closed by default. The local override is not a subject consent receipt
and is accepted only with an authenticated loopback server and a private
append-only audit log. A newly created audit entry and its parent directory are
synced before export is enabled. Opened file identity and canonical-path checks
prevent the audit log from aliasing replay state, its lock, or any gateway,
pseudonym, or companion secret. An incomplete audit tail, audit write failure,
or audit sync failure stops the worker and disconnects its queue. Exact P0 phase,
RTT, frequency offset, channel, and step vectors never enter the WebSocket
message. Aggregate decisions expire five seconds after host receipt.
The live host boundary supports multiple independently keyed gateways. Its
current scope ends at authenticated BLE and Channel Sounding admission plus
standalone aggregate publication. CSI track association and CSI plus Channel
Sounding rate fusion exist in the deterministic library simulation but are not
wired into the production sensing-server track manager by this ADR. That
follow-on cannot be represented as live or measured until its own integration
and hardware gate passes. TTLs are half open, so evidence is expired at the
exact expiry timestamp.
CSI and Channel Sounding respiration estimates combine only when live and
within the configured disagreement threshold. BLE association receives
geometry-derived track likelihoods from an upstream localizer. RSSI alone is
not treated as coordinates. Associations are one-to-one, TTL bounded and
fail closed on ambiguity or an incompatible duplicate pseudonym.
The built-in deterministic replay includes two tracks that approach, overlap
and cross. It binds both before the crossing, abstains at exact overlap, and
rebinds each rotating token to its original privacy-preserving track afterward.
Separate cases verify spoof conflict, TTL expiry, Channel Sounding motion
abstention and CSI plus Channel Sounding rate fusion. Every output is marked
`SYNTHETIC`; it is not hardware evidence.
## Security and privacy analysis
BLE, Channel Sounding and gateway-envelope tags truncate HMAC-SHA256 to 128 bits. Under a
uniform forgery model, an attacker making `q` independent online attempts has
success probability at most approximately `q / 2^128`. Even one billion
attempts gives approximately `2.9 × 10^-30`. The practical risks are therefore
key extraction, shared-key blast radius, token relay, compromised firmware and
mis-enrollment rather than blind tag guessing.
Controls are:
1. Separate BLE, companion and gateway-envelope keys with explicit key ids and
protocol domain separation.
2. Exact 32-byte secrets provisioned from files that are never printed or
persisted in the local provisioning state JSON.
3. Secure boot, flash encryption and NVS encryption for production devices.
4. Short token epochs, host freshness validation, packet TTL and sequence
replay checks.
5. No BLE address or raw advertisement egress. Only a rotating eight-byte
pseudonym leaves the scanner.
6. Geometry consistency and fail-closed abstention for relay or crossing
ambiguity. HMAC authenticates a token but cannot prove physical proximity.
7. Authenticated gateway envelopes before accepting either inner payload. A LAN
source address or an inner packet flag is not sufficient.
8. Bounded queues, ingress rates, exact pre-allocation payload sizes and replay
maps. Invalid input cannot allocate unbounded state or block a radio callback.
9. Encrypted transport when LAN disclosure of pseudonyms or biological phase
primitives is outside the deployment threat model.
10. Multiple gateway secrets are unique; the host selects an enrollment by the
unauthenticated node and key selectors, then authenticates the complete
envelope before accepting either selector as provenance.
11. Session capacity remains fail closed. Retiring a gateway boot or companion
session is an administrative key-rotation operation because automatic
eviction could make an old authenticated capture replayable.
The eight-byte rotating pseudonym has 64 bits of collision space. At 10000
simultaneous tokens, random collision probability is approximately
`2.7 × 10^-12` per epoch. Collision detection still abstains because a collision
could also be an intentional conflict.
## Consequences
The incremental firmware cost is BLE controller/host memory only when the BLE
option is compiled. Default builds retain current behavior. BLE radio use may
reduce CSI yield because WiFi and BLE share 2.4 GHz silicon. The default scan
duty is 5 percent, with a hard 25 percent ceiling, but real coexistence cost is
hardware and traffic dependent.
The architecture gains identity evidence, not absolute identity. A phone or
beacon must run the RuView token protocol; ordinary iPhone background traffic
does not qualify. It gains a path for Bluetooth 6 phase measurements, not
Bluetooth 6 capability on ESP32-S3.
## Rollback
Set `ble_enable=0` and `cs_enable=0`, or build with both Kconfig options off.
No existing packet magic, CSI path or vitals packet changes. Remove all three
provisioned secrets during decommissioning. Roll back BLE if measured CSI packet
yield or downstream presence quality breaches the deployment baseline.
## Validation
1. Host C tests parse malformed BLE AD elements, confirm privacy-minimized
telemetry, validate Channel Sounding CRC, bounds, sample age and wrap-aware
sequence handling.
2. Provisioning tests confirm separate BLE, Channel Sounding and gateway
envelope secrets become NVS blobs, never enter the additive local state, and
cannot leak into a fallback CSV.
3. Rust tests verify both HMAC layers, tamper rejection, wrong-key rejection,
gateway boot replay, companion session replay, expiry, two crossing tracks,
spoof conflict, motion abstention and synthetic respiration fusion.
4. ESP-IDF 5.4.2 clean builds passed with both features disabled and with BLE
identity plus UART companion ingress enabled. The enabled image is 1,327,888
bytes with 37 percent of the smallest application partition free. The
default image is 1,139,536 bytes with 46 percent free. The feature cost is
188,352 bytes. These are compile and link receipts, not hardware receipts.
5. Host tests verify one-shot replay initialization, exclusive locking,
descriptor-based private file checks, two independently keyed gateways,
exact pre-copy RVAE sizes, audit path isolation, fatal audit failure, and
commit plus audit before P5 publication.
6. Real hardware acceptance requires captured ESP32-S3 boot/runtime logs,
measured CSI yield before and after BLE enablement, and a capable Bluetooth
6 companion capture. Until then the implementation is **CLAIMED** and all
replay results are **SYNTHETIC**.
## Primary references
1. [Bluetooth SIG Channel Sounding overview](https://www.bluetooth.com/learn-about-bluetooth/feature-enhancements/channel-sounding/)
2. [Bluetooth Core Specification 6.0 feature overview](https://www.bluetooth.com/core-specification-6-feature-overview/)
3. [Bluetooth Low Energy primer](https://www.bluetooth.com/bluetooth-le-primer/)
4. [ESP32-S3 Bluetooth Low Energy feature support](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-guides/ble/ble-feature-support-status.html)
5. [ESP32-S3 NimBLE device discovery guide](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-guides/ble/get-started/ble-device-discovery.html)
## Acceptance test
On two ESP32-S3 gateways plus one enrolled Channel Sounding companion, replay
two rotating BLE tokens through a physical track crossing. Pass only if both
tokens bind before and after, exact overlap produces abstention, stale and
forged tokens produce no binding, motion suppresses respiration within one
fusion cycle, and BLE enablement keeps measured CSI packet yield within the
operator's predeclared regression budget. Record boot logs and raw counters;
the deterministic simulator alone cannot pass this hardware gate.

View File

@@ -203,6 +203,9 @@ All packets are sent over UDP to the configured aggregator. The magic number in
| `0xC5110001` | CSI Frame (ADR-018) | ~20 Hz | Variable | Raw I/Q per subcarrier per antenna |
| `0xC5110002` | Vitals Packet | 1 Hz | 32 bytes | Presence, breathing BPM, heart rate, fall flag, occupancy |
| `0xC5110004` | WASM Output | Event-driven | Variable | Custom events from WASM modules (u8 type + f32 value) |
| `RVAE` (`0x45415652`) | Authenticated radio envelope v1 | Bounded worker | 92 or 128 bytes | Gateway-authenticated wrapper sent over UDP |
| `0xC51100B1` | BLE Identity Evidence v1 | Bounded passive scan | 36 bytes inner payload | Rotating pseudonym, RSSI, TTL and evidence quality; never sent bare |
| `RVCS` (`0x53435652`) | External Channel Sounding v1 | Companion-defined | 72 bytes inner payload | HMAC-authenticated phase and RTT primitives; never sent bare |
### ADR-018 Binary Frame Format
@@ -238,6 +241,92 @@ Offset Size Field
28 4 Reserved
```
### Optional BLE and Bluetooth 6 companion path (ADR-341)
Both paths are disabled by default. ESP32-S3 can scan ordinary BLE advertising
metadata and RSSI, but this firmware does **not** claim that the S3 exposes raw
CTE IQ or native Bluetooth 6 Channel Sounding.
The BLE path accepts only the RuView vendor service token authenticated with a
provisioned 32-byte HMAC key. It discards the advertiser address and raw packet,
then forwards a rotating eight-byte pseudonym with an explicit TTL. Ordinary
iPhone background advertisements do not satisfy this contract and are not an
identity source.
The Channel Sounding path uses a separate capable radio on UART2. Its fixed v1
frame carries sample age, timing uncertainty, phase, RTT, frequency offset,
quality, source session, procedure metadata, sequence, a domain-separated
128-bit HMAC tag and CRC32. The S3 validates these primitives. Both radio paths
then enter a bounded queue and a second HMAC-protected gateway envelope carrying
the node, random boot nonce, gateway sequence and receive time. Respiration
inference and motion abstention happen on the host. See
[`ADR-341`](../../docs/adr/ADR-341-authenticated-ble-and-channel-sounding-fusion.md)
for exact layouts and the rvCSI mapping.
To compile the BLE scanner, first enable ESP-IDF Bluetooth, NimBLE, the observer
role, `CONFIG_BT_NIMBLE_EXT_SCAN=y`, `CONFIG_BT_NIMBLE_EXT_ADV=y`, and
`CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE=257`, then set
`CONFIG_BLE_IDENTITY_SCAN_ENABLE=y`. The 50-byte token requires extended
advertising and cannot fit in a legacy advertisement or scan response. The
advertiser should keep its complete advertising data at or below 200 bytes;
incomplete or truncated reports are rejected rather than authenticating a
fragment. The
default scan window is 50 ms per 1000 ms, or 5 percent duty. Firmware refuses
settings above 25 percent. To compile the companion ingress on ESP32-S3, set
`CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE=y` and verify the UART and GPIO choices
against the specific board.
Runtime activation requires separate secrets and remains fail closed:
```bash
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20 \
--ble-identity-enable 1 --ble-key-id 7 --ble-secret-file ble-key.bin \
--cs-ingress-enable 1 --cs-key-id 9 --cs-source-id 270544960 \
--cs-secret-file cs-key.bin --radio-envelope-key-id 12 \
--radio-envelope-secret-file gateway-key.bin
```
Each of the three independent key files contains exactly 32 raw bytes or 64
hexadecimal characters. Key
contents are written to NVS but are never printed or persisted in the local
additive provisioning-state JSON. Production devices also require secure boot,
flash encryption and NVS encryption. Re-supply the secret files on every later
provisioning run while either feature remains enabled. Provisioning fails closed
instead of writing a fallback CSV when any secret is present.
The sensing server requires a fourth independent 32-byte host pseudonym key.
On the first boot only, explicitly create the replay snapshot:
```bash
RUVIEW_API_TOKEN="replace-with-a-long-local-token" \
cargo run -p wifi-densepose-sensing-server -- --source auto \
--radio-gateway-node-id 7 --radio-gateway-key-id 12 \
--radio-gateway-secret-file gateway-key.bin \
--radio-host-pseudonym-secret-file host-pseudonym-key.bin \
--radio-replay-state data/radio-replay-v2.json \
--radio-initialize-replay-state \
--radio-cs-key-id 9 --radio-cs-source-id 270544960 \
--radio-cs-secret-file cs-key.bin
```
Omit `--radio-initialize-replay-state` on every subsequent boot. If an
established replay snapshot is lost, rotate all gateway, advertiser, and
companion keys before creating a replacement. Add independent gateways with a
repeatable `--radio-gateway NODE,KEY,SECRET_PATH` argument. P4 respiration and
P5 pseudonymous anchor WebSocket exports remain closed by default. Their local
overrides require loopback binding, configured bearer or OAuth authentication,
and a private audit log. The override is deployment authorization, not a
subject consent receipt.
The gateway envelope authenticates integrity and source but does not encrypt
UDP. Use WireGuard, DTLS, or an equivalent confidential transport if observers
on the LAN must not see rotating pseudonyms or Channel Sounding primitives.
The included C and Rust replays are **SYNTHETIC**. They are not evidence that a
specific board, companion, room, respiration rate or identity-association
accuracy has been validated.
---
## Building

View File

@@ -3,6 +3,13 @@ set(SRCS
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
# ADR-341 — privacy-minimized BLE anchors + external BT6 CS ingress
"ble_identity_protocol.c"
"ble_identity.c"
"channel_sounding_protocol.c"
"channel_sounding_ingress.c"
"radio_gateway_protocol.c"
"radio_gateway_sender.c"
"swarm_bridge.c"
# ADR-081 — adaptive CSI mesh firmware kernel
"rv_radio_ops_esp32.c"
@@ -38,6 +45,10 @@ set(REQUIRES
driver
lwip
mbedtls
# The BLE translation unit compiles to stubs when disabled. Keep the
# component dependency explicit so a feature-enabled clean configure gets
# the public NimBLE headers during component discovery.
bt
)
# ADR-110: C6-only components — pulled in when building for esp32c6.

View File

@@ -87,6 +87,159 @@ menu "Edge Intelligence (ADR-039)"
endmenu
menu "BLE identity anchors and external Channel Sounding (ADR-341)"
config RADIO_GATEWAY_QUEUE_DEPTH
int "Authenticated radio-envelope queue depth"
default 16
range 4 64
help
Fixed nonblocking queue between radio callbacks and HMAC plus UDP
egress. A full queue drops evidence instead of blocking NimBLE or
UART ingest. Runtime radio_key_id and an exact 32-byte
radio_secret are required whenever either radio path is enabled.
config BLE_IDENTITY_SCAN_ENABLE
bool "Enable authenticated RuView BLE identity-token scanning"
default n
depends on BT_ENABLED && BT_NIMBLE_ENABLED && BT_NIMBLE_ROLE_OBSERVER
depends on BT_NIMBLE_EXT_SCAN
depends on BT_NIMBLE_EXT_ADV
depends on BT_NIMBLE_TRANSPORT_EVT_SIZE >= 257
help
Passive extended scanning for the RuView 128-bit service-data
token. The 50-byte token cannot fit in a legacy advertising or
scan-response payload. This does not inspect arbitrary phone
advertisements, expose BLE MAC addresses, acquire CTE IQ, or infer
vital signs. Runtime NVS key ble_enable and a 32-byte ble_secret
are also required, so a binary compiled with this option still
fails closed until provisioned. A 257-byte NimBLE transport event
buffer is required so the 50-byte service record plus the extended
report header arrives without controller truncation. ESP-IDF 5.4
also gates the extended-report event structure behind
BT_NIMBLE_EXT_ADV, so both extended scan and advertising support
must be compiled even though this node never advertises.
config BLE_IDENTITY_SCAN_INTERVAL_MS
int "BLE scan interval (ms)"
default 1000
range 100 10000
depends on BLE_IDENTITY_SCAN_ENABLE
help
Controller scan interval. The scan window below must remain at or
below one quarter of this value to bound WiFi/BLE coexistence cost.
config BLE_IDENTITY_SCAN_WINDOW_MS
int "BLE scan window (ms)"
default 50
range 5 250
depends on BLE_IDENTITY_SCAN_ENABLE
help
Passive scan window. Firmware rejects configurations above a 25
percent duty ceiling. Default is 5 percent.
config BLE_IDENTITY_TTL_MS
int "Forwarded BLE anchor TTL (ms)"
default 3000
range 250 5000
depends on BLE_IDENTITY_SCAN_ENABLE
help
Maximum host association lifetime. Expired anchors must abstain.
config BLE_IDENTITY_TOKEN_SKEW_MIN
int "Authenticated token clock-skew allowance (minutes)"
default 2
range 0 10
depends on BLE_IDENTITY_SCAN_ENABLE
config BLE_IDENTITY_MIN_CSI_PPS
int "Warn below this CSI callback rate when BLE starts"
default 5
range 0 100
depends on BLE_IDENTITY_SCAN_ENABLE
help
Coexistence diagnostic only. Operators should disable BLE if the
deployment's measured CSI yield regresses.
config BLE_IDENTITY_MAX_REPORTS_PER_SEC
int "Maximum BLE token reports admitted per second"
default 40
range 1 200
depends on BLE_IDENTITY_SCAN_ENABLE
help
Global bound before token HMAC and enqueue. Excess reports are
dropped and counted, limiting valid-token or replay floods.
config CHANNEL_SOUNDING_INGRESS_ENABLE
bool "Enable external Bluetooth 6 Channel Sounding UART ingress"
default n
depends on IDF_TARGET_ESP32S3
help
Accept calibrated phase and timing primitives from a separate
Channel Sounding-capable radio. ESP32-S3 does not acquire these
primitives itself. The gateway validates framing, CRC, bounds,
age, quality, session, procedure and sequence, then places the
exact primitive in an authenticated gateway envelope.
config CHANNEL_SOUNDING_UART_NUM
int "Companion UART controller"
default 2
range 1 2
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
help
UART1 is used by the optional mmWave probe. UART2 is the default on
ESP32-S3. Confirm the board pinout before enabling.
config CHANNEL_SOUNDING_UART_BAUD
int "Companion UART baud"
default 921600
range 115200 2000000
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
config CHANNEL_SOUNDING_UART_TX_GPIO
int "Companion UART TX GPIO"
default 15
range 0 48
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
config CHANNEL_SOUNDING_UART_RX_GPIO
int "Companion UART RX GPIO"
default 16
range 0 48
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
config CHANNEL_SOUNDING_MAX_AGE_MS
int "Maximum companion measurement age (ms)"
default 2000
range 50 10000
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
config CHANNEL_SOUNDING_MIN_QUALITY_PERMILLE
int "Minimum companion quality (per mille)"
default 600
range 1 1000
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
config CHANNEL_SOUNDING_MAX_FRAMES_PER_SEC
int "Maximum companion frames admitted per second"
default 100
range 1 500
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
config CHANNEL_SOUNDING_SESSION_RETIRE_MS
int "Retire oldest companion replay session after inactivity (ms)"
default 600000
range 10000 86400000
depends on CHANNEL_SOUNDING_INGRESS_ENABLE
help
The gateway keeps eight recent authenticated companion sessions.
When the table is full, only a sequence-one frame may replace the
oldest session after this inactivity horizon. The host remains the
durable replay authority and must checkpoint all retired session
high-water marks.
endmenu
menu "Adaptive Controller (ADR-081)"
config ADAPTIVE_FAST_LOOP_MS

View File

@@ -0,0 +1,341 @@
/** @file ble_identity.c */
#include "ble_identity.h"
#include "sdkconfig.h"
#if defined(CONFIG_BLE_IDENTITY_SCAN_ENABLE)
#include <stdatomic.h>
#include <string.h>
#include <time.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "host/ble_gap.h"
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "mbedtls/md.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "ble_identity_protocol.h"
#include "csi_collector.h"
#include "nvs_config.h"
#include "radio_gateway_protocol.h"
#include "radio_gateway_sender.h"
extern nvs_config_t g_nvs_config;
static const char *TAG = "ble_identity";
static uint8_t s_own_addr_type;
static uint32_t s_sequence;
static uint64_t s_rate_window_start_us;
static uint32_t s_rate_window_count;
static uint32_t s_rate_drops;
static uint32_t s_queue_drops;
static EventGroupHandle_t s_start_events;
static atomic_bool s_scan_healthy;
static atomic_bool s_host_running;
static int start_scan(void);
#define BLE_START_READY_BIT (1u << 0)
#define BLE_START_FAILED_BIT (1u << 1)
static bool admit_report(uint64_t observed_at_us)
{
if (s_rate_window_start_us == 0u
|| observed_at_us - s_rate_window_start_us >= 1000000u) {
s_rate_window_start_us = observed_at_us;
s_rate_window_count = 0u;
}
if (s_rate_window_count >= CONFIG_BLE_IDENTITY_MAX_REPORTS_PER_SEC) {
s_rate_drops++;
if ((s_rate_drops & 63u) == 1u) {
ESP_LOGW(TAG, "BLE token admission rate limited (drops=%lu)",
(unsigned long)s_rate_drops);
}
return false;
}
s_rate_window_count++;
return true;
}
static bool authenticate_token(const rv_ble_token_t *token)
{
if (token == NULL || !g_nvs_config.ble_secret_valid
|| token->key_id != g_nvs_config.ble_key_id) {
return false;
}
uint8_t covered[34];
uint8_t digest[32];
rv_ble_token_mac_input(token, covered);
const mbedtls_md_info_t *sha256 = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (sha256 == NULL
|| mbedtls_md_hmac(sha256,
g_nvs_config.ble_secret,
sizeof(g_nvs_config.ble_secret),
covered,
sizeof(covered),
digest) != 0) {
return false;
}
return rv_ble_auth_tag_equal(token->auth_tag, digest);
}
static bool token_time_is_fresh(const rv_ble_token_t *token, bool *clock_valid)
{
time_t now = time(NULL);
*clock_valid = now >= 1700000000;
if (!*clock_valid) {
return true; /* Host must validate token_epoch_min before association. */
}
uint32_t now_min = (uint32_t)((uint64_t)now / 60u);
uint32_t delta = now_min > token->epoch_min
? now_min - token->epoch_min
: token->epoch_min - now_min;
return delta <= (uint32_t)CONFIG_BLE_IDENTITY_TOKEN_SKEW_MIN;
}
static uint16_t observation_confidence(int8_t rssi, bool clock_valid)
{
/* This is evidence quality, not probability that a tag is a person. */
int quality = 900;
if (!clock_valid) quality = 650;
if (rssi < -95) quality -= 250;
else if (rssi < -85) quality -= 120;
if (quality < 100) quality = 100;
return (uint16_t)quality;
}
static void process_advertisement(const uint8_t *data, uint16_t length_data,
int8_t rssi, int8_t tx_power,
bool extended)
{
if (data == NULL || length_data > 200u || rssi == 127) return;
rv_ble_token_t token;
if (!rv_ble_parse_advertisement(data, length_data, &token)) {
return;
}
uint64_t observed_at_us = (uint64_t)esp_timer_get_time();
if (!admit_report(observed_at_us) || !authenticate_token(&token)) {
return;
}
bool clock_valid = false;
if (!token_time_is_fresh(&token, &clock_valid)) {
ESP_LOGW(TAG, "dropping authenticated but stale BLE token (key=%u)",
(unsigned)token.key_id);
return;
}
if (s_sequence == UINT32_MAX) {
ESP_LOGE(TAG, "BLE telemetry sequence exhausted; reboot or rekey required");
return;
}
rv_ble_telemetry_t telemetry = {
.node_id = csi_collector_get_node_id(),
.flags = RV_BLE_FLAG_AUTHENTICATED,
.key_id = token.key_id,
.sequence = ++s_sequence,
.observed_at_ms = (uint32_t)(observed_at_us / 1000u),
.ttl_ms = (uint16_t)CONFIG_BLE_IDENTITY_TTL_MS,
.confidence_permille = observation_confidence(rssi, clock_valid),
.rssi_dbm = rssi,
.tx_power_dbm = tx_power,
.token_epoch_min = token.epoch_min,
};
if (clock_valid) telemetry.flags |= RV_BLE_FLAG_TIME_VERIFIED;
if (extended) telemetry.flags |= RV_BLE_FLAG_EXTENDED_ADVERT;
memcpy(telemetry.ephemeral_id, token.ephemeral_id,
sizeof(telemetry.ephemeral_id));
uint8_t packet[RV_BLE_TELEMETRY_SIZE];
if (rv_ble_serialize_telemetry(&telemetry, packet, sizeof(packet))) {
esp_err_t rc = radio_gateway_sender_enqueue(
RV_GATEWAY_PAYLOAD_BLE_IDENTITY, packet, sizeof(packet),
observed_at_us, 1000u);
if (rc != ESP_OK) {
s_queue_drops++;
if ((s_queue_drops & 63u) == 1u) {
ESP_LOGW(TAG, "BLE gateway queue full or unavailable (drops=%lu)",
(unsigned long)s_queue_drops);
}
}
}
}
static int gap_event(struct ble_gap_event *event, void *arg)
{
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_DISC:
process_advertisement(event->disc.data, event->disc.length_data,
event->disc.rssi, 127, false);
return 0;
#if defined(CONFIG_BT_NIMBLE_EXT_SCAN)
case BLE_GAP_EVENT_EXT_DISC:
if (event->ext_disc.data_status
!= BLE_GAP_EXT_ADV_DATA_STATUS_COMPLETE) {
/* Do not authenticate a prefix. Deployments keep the complete
* advertiser payload bounded so it fits one 257-byte HCI event. */
return 0;
}
process_advertisement(event->ext_disc.data,
event->ext_disc.length_data,
event->ext_disc.rssi,
event->ext_disc.tx_power,
true);
return 0;
#endif
case BLE_GAP_EVENT_DISC_COMPLETE:
atomic_store_explicit(&s_scan_healthy, false, memory_order_release);
ESP_LOGW(TAG, "BLE scan terminated (reason=%d); restarting",
event->disc_complete.reason);
(void)start_scan();
return 0;
default:
return 0;
}
}
static int start_scan(void)
{
struct ble_gap_ext_disc_params params;
memset(&params, 0, sizeof(params));
params.passive = 1;
params.itvl = BLE_GAP_SCAN_ITVL_MS(CONFIG_BLE_IDENTITY_SCAN_INTERVAL_MS);
params.window = BLE_GAP_SCAN_WIN_MS(CONFIG_BLE_IDENTITY_SCAN_WINDOW_MS);
/*
* The authenticated service record is 50 bytes and therefore cannot be
* received through legacy discovery. duration=0 and period=0 request a
* continuous extended discovery procedure. Duplicate filtering is off:
* otherwise the controller may report a valid token only once for the
* entire scan session and its three-second host TTL cannot be refreshed.
* Scan the uncoded primary PHY only so the configured duty ceiling maps
* to one controller scan window rather than two concurrent PHY windows.
*/
int rc = ble_gap_ext_disc(s_own_addr_type, 0, 0, 0, 0, 0,
&params, NULL, gap_event, NULL);
if (rc != 0) {
atomic_store_explicit(&s_scan_healthy, false, memory_order_release);
if (s_start_events != NULL) {
xEventGroupSetBits(s_start_events, BLE_START_FAILED_BIT);
}
ESP_LOGE(TAG, "passive scan start failed: rc=%d", rc);
} else {
atomic_store_explicit(&s_scan_healthy, true, memory_order_release);
if (s_start_events != NULL) {
xEventGroupSetBits(s_start_events, BLE_START_READY_BIT);
}
ESP_LOGI(TAG, "passive BLE identity scan active: %d/%d ms duty window",
CONFIG_BLE_IDENTITY_SCAN_WINDOW_MS,
CONFIG_BLE_IDENTITY_SCAN_INTERVAL_MS);
}
return rc;
}
static void on_reset(int reason)
{
atomic_store_explicit(&s_scan_healthy, false, memory_order_release);
ESP_LOGE(TAG, "NimBLE host reset: reason=%d", reason);
}
static void on_sync(void)
{
int rc = ble_hs_util_ensure_addr(0);
if (rc != 0) {
ESP_LOGE(TAG, "cannot ensure BLE identity address: rc=%d", rc);
return;
}
rc = ble_hs_id_infer_auto(0, &s_own_addr_type);
if (rc != 0) {
ESP_LOGE(TAG, "cannot infer BLE address type: rc=%d", rc);
return;
}
(void)start_scan();
}
static void host_task(void *arg)
{
(void)arg;
nimble_port_run();
atomic_store_explicit(&s_scan_healthy, false, memory_order_release);
atomic_store_explicit(&s_host_running, false, memory_order_release);
nimble_port_freertos_deinit();
}
esp_err_t ble_identity_init(void)
{
if (!g_nvs_config.ble_identity_enabled) {
ESP_LOGI(TAG, "disabled by NVS (ble_enable=0)");
return ESP_ERR_NOT_SUPPORTED;
}
if (!g_nvs_config.ble_secret_valid) {
ESP_LOGE(TAG, "enabled without a 32-byte ble_secret; refusing to scan");
return ESP_ERR_INVALID_STATE;
}
if (!radio_gateway_sender_is_ready()) {
ESP_LOGE(TAG, "authenticated gateway envelope is unavailable");
return ESP_ERR_INVALID_STATE;
}
if (CONFIG_BLE_IDENTITY_SCAN_WINDOW_MS > CONFIG_BLE_IDENTITY_SCAN_INTERVAL_MS / 4) {
ESP_LOGE(TAG, "scan duty exceeds the hard 25%% coexistence ceiling");
return ESP_ERR_INVALID_ARG;
}
if (csi_collector_get_pkt_yield_per_sec()
< (uint16_t)CONFIG_BLE_IDENTITY_MIN_CSI_PPS) {
ESP_LOGW(TAG, "CSI yield is below coexistence target at BLE start");
}
if (atomic_load_explicit(&s_host_running, memory_order_acquire)) {
return ble_identity_is_healthy() ? ESP_OK : ESP_ERR_INVALID_STATE;
}
if (s_start_events == NULL) {
s_start_events = xEventGroupCreate();
if (s_start_events == NULL) return ESP_ERR_NO_MEM;
}
xEventGroupClearBits(s_start_events,
BLE_START_READY_BIT | BLE_START_FAILED_BIT);
int rc = nimble_port_init();
if (rc != 0) {
ESP_LOGE(TAG, "nimble_port_init failed: rc=%d", rc);
return ESP_FAIL;
}
ble_hs_cfg.sync_cb = on_sync;
ble_hs_cfg.reset_cb = on_reset;
atomic_store_explicit(&s_host_running, true, memory_order_release);
nimble_port_freertos_init(host_task);
EventBits_t result = xEventGroupWaitBits(
s_start_events, BLE_START_READY_BIT | BLE_START_FAILED_BIT,
pdFALSE, pdFALSE, pdMS_TO_TICKS(5000));
if ((result & BLE_START_READY_BIT) != 0u) return ESP_OK;
ESP_LOGE(TAG, "BLE scanner did not become healthy within startup deadline");
(void)nimble_port_stop();
return (result & BLE_START_FAILED_BIT) != 0u
? ESP_FAIL : ESP_ERR_TIMEOUT;
}
bool ble_identity_is_healthy(void)
{
return atomic_load_explicit(&s_scan_healthy, memory_order_acquire);
}
#else
esp_err_t ble_identity_init(void)
{
return ESP_ERR_NOT_SUPPORTED;
}
bool ble_identity_is_healthy(void)
{
return false;
}
#endif /* CONFIG_BLE_IDENTITY_SCAN_ENABLE */

View File

@@ -0,0 +1,23 @@
/**
* @file ble_identity.h
* @brief Opt-in, privacy-minimized BLE identity-anchor scanner.
*
* ESP32-S3 BLE scanning provides advertising metadata and RSSI. It does not
* expose raw CTE IQ or Bluetooth Channel Sounding measurements. This module
* therefore accepts only authenticated RuView service tokens and emits a
* short-lived pseudonym; it makes no civil-identity or vital-sign claim.
*/
#ifndef BLE_IDENTITY_H
#define BLE_IDENTITY_H
#include <stdbool.h>
#include "esp_err.h"
/** Start the bounded-duty passive scanner, or return NOT_SUPPORTED/off. */
esp_err_t ble_identity_init(void);
/** True only while the controller has an active extended scan procedure. */
bool ble_identity_is_healthy(void);
#endif /* BLE_IDENTITY_H */

View File

@@ -0,0 +1,154 @@
/** @file ble_identity_protocol.c */
#include "ble_identity_protocol.h"
#include <string.h>
#ifdef ESP_PLATFORM
#include "mbedtls/constant_time.h"
#endif
const uint8_t RV_BLE_SERVICE_UUID_LE[16] = {
0x00, 0x01, 0xe0, 0xb1, 0x11, 0xc5, 0x09, 0x9f,
0x69, 0x4d, 0x65, 0x5d, 0x40, 0xa8, 0x31, 0x6f,
};
static uint32_t read_le32(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
static void write_le16(uint8_t *p, uint16_t v)
{
p[0] = (uint8_t)(v & 0xffu);
p[1] = (uint8_t)(v >> 8);
}
static void write_le32(uint8_t *p, uint32_t v)
{
p[0] = (uint8_t)(v & 0xffu);
p[1] = (uint8_t)((v >> 8) & 0xffu);
p[2] = (uint8_t)((v >> 16) & 0xffu);
p[3] = (uint8_t)((v >> 24) & 0xffu);
}
bool rv_ble_parse_advertisement(const uint8_t *advertisement,
size_t advertisement_len,
rv_ble_token_t *out)
{
if (advertisement == NULL || out == NULL || advertisement_len == 0u) {
return false;
}
size_t offset = 0u;
bool found = false;
rv_ble_token_t parsed;
memset(&parsed, 0, sizeof(parsed));
while (offset < advertisement_len) {
uint8_t field_len = advertisement[offset];
if (field_len == 0u) {
break;
}
if ((size_t)field_len + 1u > advertisement_len - offset) {
return false;
}
const uint8_t *field = &advertisement[offset + 1u];
uint8_t ad_type = field[0];
size_t payload_len = (size_t)field_len - 1u;
const uint8_t *payload = &field[1];
if (ad_type == RV_BLE_AD_TYPE_SERVICE_DATA_UUID128
&& payload_len >= sizeof(RV_BLE_SERVICE_UUID_LE)
&& memcmp(payload, RV_BLE_SERVICE_UUID_LE,
sizeof(RV_BLE_SERVICE_UUID_LE)) == 0) {
if (found || payload_len != RV_BLE_SERVICE_DATA_SIZE) {
return false;
}
const uint8_t *body = payload + sizeof(RV_BLE_SERVICE_UUID_LE);
parsed.version = body[0];
parsed.key_id = body[1];
parsed.epoch_min = read_le32(&body[2]);
parsed.nonce = read_le32(&body[6]);
memcpy(parsed.ephemeral_id, &body[10], RV_BLE_EPHEMERAL_ID_SIZE);
memcpy(parsed.auth_tag, &body[18], RV_BLE_AUTH_TAG_SIZE);
if (parsed.version != RV_BLE_TOKEN_VERSION) {
return false;
}
found = true;
}
offset += (size_t)field_len + 1u;
}
if (!found) {
return false;
}
*out = parsed;
return true;
}
void rv_ble_token_mac_input(const rv_ble_token_t *token, uint8_t out[34])
{
if (token == NULL || out == NULL) {
return;
}
memcpy(out, RV_BLE_SERVICE_UUID_LE, 16u);
out[16] = token->version;
out[17] = token->key_id;
write_le32(&out[18], token->epoch_min);
write_le32(&out[22], token->nonce);
memcpy(&out[26], token->ephemeral_id, RV_BLE_EPHEMERAL_ID_SIZE);
}
bool rv_ble_auth_tag_equal(const uint8_t lhs[RV_BLE_AUTH_TAG_SIZE],
const uint8_t rhs[RV_BLE_AUTH_TAG_SIZE])
{
if (lhs == NULL || rhs == NULL) {
return false;
}
#ifdef ESP_PLATFORM
return mbedtls_ct_memcmp(lhs, rhs, RV_BLE_AUTH_TAG_SIZE) == 0;
#else
uint8_t diff = 0u;
for (size_t i = 0u; i < RV_BLE_AUTH_TAG_SIZE; i++) {
diff |= (uint8_t)(lhs[i] ^ rhs[i]);
}
return diff == 0u;
#endif
}
bool rv_ble_serialize_telemetry(const rv_ble_telemetry_t *telemetry,
uint8_t *out,
size_t out_len)
{
if (telemetry == NULL || out == NULL || out_len < RV_BLE_TELEMETRY_SIZE
|| telemetry->ttl_ms == 0u
|| telemetry->confidence_permille > 1000u
|| telemetry->rssi_dbm == 127
|| (telemetry->flags & ~RV_BLE_FLAGS_ALLOWED) != 0u
|| (telemetry->flags & RV_BLE_FLAG_AUTHENTICATED) == 0u) {
return false;
}
memset(out, 0, RV_BLE_TELEMETRY_SIZE);
write_le32(&out[0], RV_BLE_TELEMETRY_MAGIC);
out[4] = RV_BLE_TELEMETRY_VERSION;
out[5] = telemetry->node_id;
out[6] = telemetry->flags;
out[7] = telemetry->key_id;
write_le32(&out[8], telemetry->sequence);
write_le32(&out[12], telemetry->observed_at_ms);
write_le16(&out[16], telemetry->ttl_ms);
write_le16(&out[18], telemetry->confidence_permille);
out[20] = (uint8_t)telemetry->rssi_dbm;
out[21] = (uint8_t)telemetry->tx_power_dbm;
/* bytes 22..23 are reserved and remain zero */
memcpy(&out[24], telemetry->ephemeral_id, RV_BLE_EPHEMERAL_ID_SIZE);
write_le32(&out[32], telemetry->token_epoch_min);
return true;
}

View File

@@ -0,0 +1,91 @@
/**
* @file ble_identity_protocol.h
* @brief Pure-C RuView BLE identity-token and telemetry wire contracts.
*
* This module deliberately contains no ESP-IDF dependency so the untrusted
* advertising-data boundary can be exercised by host unit tests. It parses
* only RuView's vendor 128-bit service-data record. It never exports a BLE
* address or general advertising payload.
*/
#ifndef BLE_IDENTITY_PROTOCOL_H
#define BLE_IDENTITY_PROTOCOL_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define RV_BLE_AD_TYPE_SERVICE_DATA_UUID128 0x21u
#define RV_BLE_TOKEN_VERSION 1u
#define RV_BLE_TOKEN_BODY_SIZE 34u
#define RV_BLE_SERVICE_DATA_SIZE 50u
#define RV_BLE_EPHEMERAL_ID_SIZE 8u
#define RV_BLE_AUTH_TAG_SIZE 16u
#define RV_BLE_TELEMETRY_MAGIC 0xC51100B1u
#define RV_BLE_TELEMETRY_VERSION 1u
#define RV_BLE_TELEMETRY_SIZE 36u
#define RV_BLE_FLAG_AUTHENTICATED (1u << 0)
#define RV_BLE_FLAG_TIME_VERIFIED (1u << 1)
#define RV_BLE_FLAG_EXTENDED_ADVERT (1u << 2)
#define RV_BLE_FLAGS_ALLOWED (RV_BLE_FLAG_AUTHENTICATED | \
RV_BLE_FLAG_TIME_VERIFIED | \
RV_BLE_FLAG_EXTENDED_ADVERT)
/** Raw little-endian UUID bytes as they appear in BLE service data. */
extern const uint8_t RV_BLE_SERVICE_UUID_LE[16];
/** Authenticated, rotating token extracted from RuView service data. */
typedef struct {
uint8_t version;
uint8_t key_id;
uint32_t epoch_min;
uint32_t nonce;
uint8_t ephemeral_id[RV_BLE_EPHEMERAL_ID_SIZE];
uint8_t auth_tag[RV_BLE_AUTH_TAG_SIZE];
} rv_ble_token_t;
/** Privacy-minimized observation forwarded to the RuView host. */
typedef struct {
uint8_t node_id;
uint8_t flags;
uint8_t key_id;
uint32_t sequence;
uint32_t observed_at_ms;
uint16_t ttl_ms;
uint16_t confidence_permille;
int8_t rssi_dbm;
int8_t tx_power_dbm;
uint8_t ephemeral_id[RV_BLE_EPHEMERAL_ID_SIZE];
uint32_t token_epoch_min;
} rv_ble_telemetry_t;
/**
* Find and parse the RuView 128-bit service-data record from one advertising
* report. Unknown AD elements are skipped. Truncation, duplicate RuView
* elements, wrong UUIDs, or unsupported token versions fail closed.
*/
bool rv_ble_parse_advertisement(const uint8_t *advertisement,
size_t advertisement_len,
rv_ble_token_t *out);
/**
* Return the bytes covered by the token HMAC.
*
* The covered bytes are the raw 16-byte UUID followed by token version,
* key-id, epoch, nonce and ephemeral id. The authentication tag itself is
* excluded. The caller supplies a 34-byte output buffer.
*/
void rv_ble_token_mac_input(const rv_ble_token_t *token, uint8_t out[34]);
/** Constant-time comparison for the truncated authentication tag. */
bool rv_ble_auth_tag_equal(const uint8_t lhs[RV_BLE_AUTH_TAG_SIZE],
const uint8_t rhs[RV_BLE_AUTH_TAG_SIZE]);
/** Serialize one telemetry packet in the fixed little-endian wire format. */
bool rv_ble_serialize_telemetry(const rv_ble_telemetry_t *telemetry,
uint8_t *out,
size_t out_len);
#endif /* BLE_IDENTITY_PROTOCOL_H */

View File

@@ -0,0 +1,273 @@
/** @file channel_sounding_ingress.c */
#include "channel_sounding_ingress.h"
#include "sdkconfig.h"
#if defined(CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE)
#include <stdbool.h>
#include <string.h>
#include "driver/uart.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "channel_sounding_protocol.h"
#include "mbedtls/md.h"
#include "nvs_config.h"
#include "radio_gateway_protocol.h"
#include "radio_gateway_sender.h"
extern nvs_config_t g_nvs_config;
static const char *TAG = "cs_ingress";
typedef struct {
uint32_t source_id;
uint32_t source_session_id;
uint32_t sequence;
uint64_t last_seen_us;
bool initialized;
} source_sequence_t;
#define SOURCE_SEQUENCE_SLOTS 8u
static source_sequence_t s_sequences[SOURCE_SEQUENCE_SLOTS];
static uint64_t s_rate_window_start_us;
static uint32_t s_rate_window_count;
static uint32_t s_rate_drops;
static uint32_t s_invalid_drops;
static uint32_t s_auth_drops;
static uint32_t s_replay_drops;
static uint32_t s_queue_drops;
static bool authenticate_frame(const uint8_t frame[RV_CS_FRAME_SIZE])
{
if (!g_nvs_config.cs_secret_valid || frame[6] != g_nvs_config.cs_key_id) return false;
uint8_t covered[RV_CS_MAC_INPUT_SIZE];
uint8_t digest[32];
rv_cs_mac_input(frame, covered);
const mbedtls_md_info_t *sha256 = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (sha256 == NULL
|| mbedtls_md_hmac(sha256, g_nvs_config.cs_secret,
sizeof(g_nvs_config.cs_secret), covered,
sizeof(covered), digest) != 0) return false;
return rv_cs_auth_tag_equal(&frame[RV_CS_SIGNED_PREFIX_SIZE], digest);
}
static bool accept_sequence(uint32_t source_id, uint32_t source_session_id,
uint32_t sequence, uint64_t received_at_us)
{
size_t empty = SOURCE_SEQUENCE_SLOTS;
size_t oldest = 0u;
for (size_t i = 0u; i < SOURCE_SEQUENCE_SLOTS; i++) {
if (s_sequences[i].initialized
&& s_sequences[i].source_id == source_id
&& s_sequences[i].source_session_id == source_session_id) {
if (!rv_cs_sequence_is_newer(sequence, s_sequences[i].sequence)) {
return false;
}
s_sequences[i].sequence = sequence;
s_sequences[i].last_seen_us = received_at_us;
return true;
}
if (!s_sequences[i].initialized && empty == SOURCE_SEQUENCE_SLOTS) empty = i;
if (s_sequences[i].initialized
&& s_sequences[i].last_seen_us < s_sequences[oldest].last_seen_us) {
oldest = i;
}
}
if (empty == SOURCE_SEQUENCE_SLOTS) {
uint64_t retire_us =
(uint64_t)CONFIG_CHANNEL_SOUNDING_SESSION_RETIRE_MS * 1000u;
if (sequence != 1u
|| received_at_us < s_sequences[oldest].last_seen_us
|| received_at_us - s_sequences[oldest].last_seen_us < retire_us) {
return false;
}
empty = oldest;
}
s_sequences[empty].source_id = source_id;
s_sequences[empty].source_session_id = source_session_id;
s_sequences[empty].sequence = sequence;
s_sequences[empty].last_seen_us = received_at_us;
s_sequences[empty].initialized = true;
return true;
}
static bool admit_frame(uint64_t received_at_us)
{
if (s_rate_window_start_us == 0u
|| received_at_us - s_rate_window_start_us >= 1000000u) {
s_rate_window_start_us = received_at_us;
s_rate_window_count = 0u;
}
if (s_rate_window_count >= CONFIG_CHANNEL_SOUNDING_MAX_FRAMES_PER_SEC) {
s_rate_drops++;
if ((s_rate_drops & 63u) == 1u) {
ESP_LOGW(TAG, "companion admission rate limited (drops=%lu)",
(unsigned long)s_rate_drops);
}
return false;
}
s_rate_window_count++;
return true;
}
static void process_frame(const uint8_t frame[RV_CS_FRAME_SIZE],
uint64_t received_at_us)
{
rv_cs_measurement_t measurement;
rv_cs_parse_result_t result = rv_cs_parse_frame(
frame, RV_CS_FRAME_SIZE,
(uint32_t)CONFIG_CHANNEL_SOUNDING_MAX_AGE_MS * 1000u,
(uint16_t)CONFIG_CHANNEL_SOUNDING_MIN_QUALITY_PERMILLE,
&measurement);
if (result != RV_CS_PARSE_OK
|| measurement.source_id != g_nvs_config.cs_enrolled_source_id) {
s_invalid_drops++;
if ((s_invalid_drops & 63u) == 1u) {
ESP_LOGW(TAG, "invalid or unenrolled companion frame (reason=%d drops=%lu)",
(int)result, (unsigned long)s_invalid_drops);
}
return;
}
if (!admit_frame(received_at_us)) return;
if (!authenticate_frame(frame)) {
s_auth_drops++;
if ((s_auth_drops & 63u) == 1u) {
ESP_LOGW(TAG, "unauthenticated companion frame (drops=%lu)",
(unsigned long)s_auth_drops);
}
return;
}
if (!accept_sequence(measurement.source_id,
measurement.source_session_id,
measurement.sequence, received_at_us)) {
s_replay_drops++;
if ((s_replay_drops & 63u) == 1u) {
ESP_LOGW(TAG, "companion replay or state capacity drop (drops=%lu)",
(unsigned long)s_replay_drops);
}
return;
}
uint32_t uart_uncertainty_us =
(uint32_t)((RV_CS_FRAME_SIZE * 10u * 1000000u
+ CONFIG_CHANNEL_SOUNDING_UART_BAUD - 1u)
/ CONFIG_CHANNEL_SOUNDING_UART_BAUD);
uint32_t uncertainty_us =
(uint32_t)measurement.timing_uncertainty_us + uart_uncertainty_us;
esp_err_t rc = radio_gateway_sender_enqueue(
RV_GATEWAY_PAYLOAD_CHANNEL_SOUNDING, frame, RV_CS_FRAME_SIZE,
received_at_us, uncertainty_us);
if (rc != ESP_OK) {
s_queue_drops++;
if ((s_queue_drops & 63u) == 1u) {
ESP_LOGW(TAG, "Channel Sounding gateway queue drop (drops=%lu)",
(unsigned long)s_queue_drops);
}
}
}
static void uart_task(void *arg)
{
(void)arg;
static const uint8_t magic[4] = { 'R', 'V', 'C', 'S' };
uint8_t frame[RV_CS_FRAME_SIZE];
uint8_t chunk[128];
size_t used = 0u;
for (;;) {
int count = uart_read_bytes(CONFIG_CHANNEL_SOUNDING_UART_NUM,
chunk, sizeof(chunk), pdMS_TO_TICKS(100));
if (count <= 0) continue;
for (int index = 0; index < count; index++) {
uint8_t byte = chunk[index];
if (used < sizeof(magic)) {
if (byte == magic[used]) {
frame[used++] = byte;
} else {
used = byte == magic[0] ? 1u : 0u;
if (used == 1u) frame[0] = byte;
}
continue;
}
frame[used++] = byte;
if (used == sizeof(frame)) {
process_frame(frame, (uint64_t)esp_timer_get_time());
used = 0u;
}
}
}
}
esp_err_t channel_sounding_ingress_init(void)
{
if (!g_nvs_config.cs_ingress_enabled) {
ESP_LOGI(TAG, "disabled by NVS (cs_enable=0)");
return ESP_ERR_NOT_SUPPORTED;
}
if (!g_nvs_config.cs_secret_valid) {
ESP_LOGE(TAG, "enabled without a 32-byte cs_secret; refusing UART ingress");
return ESP_ERR_INVALID_STATE;
}
if (g_nvs_config.cs_enrolled_source_id == 0u) {
ESP_LOGE(TAG, "enabled without a nonzero enrolled cs_source_id");
return ESP_ERR_INVALID_STATE;
}
if (!radio_gateway_sender_is_ready()) {
ESP_LOGE(TAG, "authenticated gateway envelope is unavailable");
return ESP_ERR_INVALID_STATE;
}
if (CONFIG_CHANNEL_SOUNDING_UART_NUM == UART_NUM_0) {
ESP_LOGE(TAG, "UART0 is reserved for console and provisioning");
return ESP_ERR_INVALID_ARG;
}
memset(s_sequences, 0, sizeof(s_sequences));
uart_config_t config = {
.baud_rate = CONFIG_CHANNEL_SOUNDING_UART_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
esp_err_t rc = uart_driver_install(CONFIG_CHANNEL_SOUNDING_UART_NUM,
RV_CS_FRAME_SIZE * 8u, 0, 0, NULL, 0);
if (rc != ESP_OK) return rc;
rc = uart_param_config(CONFIG_CHANNEL_SOUNDING_UART_NUM, &config);
if (rc != ESP_OK) {
uart_driver_delete(CONFIG_CHANNEL_SOUNDING_UART_NUM);
return rc;
}
rc = uart_set_pin(CONFIG_CHANNEL_SOUNDING_UART_NUM,
CONFIG_CHANNEL_SOUNDING_UART_TX_GPIO,
CONFIG_CHANNEL_SOUNDING_UART_RX_GPIO,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
if (rc != ESP_OK) {
uart_driver_delete(CONFIG_CHANNEL_SOUNDING_UART_NUM);
return rc;
}
BaseType_t task = xTaskCreate(uart_task, "cs_uart", 4096,
NULL, 5, NULL);
if (task != pdPASS) {
uart_driver_delete(CONFIG_CHANNEL_SOUNDING_UART_NUM);
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "external Channel Sounding ingress active on UART%d; data is unvalidated by hardware evidence",
CONFIG_CHANNEL_SOUNDING_UART_NUM);
return ESP_OK;
}
#else
esp_err_t channel_sounding_ingress_init(void)
{
return ESP_ERR_NOT_SUPPORTED;
}
#endif /* CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE */

View File

@@ -0,0 +1,11 @@
/** @file channel_sounding_ingress.h */
#ifndef CHANNEL_SOUNDING_INGRESS_H
#define CHANNEL_SOUNDING_INGRESS_H
#include "esp_err.h"
/** Start the optional external Channel Sounding UART ingress. */
esp_err_t channel_sounding_ingress_init(void);
#endif /* CHANNEL_SOUNDING_INGRESS_H */

View File

@@ -0,0 +1,124 @@
/** @file channel_sounding_protocol.c */
#include "channel_sounding_protocol.h"
#ifdef ESP_PLATFORM
#include "mbedtls/constant_time.h"
#endif
static uint16_t read_le16(const uint8_t *p)
{
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
}
static uint32_t read_le32(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
uint32_t rv_cs_crc32(const uint8_t *data, size_t len)
{
if (data == NULL) return 0u;
uint32_t crc = 0xffffffffu;
for (size_t i = 0u; i < len; i++) {
crc ^= data[i];
for (unsigned bit = 0u; bit < 8u; bit++) {
uint32_t mask = (uint32_t)-(int32_t)(crc & 1u);
crc = (crc >> 1) ^ (0xedb88320u & mask);
}
}
return ~crc;
}
bool rv_cs_sequence_is_newer(uint32_t candidate, uint32_t previous)
{
uint32_t delta = candidate - previous;
return delta != 0u && delta < 0x80000000u;
}
rv_cs_parse_result_t rv_cs_parse_frame(const uint8_t *data,
size_t len,
uint32_t max_age_us,
uint16_t min_quality_permille,
rv_cs_measurement_t *out)
{
if (data == NULL || out == NULL) return RV_CS_PARSE_BAD_ARGUMENT;
if (len != RV_CS_FRAME_SIZE) return RV_CS_PARSE_BAD_LENGTH;
if (read_le32(&data[0]) != RV_CS_MAGIC) return RV_CS_PARSE_BAD_MAGIC;
if (data[4] != RV_CS_VERSION) return RV_CS_PARSE_BAD_VERSION;
if (data[7] != 0u || read_le16(&data[8]) != RV_CS_FRAME_SIZE) return RV_CS_PARSE_BAD_LENGTH;
if ((data[5] & ~RV_CS_FLAGS_ALLOWED) != 0u) return RV_CS_PARSE_BAD_FLAGS;
if (rv_cs_crc32(data, RV_CS_FRAME_SIZE - 4u)
!= read_le32(&data[RV_CS_FRAME_SIZE - 4u])) {
return RV_CS_PARSE_BAD_CRC;
}
rv_cs_measurement_t parsed = {
.flags = data[5],
.key_id = data[6],
.channel_index = read_le16(&data[10]),
.sequence = read_le32(&data[12]),
.sample_age_us = read_le32(&data[16]),
.source_id = read_le32(&data[20]),
.quality_permille = read_le16(&data[24]),
.timing_uncertainty_us = read_le16(&data[26]),
.phase_millirad = (int32_t)read_le32(&data[28]),
.rtt_picoseconds = (int32_t)read_le32(&data[32]),
.frequency_offset_hz = (int32_t)read_le32(&data[36]),
.source_session_id = read_le32(&data[40]),
.procedure_id = read_le32(&data[44]),
.step_index = read_le16(&data[48]),
.step_count = read_le16(&data[50]),
};
if (parsed.source_id == 0u) return RV_CS_PARSE_BAD_SOURCE;
if (parsed.source_session_id == 0u) return RV_CS_PARSE_BAD_SESSION;
if (parsed.procedure_id == 0u) return RV_CS_PARSE_BAD_PROCEDURE;
if (parsed.step_count < RV_CS_MIN_STEP_COUNT
|| parsed.step_count > RV_CS_MAX_STEP_COUNT
|| parsed.step_index >= parsed.step_count) return RV_CS_PARSE_BAD_STEP;
if (parsed.channel_index > RV_CS_MAX_CHANNEL_INDEX) return RV_CS_PARSE_BAD_CHANNEL;
if (parsed.quality_permille < min_quality_permille
|| parsed.quality_permille > 1000u) return RV_CS_PARSE_BAD_QUALITY;
if (parsed.phase_millirad < -RV_CS_MAX_PHASE_MRAD
|| parsed.phase_millirad > RV_CS_MAX_PHASE_MRAD) return RV_CS_PARSE_BAD_PHASE;
if (parsed.rtt_picoseconds < 0
|| parsed.rtt_picoseconds > RV_CS_MAX_RTT_PS) return RV_CS_PARSE_BAD_RTT;
if (parsed.frequency_offset_hz < -RV_CS_MAX_FREQ_OFFSET_HZ
|| parsed.frequency_offset_hz > RV_CS_MAX_FREQ_OFFSET_HZ) {
return RV_CS_PARSE_BAD_FREQUENCY_OFFSET;
}
if (parsed.timing_uncertainty_us > 10000u) return RV_CS_PARSE_BAD_TIMING_UNCERTAINTY;
if (parsed.sample_age_us > max_age_us) return RV_CS_PARSE_STALE;
*out = parsed;
return RV_CS_PARSE_OK;
}
void rv_cs_mac_input(const uint8_t frame[RV_CS_FRAME_SIZE],
uint8_t out[RV_CS_MAC_INPUT_SIZE])
{
static const uint8_t domain[RV_CS_MAC_DOMAIN_SIZE] = {
'R', 'u', 'V', 'i', 'e', 'w', '/', 'C', 'S', '/', 'v', '1'
};
if (frame == NULL || out == NULL) return;
for (size_t i = 0u; i < sizeof(domain); i++) out[i] = domain[i];
for (size_t i = 0u; i < RV_CS_SIGNED_PREFIX_SIZE; i++) {
out[sizeof(domain) + i] = frame[i];
}
}
bool rv_cs_auth_tag_equal(const uint8_t lhs[RV_CS_AUTH_TAG_SIZE],
const uint8_t rhs[RV_CS_AUTH_TAG_SIZE])
{
if (lhs == NULL || rhs == NULL) return false;
#ifdef ESP_PLATFORM
return mbedtls_ct_memcmp(lhs, rhs, RV_CS_AUTH_TAG_SIZE) == 0;
#else
uint8_t diff = 0u;
for (size_t i = 0u; i < RV_CS_AUTH_TAG_SIZE; i++) diff |= (uint8_t)(lhs[i] ^ rhs[i]);
return diff == 0u;
#endif
}

View File

@@ -0,0 +1,105 @@
/**
* @file channel_sounding_protocol.h
* @brief Versioned UART contract for an external Bluetooth Channel Sounding radio.
*
* ESP32-S3 cannot acquire Bluetooth 6 Channel Sounding phase or RTT. A radio
* that can do so may send calibrated primitives over this bounded frame. The
* ESP32 validates and forwards primitives only; it does not label them as a
* respiration or heartbeat result.
*/
#ifndef CHANNEL_SOUNDING_PROTOCOL_H
#define CHANNEL_SOUNDING_PROTOCOL_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define RV_CS_MAGIC 0x53435652u /* "RVCS" little endian */
#define RV_CS_VERSION 1u
#define RV_CS_FRAME_SIZE 72u
#define RV_CS_AUTH_TAG_SIZE 16u
#define RV_CS_SIGNED_PREFIX_SIZE 52u
#define RV_CS_MAC_DOMAIN_SIZE 12u
#define RV_CS_MAC_INPUT_SIZE (RV_CS_MAC_DOMAIN_SIZE + RV_CS_SIGNED_PREFIX_SIZE)
#define RV_CS_MAX_CHANNEL_INDEX 78u
#define RV_CS_MAX_PHASE_MRAD 3142
#define RV_CS_MAX_RTT_PS 250000
#define RV_CS_MAX_FREQ_OFFSET_HZ 500000
#define RV_CS_MIN_STEP_COUNT 4u
#define RV_CS_MAX_STEP_COUNT 79u
#define RV_CS_FLAG_CALIBRATED (1u << 0)
#define RV_CS_FLAG_MOTION (1u << 1)
#define RV_CS_FLAGS_ALLOWED (RV_CS_FLAG_CALIBRATED | RV_CS_FLAG_MOTION)
typedef struct {
uint8_t flags;
uint8_t key_id;
uint32_t sequence;
uint32_t sample_age_us;
uint32_t source_id;
uint32_t source_session_id;
uint32_t procedure_id;
uint16_t channel_index;
uint16_t step_index;
uint16_t step_count;
uint16_t quality_permille;
uint16_t timing_uncertainty_us;
int32_t phase_millirad;
int32_t rtt_picoseconds;
int32_t frequency_offset_hz;
} rv_cs_measurement_t;
typedef enum {
RV_CS_PARSE_OK = 0,
RV_CS_PARSE_BAD_ARGUMENT,
RV_CS_PARSE_BAD_MAGIC,
RV_CS_PARSE_BAD_VERSION,
RV_CS_PARSE_BAD_LENGTH,
RV_CS_PARSE_BAD_FLAGS,
RV_CS_PARSE_BAD_CRC,
RV_CS_PARSE_BAD_SOURCE,
RV_CS_PARSE_BAD_SESSION,
RV_CS_PARSE_BAD_PROCEDURE,
RV_CS_PARSE_BAD_STEP,
RV_CS_PARSE_BAD_CHANNEL,
RV_CS_PARSE_BAD_QUALITY,
RV_CS_PARSE_BAD_PHASE,
RV_CS_PARSE_BAD_RTT,
RV_CS_PARSE_BAD_FREQUENCY_OFFSET,
RV_CS_PARSE_BAD_TIMING_UNCERTAINTY,
RV_CS_PARSE_STALE,
} rv_cs_parse_result_t;
uint32_t rv_cs_crc32(const uint8_t *data, size_t len);
/**
* Parse and validate a fixed frame. The companion supplies bounded sample age,
* not its unrelated monotonic timestamp. The gateway receive timestamp is
* assigned after authentication by the caller.
*
* @param data frame bytes
* @param len must equal RV_CS_FRAME_SIZE
* @param max_age_us maximum accepted age
* @param min_quality_permille minimum admitted quality
* @param out validated primitive
*/
rv_cs_parse_result_t rv_cs_parse_frame(const uint8_t *data,
size_t len,
uint32_t max_age_us,
uint16_t min_quality_permille,
rv_cs_measurement_t *out);
/** Build domain-separated HMAC input from the signed 52-byte prefix. */
void rv_cs_mac_input(const uint8_t frame[RV_CS_FRAME_SIZE],
uint8_t out[RV_CS_MAC_INPUT_SIZE]);
/** Constant-time comparison for the 128-bit companion authentication tag. */
bool rv_cs_auth_tag_equal(const uint8_t lhs[RV_CS_AUTH_TAG_SIZE],
const uint8_t rhs[RV_CS_AUTH_TAG_SIZE]);
/** True when candidate is strictly newer under uint32 wrap semantics. */
bool rv_cs_sequence_is_newer(uint32_t candidate, uint32_t previous);
#endif /* CHANNEL_SOUNDING_PROTOCOL_H */

View File

@@ -38,6 +38,9 @@
#include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */
#include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */
#include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */
#include "ble_identity.h" /* ADR-341: authenticated BLE anchors, no CTE IQ */
#include "channel_sounding_ingress.h" /* ADR-341: external BT6 CS primitives */
#include "radio_gateway_sender.h" /* ADR-341: bounded authenticated egress */
#ifdef CONFIG_CSI_MOCK_ENABLED
#include "mock_csi.h"
#endif
@@ -501,12 +504,32 @@ void app_main(void)
csi_collector_enable_data_capture();
}
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s, adapt=%s)",
/* ADR-341: both capabilities are opt-in. BLE uses bounded passive scan
* duty and forwards only authenticated rotating pseudonyms. Channel
* Sounding data can only arrive from a separate capable radio over UART;
* the ESP32-S3 itself exposes neither CTE IQ nor BT6 CS measurements. */
esp_err_t gateway_ret = radio_gateway_sender_init();
if (gateway_ret != ESP_OK && gateway_ret != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "Radio gateway envelope init failed: %s",
esp_err_to_name(gateway_ret));
}
esp_err_t ble_ret = ble_identity_init();
if (ble_ret != ESP_OK && ble_ret != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "BLE identity init failed: %s", esp_err_to_name(ble_ret));
}
esp_err_t cs_ret = channel_sounding_ingress_init();
if (cs_ret != ESP_OK && cs_ret != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "Channel Sounding companion init failed: %s", esp_err_to_name(cs_ret));
}
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, BLE-anchor=%s, BT6-CS=%s, swarm=%s, adapt=%s)",
g_nvs_config.target_ip, g_nvs_config.target_port,
g_nvs_config.edge_tier,
(ota_ret == ESP_OK) ? "ready" : "off",
(wasm_ret == ESP_OK) ? "ready" : "off",
(mmwave_ret == ESP_OK) ? "active" : "off",
(ble_ret == ESP_OK) ? "starting" : "off",
(cs_ret == ESP_OK) ? "external" : "off",
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off",
(adapt_ret == ESP_OK) ? "on" : "off");

View File

@@ -8,6 +8,7 @@
#include "nvs_config.h"
#include <stdbool.h>
#include <string.h>
#include "esp_log.h"
#include "nvs_flash.h"
@@ -16,6 +17,13 @@
static const char *TAG = "nvs_config";
static bool secret_has_nonzero_byte(const uint8_t *secret, size_t len)
{
uint8_t combined = 0u;
for (size_t index = 0u; index < len; index++) combined |= secret[index];
return combined != 0u;
}
void nvs_config_load(nvs_config_t *cfg)
{
if (cfg == NULL) {
@@ -94,6 +102,21 @@ void nvs_config_load(nvs_config_t *cfg)
cfg->filter_mac_set = 0;
memset(cfg->filter_mac, 0, 6);
/* ADR-341: BLE identity is runtime-off even in an enabled build. */
cfg->ble_identity_enabled = 0;
cfg->ble_key_id = 0;
cfg->ble_secret_valid = 0;
memset(cfg->ble_secret, 0, sizeof(cfg->ble_secret));
cfg->cs_ingress_enabled = 0;
cfg->cs_key_id = 0;
cfg->cs_secret_valid = 0;
memset(cfg->cs_secret, 0, sizeof(cfg->cs_secret));
cfg->cs_enrolled_source_id = 0u;
cfg->radio_envelope_key_id = 0u;
cfg->radio_envelope_secret_valid = 0u;
memset(cfg->radio_envelope_secret, 0,
sizeof(cfg->radio_envelope_secret));
/* Try to override from NVS */
nvs_handle_t handle;
esp_err_t err = nvs_open("csi_cfg", NVS_READONLY, &handle);
@@ -317,6 +340,81 @@ void nvs_config_load(nvs_config_t *cfg)
cfg->swarm_ingest_sec = 5;
}
/* ADR-341: BLE identity anchor. The secret is never printed. */
uint8_t ble_enable_val;
if (nvs_get_u8(handle, "ble_enable", &ble_enable_val) == ESP_OK) {
cfg->ble_identity_enabled = ble_enable_val ? 1u : 0u;
ESP_LOGI(TAG, "NVS override: ble_identity_enabled=%u",
(unsigned)cfg->ble_identity_enabled);
}
if (nvs_get_u8(handle, "ble_key_id", &cfg->ble_key_id) == ESP_OK) {
ESP_LOGI(TAG, "NVS override: ble_key_id=%u", (unsigned)cfg->ble_key_id);
}
size_t ble_secret_len = sizeof(cfg->ble_secret);
if (nvs_get_blob(handle, "ble_secret", cfg->ble_secret, &ble_secret_len) == ESP_OK
&& ble_secret_len == sizeof(cfg->ble_secret)
&& secret_has_nonzero_byte(cfg->ble_secret, sizeof(cfg->ble_secret))) {
cfg->ble_secret_valid = 1u;
ESP_LOGI(TAG, "NVS: ble_secret loaded (32 bytes)");
} else if (cfg->ble_identity_enabled) {
ESP_LOGW(TAG, "ble_enable=1 but exact 32-byte ble_secret is absent; scanner will fail closed");
}
uint8_t cs_enable_val;
if (nvs_get_u8(handle, "cs_enable", &cs_enable_val) == ESP_OK) {
cfg->cs_ingress_enabled = cs_enable_val ? 1u : 0u;
ESP_LOGI(TAG, "NVS override: cs_ingress_enabled=%u",
(unsigned)cfg->cs_ingress_enabled);
}
if (nvs_get_u8(handle, "cs_key_id", &cfg->cs_key_id) == ESP_OK) {
ESP_LOGI(TAG, "NVS override: cs_key_id=%u", (unsigned)cfg->cs_key_id);
}
size_t cs_secret_len = sizeof(cfg->cs_secret);
if (nvs_get_blob(handle, "cs_secret", cfg->cs_secret, &cs_secret_len) == ESP_OK
&& cs_secret_len == sizeof(cfg->cs_secret)
&& secret_has_nonzero_byte(cfg->cs_secret, sizeof(cfg->cs_secret))) {
cfg->cs_secret_valid = 1u;
ESP_LOGI(TAG, "NVS: cs_secret loaded (32 bytes)");
} else if (cfg->cs_ingress_enabled) {
ESP_LOGW(TAG, "cs_enable=1 but exact 32-byte cs_secret is absent; ingress will fail closed");
}
if (nvs_get_u32(handle, "cs_source_id", &cfg->cs_enrolled_source_id) == ESP_OK) {
ESP_LOGI(TAG, "NVS: enrolled Channel Sounding source id loaded");
}
if (nvs_get_u8(handle, "radio_key_id", &cfg->radio_envelope_key_id) == ESP_OK) {
ESP_LOGI(TAG, "NVS override: radio envelope key id=%u",
(unsigned)cfg->radio_envelope_key_id);
}
size_t radio_secret_len = sizeof(cfg->radio_envelope_secret);
if (nvs_get_blob(handle, "radio_secret", cfg->radio_envelope_secret,
&radio_secret_len) == ESP_OK
&& radio_secret_len == sizeof(cfg->radio_envelope_secret)
&& secret_has_nonzero_byte(cfg->radio_envelope_secret,
sizeof(cfg->radio_envelope_secret))) {
cfg->radio_envelope_secret_valid = 1u;
ESP_LOGI(TAG, "NVS: radio envelope secret loaded (32 bytes)");
} else if (cfg->ble_identity_enabled || cfg->cs_ingress_enabled) {
ESP_LOGW(TAG, "radio evidence enabled but gateway envelope key is absent; both paths fail closed");
}
bool reused_radio_key =
(cfg->ble_secret_valid && cfg->cs_secret_valid
&& memcmp(cfg->ble_secret, cfg->cs_secret,
sizeof(cfg->ble_secret)) == 0)
|| (cfg->ble_secret_valid && cfg->radio_envelope_secret_valid
&& memcmp(cfg->ble_secret, cfg->radio_envelope_secret,
sizeof(cfg->ble_secret)) == 0)
|| (cfg->cs_secret_valid && cfg->radio_envelope_secret_valid
&& memcmp(cfg->cs_secret, cfg->radio_envelope_secret,
sizeof(cfg->cs_secret)) == 0);
if (reused_radio_key) {
ESP_LOGE(TAG, "BLE, Channel Sounding and gateway envelope keys must be distinct");
cfg->ble_secret_valid = 0u;
cfg->cs_secret_valid = 0u;
cfg->radio_envelope_secret_valid = 0u;
}
/* Validate tdm_slot_index < tdm_node_count */
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",

View File

@@ -20,6 +20,7 @@
/** Maximum channels in the hop list (must match CSI_HOP_CHANNELS_MAX). */
#define NVS_CFG_HOP_MAX 6
#define NVS_CFG_BLE_SECRET_SIZE 32
/** Runtime configuration loaded from NVS or Kconfig defaults. */
typedef struct {
@@ -62,6 +63,24 @@ typedef struct {
char zone_name[16]; /**< Zone name for this node (e.g. "lobby"). */
uint16_t swarm_heartbeat_sec; /**< Heartbeat interval (seconds, default 30). */
uint16_t swarm_ingest_sec; /**< Vector ingest interval (seconds, default 5). */
/* ADR-341: Authenticated, rotating BLE identity tokens. */
uint8_t ble_identity_enabled; /**< Runtime opt-in; default 0. */
uint8_t ble_key_id; /**< Provisioned shared-key selector. */
uint8_t ble_secret[NVS_CFG_BLE_SECRET_SIZE]; /**< HMAC key; never logged. */
uint8_t ble_secret_valid; /**< Exact 32-byte secret was loaded. */
/* ADR-341: authenticated external Channel Sounding companion. */
uint8_t cs_ingress_enabled; /**< Runtime opt-in; default 0. */
uint8_t cs_key_id; /**< Separate companion key selector. */
uint8_t cs_secret[NVS_CFG_BLE_SECRET_SIZE]; /**< Separate HMAC key. */
uint8_t cs_secret_valid; /**< Exact 32-byte key loaded. */
uint32_t cs_enrolled_source_id; /**< Exact authenticated companion source. */
/* ADR-341: ESP32 gateway authentication around both radio payloads. */
uint8_t radio_envelope_key_id; /**< Gateway HMAC key selector. */
uint8_t radio_envelope_secret[NVS_CFG_BLE_SECRET_SIZE]; /**< Gateway HMAC key. */
uint8_t radio_envelope_secret_valid; /**< Exact 32-byte key loaded. */
} nvs_config_t;
/**

View File

@@ -0,0 +1,104 @@
/** @file radio_gateway_protocol.c */
#include "radio_gateway_protocol.h"
#include <string.h>
#ifdef ESP_PLATFORM
#include "mbedtls/constant_time.h"
#endif
const uint8_t RV_GATEWAY_MAC_DOMAIN[RV_GATEWAY_MAC_DOMAIN_SIZE] = {
'R', 'u', 'V', 'i', 'e', 'w', '/', 'G', 'W', '/', 'v', '1'
};
static void write_le16(uint8_t *p, uint16_t value)
{
p[0] = (uint8_t)value;
p[1] = (uint8_t)(value >> 8);
}
static void write_le32(uint8_t *p, uint32_t value)
{
p[0] = (uint8_t)value;
p[1] = (uint8_t)(value >> 8);
p[2] = (uint8_t)(value >> 16);
p[3] = (uint8_t)(value >> 24);
}
static void write_le64(uint8_t *p, uint64_t value)
{
for (unsigned i = 0u; i < 8u; i++) {
p[i] = (uint8_t)(value >> (8u * i));
}
}
static bool payload_size_is_valid(uint8_t payload_type, size_t payload_len)
{
if (payload_type == RV_GATEWAY_PAYLOAD_BLE_IDENTITY) {
return payload_len == 36u;
}
if (payload_type == RV_GATEWAY_PAYLOAD_CHANNEL_SOUNDING) {
return payload_len == 72u;
}
return false;
}
bool rv_gateway_build_unsigned(const rv_gateway_metadata_t *metadata,
const uint8_t *payload,
size_t payload_len,
uint8_t *out,
size_t out_capacity,
size_t *signed_len,
size_t *frame_len)
{
if (metadata == NULL || payload == NULL || out == NULL
|| signed_len == NULL || frame_len == NULL
|| !payload_size_is_valid(metadata->payload_type, payload_len)
|| (metadata->flags & ~RV_GATEWAY_FLAGS_ALLOWED) != 0u
|| (metadata->flags & RV_GATEWAY_FLAG_RX_MONOTONIC) == 0u
|| metadata->sequence == 0u || metadata->boot_nonce == 0u
|| payload_len > RV_GATEWAY_MAX_PAYLOAD_SIZE) {
return false;
}
size_t covered = RV_GATEWAY_HEADER_SIZE + payload_len;
size_t total = covered + RV_GATEWAY_AUTH_TAG_SIZE;
if (total > out_capacity || total > UINT16_MAX || payload_len > UINT16_MAX) {
return false;
}
memset(out, 0, total);
write_le32(&out[0], RV_GATEWAY_MAGIC);
out[4] = RV_GATEWAY_VERSION;
out[5] = metadata->payload_type;
out[6] = metadata->flags;
out[7] = metadata->key_id;
write_le16(&out[8], (uint16_t)total);
write_le16(&out[10], (uint16_t)payload_len);
out[12] = metadata->node_id;
/* bytes 13..15 are reserved and remain zero */
write_le32(&out[16], metadata->sequence);
write_le64(&out[20], metadata->boot_nonce);
write_le64(&out[28], metadata->received_at_boot_us);
write_le32(&out[36], metadata->timing_uncertainty_us);
memcpy(&out[RV_GATEWAY_HEADER_SIZE], payload, payload_len);
*signed_len = covered;
*frame_len = total;
return true;
}
bool rv_gateway_auth_tag_equal(const uint8_t lhs[RV_GATEWAY_AUTH_TAG_SIZE],
const uint8_t rhs[RV_GATEWAY_AUTH_TAG_SIZE])
{
if (lhs == NULL || rhs == NULL) return false;
#ifdef ESP_PLATFORM
return mbedtls_ct_memcmp(lhs, rhs, RV_GATEWAY_AUTH_TAG_SIZE) == 0;
#else
uint8_t diff = 0u;
for (size_t i = 0u; i < RV_GATEWAY_AUTH_TAG_SIZE; i++) {
diff |= (uint8_t)(lhs[i] ^ rhs[i]);
}
return diff == 0u;
#endif
}

View File

@@ -0,0 +1,64 @@
/**
* @file radio_gateway_protocol.h
* @brief Authenticated gateway envelope for privacy-sensitive radio evidence.
*/
#ifndef RADIO_GATEWAY_PROTOCOL_H
#define RADIO_GATEWAY_PROTOCOL_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define RV_GATEWAY_MAGIC 0x45415652u /* "RVAE" little endian */
#define RV_GATEWAY_VERSION 1u
#define RV_GATEWAY_HEADER_SIZE 40u
#define RV_GATEWAY_AUTH_TAG_SIZE 16u
#define RV_GATEWAY_MAC_DOMAIN_SIZE 12u
#define RV_GATEWAY_MAX_PAYLOAD_SIZE 72u
#define RV_GATEWAY_MAX_SIGNED_SIZE \
(RV_GATEWAY_HEADER_SIZE + RV_GATEWAY_MAX_PAYLOAD_SIZE)
#define RV_GATEWAY_MAX_FRAME_SIZE \
(RV_GATEWAY_MAX_SIGNED_SIZE + RV_GATEWAY_AUTH_TAG_SIZE)
#define RV_GATEWAY_PAYLOAD_BLE_IDENTITY 1u
#define RV_GATEWAY_PAYLOAD_CHANNEL_SOUNDING 2u
#define RV_GATEWAY_FLAG_RX_MONOTONIC (1u << 0)
#define RV_GATEWAY_FLAGS_ALLOWED RV_GATEWAY_FLAG_RX_MONOTONIC
/** HMAC domain separator; not NUL terminated on the wire. */
extern const uint8_t RV_GATEWAY_MAC_DOMAIN[RV_GATEWAY_MAC_DOMAIN_SIZE];
/** Metadata captured by the ESP32 gateway before asynchronous UDP egress. */
typedef struct {
uint8_t payload_type;
uint8_t flags;
uint8_t key_id;
uint8_t node_id;
uint32_t sequence;
uint64_t boot_nonce;
uint64_t received_at_boot_us;
uint32_t timing_uncertainty_us;
} rv_gateway_metadata_t;
/**
* Build the authenticated prefix and reserve a trailing 16-byte tag.
*
* The caller HMACs `RV_GATEWAY_MAC_DOMAIN || out[0..signed_len]`, copies the
* first 16 digest bytes to `out[signed_len..frame_len]`, then sends exactly
* `frame_len` bytes.
*/
bool rv_gateway_build_unsigned(const rv_gateway_metadata_t *metadata,
const uint8_t *payload,
size_t payload_len,
uint8_t *out,
size_t out_capacity,
size_t *signed_len,
size_t *frame_len);
/** Constant-time comparison for a truncated gateway authentication tag. */
bool rv_gateway_auth_tag_equal(const uint8_t lhs[RV_GATEWAY_AUTH_TAG_SIZE],
const uint8_t rhs[RV_GATEWAY_AUTH_TAG_SIZE]);
#endif /* RADIO_GATEWAY_PROTOCOL_H */

View File

@@ -0,0 +1,187 @@
/** @file radio_gateway_sender.c */
#include "radio_gateway_sender.h"
#include "sdkconfig.h"
#if defined(CONFIG_BLE_IDENTITY_SCAN_ENABLE) || \
defined(CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE)
#include <limits.h>
#include <stdatomic.h>
#include <string.h>
#include "esp_log.h"
#include "esp_random.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "mbedtls/md.h"
#include "csi_collector.h"
#include "nvs_config.h"
#include "radio_gateway_protocol.h"
#include "stream_sender.h"
extern nvs_config_t g_nvs_config;
static const char *TAG = "radio_gateway";
typedef struct {
uint8_t payload_type;
uint8_t payload_len;
uint8_t payload[RV_GATEWAY_MAX_PAYLOAD_SIZE];
uint64_t received_at_boot_us;
uint32_t timing_uncertainty_us;
} gateway_queue_item_t;
static QueueHandle_t s_queue;
static uint64_t s_boot_nonce;
static uint32_t s_sequence;
static atomic_bool s_ready;
static uint32_t s_delivery_drops;
static void sender_task(void *arg)
{
(void)arg;
gateway_queue_item_t item;
uint8_t frame[RV_GATEWAY_MAX_FRAME_SIZE];
uint8_t covered[RV_GATEWAY_MAC_DOMAIN_SIZE + RV_GATEWAY_MAX_SIGNED_SIZE];
uint8_t digest[32];
for (;;) {
if (xQueueReceive(s_queue, &item, portMAX_DELAY) != pdTRUE) continue;
if (s_sequence == UINT32_MAX) {
ESP_LOGE(TAG, "gateway sequence exhausted; reboot or rekey required");
atomic_store_explicit(&s_ready, false, memory_order_release);
continue;
}
rv_gateway_metadata_t metadata = {
.payload_type = item.payload_type,
.flags = RV_GATEWAY_FLAG_RX_MONOTONIC,
.key_id = g_nvs_config.radio_envelope_key_id,
.node_id = csi_collector_get_node_id(),
.sequence = ++s_sequence,
.boot_nonce = s_boot_nonce,
.received_at_boot_us = item.received_at_boot_us,
.timing_uncertainty_us = item.timing_uncertainty_us,
};
size_t signed_len = 0u;
size_t frame_len = 0u;
if (!rv_gateway_build_unsigned(&metadata, item.payload, item.payload_len,
frame, sizeof(frame), &signed_len,
&frame_len)) {
ESP_LOGE(TAG, "internal gateway envelope construction failed");
continue;
}
memcpy(covered, RV_GATEWAY_MAC_DOMAIN, RV_GATEWAY_MAC_DOMAIN_SIZE);
memcpy(&covered[RV_GATEWAY_MAC_DOMAIN_SIZE], frame, signed_len);
const mbedtls_md_info_t *sha256 =
mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (sha256 == NULL
|| mbedtls_md_hmac(sha256, g_nvs_config.radio_envelope_secret,
sizeof(g_nvs_config.radio_envelope_secret),
covered,
RV_GATEWAY_MAC_DOMAIN_SIZE + signed_len,
digest) != 0) {
ESP_LOGE(TAG, "gateway envelope HMAC failed");
continue;
}
memcpy(&frame[signed_len], digest, RV_GATEWAY_AUTH_TAG_SIZE);
/* Radio evidence is bulk data, not the <=48-byte <=1 Hz priority
* control path. Normal ENOMEM backpressure applies. */
if (stream_sender_send(frame, frame_len) != (int)frame_len) {
s_delivery_drops++;
if ((s_delivery_drops & 63u) == 1u) {
ESP_LOGW(TAG, "radio envelope UDP delivery dropped (%lu total)",
(unsigned long)s_delivery_drops);
}
}
}
}
esp_err_t radio_gateway_sender_init(void)
{
if (!g_nvs_config.ble_identity_enabled && !g_nvs_config.cs_ingress_enabled) {
return ESP_ERR_NOT_SUPPORTED;
}
if (!g_nvs_config.radio_envelope_secret_valid) {
ESP_LOGE(TAG, "radio evidence enabled without gateway envelope key");
return ESP_ERR_INVALID_STATE;
}
if (atomic_load_explicit(&s_ready, memory_order_acquire)) return ESP_OK;
do {
esp_fill_random(&s_boot_nonce, sizeof(s_boot_nonce));
} while (s_boot_nonce == 0u);
s_sequence = 0u;
s_queue = xQueueCreate(CONFIG_RADIO_GATEWAY_QUEUE_DEPTH,
sizeof(gateway_queue_item_t));
if (s_queue == NULL) return ESP_ERR_NO_MEM;
if (xTaskCreate(sender_task, "radio_gateway", 4096, NULL, 5, NULL)
!= pdPASS) {
vQueueDelete(s_queue);
s_queue = NULL;
return ESP_ERR_NO_MEM;
}
atomic_store_explicit(&s_ready, true, memory_order_release);
ESP_LOGI(TAG, "authenticated radio envelope ready (queue=%d)",
CONFIG_RADIO_GATEWAY_QUEUE_DEPTH);
return ESP_OK;
}
bool radio_gateway_sender_is_ready(void)
{
return atomic_load_explicit(&s_ready, memory_order_acquire);
}
esp_err_t radio_gateway_sender_enqueue(uint8_t payload_type,
const uint8_t *payload,
size_t payload_len,
uint64_t received_at_boot_us,
uint32_t timing_uncertainty_us)
{
if (!atomic_load_explicit(&s_ready, memory_order_acquire)
|| s_queue == NULL) return ESP_ERR_INVALID_STATE;
if (payload == NULL || payload_len > RV_GATEWAY_MAX_PAYLOAD_SIZE) {
return ESP_ERR_INVALID_ARG;
}
gateway_queue_item_t item = {
.payload_type = payload_type,
.payload_len = (uint8_t)payload_len,
.received_at_boot_us = received_at_boot_us,
.timing_uncertainty_us = timing_uncertainty_us,
};
memcpy(item.payload, payload, payload_len);
return xQueueSend(s_queue, &item, 0) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
#else
esp_err_t radio_gateway_sender_init(void)
{
return ESP_ERR_NOT_SUPPORTED;
}
bool radio_gateway_sender_is_ready(void)
{
return false;
}
esp_err_t radio_gateway_sender_enqueue(uint8_t payload_type,
const uint8_t *payload,
size_t payload_len,
uint64_t received_at_boot_us,
uint32_t timing_uncertainty_us)
{
(void)payload_type;
(void)payload;
(void)payload_len;
(void)received_at_boot_us;
(void)timing_uncertainty_us;
return ESP_ERR_NOT_SUPPORTED;
}
#endif

View File

@@ -0,0 +1,31 @@
/**
* @file radio_gateway_sender.h
* @brief Bounded asynchronous sender for authenticated radio envelopes.
*/
#ifndef RADIO_GATEWAY_SENDER_H
#define RADIO_GATEWAY_SENDER_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
/** Initialize the boot session, bounded queue, and sender task. */
esp_err_t radio_gateway_sender_init(void);
/** Whether authenticated envelope egress is ready. */
bool radio_gateway_sender_is_ready(void);
/**
* Nonblocking enqueue of an already sanitized radio payload.
* Returns `ESP_ERR_TIMEOUT` when backpressure drops the record.
*/
esp_err_t radio_gateway_sender_enqueue(uint8_t payload_type,
const uint8_t *payload,
size_t payload_len,
uint64_t received_at_boot_us,
uint32_t timing_uncertainty_us);
#endif /* RADIO_GATEWAY_SENDER_H */

View File

@@ -7,9 +7,12 @@
#include "stream_sender.h"
#include <stdatomic.h>
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "lwip/sockets.h"
#include "lwip/netdb.h"
#include "sdkconfig.h"
@@ -18,6 +21,8 @@ static const char *TAG = "stream_sender";
static int s_sock = -1;
static struct sockaddr_in s_dest_addr;
static SemaphoreHandle_t s_send_lock;
static atomic_uint_fast32_t s_lock_contention_drops;
/**
* ENOMEM backoff state.
@@ -39,6 +44,13 @@ static uint32_t s_enomem_streak = 0;
static int sender_init_internal(const char *ip, uint16_t port)
{
if (s_send_lock == NULL) {
s_send_lock = xSemaphoreCreateMutex();
if (s_send_lock == NULL) {
ESP_LOGE(TAG, "Failed to create UDP sender mutex");
return -1;
}
}
s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s_sock < 0) {
ESP_LOGE(TAG, "Failed to create socket: errno %d", errno);
@@ -72,9 +84,22 @@ int stream_sender_init_with(const char *ip, uint16_t port)
int stream_sender_send(const uint8_t *data, size_t len)
{
if (s_sock < 0) {
if (data == NULL || len == 0u || s_send_lock == NULL) {
return -1;
}
/* CSI, edge DSP, WASM and ADR-341 tasks share one socket and one backoff
* state machine. Never block a radio callback behind UDP I/O. */
if (xSemaphoreTake(s_send_lock, 0) != pdTRUE) {
uint_fast32_t drops = atomic_fetch_add_explicit(
&s_lock_contention_drops, 1u, memory_order_relaxed) + 1u;
if ((drops % ENOMEM_LOG_INTERVAL) == 1u) {
ESP_LOGW(TAG, "concurrent UDP send dropped (%lu total)",
(unsigned long)drops);
}
return -1;
}
int result = -1;
if (s_sock < 0) goto done;
/* ENOMEM backoff: if we recently exhausted lwIP buffers, skip sends
* until the cooldown expires. This prevents the cascade of failed
@@ -87,7 +112,7 @@ int stream_sender_send(const uint8_t *data, size_t len)
ESP_LOGW(TAG, "sendto suppressed (ENOMEM backoff, %lu dropped)",
(unsigned long)s_enomem_suppressed);
}
return -1;
goto done;
}
/* Cooldown expired — resume sending */
ESP_LOGI(TAG, "ENOMEM backoff expired, resuming sends (%lu were suppressed)",
@@ -113,17 +138,30 @@ int stream_sender_send(const uint8_t *data, size_t len)
} else {
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
}
return -1;
goto done;
}
/* A send got through — buffer pressure cleared; reset the backoff streak. */
s_enomem_streak = 0;
return sent;
result = sent;
done:
xSemaphoreGive(s_send_lock);
return result;
}
int stream_sender_send_priority(const uint8_t *data, size_t len)
{
if (data == NULL || len == 0u || s_send_lock == NULL) {
return -1;
}
if (xSemaphoreTake(s_send_lock, 0) != pdTRUE) {
(void)atomic_fetch_add_explicit(
&s_lock_contention_drops, 1u, memory_order_relaxed);
return -1;
}
if (s_sock < 0) {
xSemaphoreGive(s_send_lock);
return -1;
}
@@ -144,16 +182,22 @@ int stream_sender_send_priority(const uint8_t *data, size_t len)
if (errno != ENOMEM) {
ESP_LOGW(TAG, "priority sendto failed: errno %d", errno);
}
xSemaphoreGive(s_send_lock);
return -1;
}
xSemaphoreGive(s_send_lock);
return sent;
}
void stream_sender_deinit(void)
{
if (s_send_lock != NULL && xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) {
return;
}
if (s_sock >= 0) {
close(s_sock);
s_sock = -1;
ESP_LOGI(TAG, "UDP sender closed");
}
if (s_send_lock != NULL) xSemaphoreGive(s_send_lock);
}

View File

@@ -79,9 +79,24 @@ CONFIG_VALUE_CHECKS = [
("zone", lambda value: value is not None),
("swarm_hb", lambda value: value is not None),
("swarm_ingest", lambda value: value is not None),
("ble_identity_enable", lambda value: value is not None),
("ble_key_id", lambda value: value is not None),
("cs_ingress_enable", lambda value: value is not None),
("cs_key_id", lambda value: value is not None),
("cs_source_id", lambda value: value is not None),
("radio_envelope_key_id", lambda value: value is not None),
]
SECRET_VALUE_ATTRS = (
"password",
"seed_token",
"ble_secret_bytes",
"cs_secret_bytes",
"radio_envelope_secret_bytes",
)
def has_config_value(args):
"""Return True when args include at least one NVS-writing config value."""
return any(
@@ -108,6 +123,9 @@ MERGEABLE_ATTRS = [
"channel", "filter_mac",
"hop_channels", "hop_dwell",
"seed_url", "seed_token", "zone", "swarm_hb", "swarm_ingest",
"ble_identity_enable", "ble_key_id",
"cs_ingress_enable", "cs_key_id", "cs_source_id",
"radio_envelope_key_id",
]
@@ -148,12 +166,26 @@ def save_state(port: str, state_dir: str, state: dict) -> str:
"""Write `state` to the per-port file, creating dirs as needed. Returns path."""
os.makedirs(state_dir, exist_ok=True)
path = _state_path_for(port, state_dir)
# Sort keys for deterministic on-disk content (easier to diff).
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp, path)
# The merge state can contain a WiFi password or bearer token. Create the
# temporary file atomically at 0600 and re-assert that mode after replace,
# including when an older, overly broad file already existed.
fd, tmp = tempfile.mkstemp(prefix=".provision-state-", dir=state_dir, text=True)
try:
_restrict_file_permissions(fd=fd)
with os.fdopen(fd, "w", encoding="utf-8") as state_file:
fd = None
json.dump(state, state_file, indent=2, sort_keys=True)
state_file.write("\n")
state_file.flush()
os.fsync(state_file.fileno())
os.replace(tmp, path)
_restrict_file_permissions(path=path)
except Exception:
if fd is not None:
os.close(fd)
if os.path.exists(tmp):
os.unlink(tmp)
raise
return path
@@ -174,6 +206,39 @@ def merge_state_into_args(args, prior: dict) -> dict:
return merged
def has_secret_value(args):
"""Return True if generated output would contain credential material."""
return any(getattr(args, name, None) is not None for name in SECRET_VALUE_ATTRS)
def _secret_hex(secret, purpose):
"""Validate an in-memory HMAC secret before adding it to NVS CSV."""
if not isinstance(secret, (bytes, bytearray)) or len(secret) != 32:
raise ValueError(f"{purpose} secret must be exactly 32 bytes")
if not any(secret):
raise ValueError(f"{purpose} secret must not be all zero")
return bytes(secret).hex()
def validate_distinct_radio_secrets(ble_secret, cs_secret, radio_secret):
"""Reject absent key separation before any secret reaches generated NVS."""
named = [
("BLE", ble_secret),
("Channel Sounding", cs_secret),
("Gateway envelope", radio_secret),
]
present = [(name, bytes(secret)) for name, secret in named if secret is not None]
for name, secret in present:
if len(secret) != 32 or not any(secret):
raise ValueError(f"{name} secret must be a nonzero 32-byte key")
for index, (left_name, left_secret) in enumerate(present):
for right_name, right_secret in present[index + 1:]:
if left_secret == right_secret:
raise ValueError(
f"{left_name} and {right_name} secrets must be independently generated"
)
def build_nvs_csv(args):
"""Build an NVS CSV string for the csi_cfg namespace."""
buf = io.StringIO()
@@ -234,16 +299,137 @@ def build_nvs_csv(args):
writer.writerow(["swarm_hb", "data", "u16", str(args.swarm_hb)])
if args.swarm_ingest is not None:
writer.writerow(["swarm_ingest", "data", "u16", str(args.swarm_ingest)])
# ADR-341: BLE identity is opt-in and requires a 32-byte secret supplied
# for this invocation. The secret is written to NVS but never to the
# additive JSON state file.
if getattr(args, "ble_identity_enable", None) is not None:
writer.writerow(["ble_enable", "data", "u8", str(args.ble_identity_enable)])
if getattr(args, "ble_key_id", None) is not None:
writer.writerow(["ble_key_id", "data", "u8", str(args.ble_key_id)])
ble_secret = getattr(args, "ble_secret_bytes", None)
if ble_secret is not None:
writer.writerow([
"ble_secret", "data", "hex2bin", _secret_hex(ble_secret, "BLE")
])
if getattr(args, "cs_ingress_enable", None) is not None:
writer.writerow(["cs_enable", "data", "u8", str(args.cs_ingress_enable)])
if getattr(args, "cs_key_id", None) is not None:
writer.writerow(["cs_key_id", "data", "u8", str(args.cs_key_id)])
cs_secret = getattr(args, "cs_secret_bytes", None)
if cs_secret is not None:
writer.writerow([
"cs_secret", "data", "hex2bin",
_secret_hex(cs_secret, "Channel Sounding")
])
if getattr(args, "cs_source_id", None) is not None:
writer.writerow(["cs_source_id", "data", "u32", str(args.cs_source_id)])
if getattr(args, "radio_envelope_key_id", None) is not None:
writer.writerow([
"radio_key_id", "data", "u8", str(args.radio_envelope_key_id)
])
radio_secret = getattr(args, "radio_envelope_secret_bytes", None)
if radio_secret is not None:
writer.writerow([
"radio_secret", "data", "hex2bin",
_secret_hex(radio_secret, "Gateway envelope")
])
return buf.getvalue()
def load_ble_secret(path, purpose="BLE"):
"""Read an exact 32-byte HMAC key without persisting or printing it.
Accepted forms are exactly 32 raw bytes or 64 ASCII hexadecimal
characters, optionally followed by one LF or CRLF. No other whitespace or
trailing bytes are accepted, and EOF is checked explicitly.
"""
if path is None:
return None
with open(path, "rb") as secret_file:
# CRLF makes 66 bytes the largest accepted representation. Read one
# byte beyond that bound to prove the file ended.
raw = secret_file.read(66)
trailing = secret_file.read(1)
if trailing:
raise ValueError(f"{purpose} secret file contains trailing data")
if len(raw) == 32:
decoded = raw
if not any(decoded):
raise ValueError(f"{purpose} secret must not be all zero")
return decoded
if len(raw) == 65 and raw.endswith(b"\n"):
encoded = raw[:-1]
elif len(raw) == 66 and raw.endswith(b"\r\n"):
encoded = raw[:-2]
elif len(raw) == 64:
encoded = raw
else:
raise ValueError(
f"{purpose} secret file must contain exactly 32 raw bytes or 64 hex characters"
)
if not all(byte in b"0123456789abcdefABCDEF" for byte in encoded):
raise ValueError(f"{purpose} secret file is not valid hexadecimal")
decoded = bytes.fromhex(encoded.decode("ascii"))
if not any(decoded):
raise ValueError(f"{purpose} secret must not be all zero")
return decoded
def _restrict_file_permissions(path=None, fd=None):
"""Apply owner-read/write-only permissions to a secret-bearing file."""
if fd is not None and hasattr(os, "fchmod"):
os.fchmod(fd, 0o600)
elif path is not None:
os.chmod(path, 0o600)
def _write_private_bytes(path, content):
"""Create or replace a binary output without a world-readable interval."""
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_BINARY"):
flags |= os.O_BINARY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(path, flags, 0o600)
try:
_restrict_file_permissions(fd=fd)
with os.fdopen(fd, "wb") as output_file:
fd = None
output_file.write(content)
finally:
if fd is not None:
os.close(fd)
def _write_private_text(path, content):
"""Create or replace a text output without a world-readable interval."""
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(path, flags, 0o600)
try:
_restrict_file_permissions(fd=fd)
with os.fdopen(fd, "w", encoding="utf-8", newline="") as output_file:
fd = None
output_file.write(content)
finally:
if fd is not None:
os.close(fd)
def generate_nvs_binary(csv_content, size):
"""Generate an NVS partition binary from CSV using nvs_partition_gen.py."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f_csv:
_restrict_file_permissions(fd=f_csv.fileno())
f_csv.write(csv_content)
csv_path = f_csv.name
bin_path = csv_path.replace(".csv", ".bin")
# Pre-create the generator output privately. Generators truncate this
# regular file in place, preserving 0600 throughout its lifetime.
_write_private_bytes(bin_path, b"")
try:
# Method 1: subprocess invocation (most reliable across package versions)
@@ -254,6 +440,7 @@ def generate_nvs_binary(csv_content, size):
csv_path, bin_path, hex(size)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
_restrict_file_permissions(path=bin_path)
with open(bin_path, "rb") as f:
return f.read()
except (subprocess.CalledProcessError, FileNotFoundError):
@@ -270,6 +457,7 @@ def generate_nvs_binary(csv_content, size):
sys.executable, gen_script, "generate",
csv_path, bin_path, hex(size)
])
_restrict_file_permissions(path=bin_path)
with open(bin_path, "rb") as f:
return f.read()
@@ -287,6 +475,7 @@ def generate_nvs_binary(csv_content, size):
def flash_nvs(port, baud, nvs_bin, chip):
"""Flash the NVS partition binary to the ESP32."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
_restrict_file_permissions(fd=f.fileno())
f.write(nvs_bin)
bin_path = f.name
@@ -306,6 +495,17 @@ def flash_nvs(port, baud, nvs_bin, chip):
os.unlink(bin_path)
def _nonzero_u32(value):
"""Argparse converter for an enrolled, nonzero uint32 identifier."""
try:
parsed = int(value, 0)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be an integer") from exc
if not 1 <= parsed <= 0xFFFFFFFF:
raise argparse.ArgumentTypeError("must be between 1 and 4294967295")
return parsed
def main():
parser = argparse.ArgumentParser(
description="Provision CSI node NVS (WiFi + aggregator); works on S3, C6, etc.",
@@ -354,6 +554,28 @@ def main():
parser.add_argument("--zone", type=str, help="Zone name for this node (e.g. lobby, hallway)")
parser.add_argument("--swarm-hb", type=int, help="Swarm heartbeat interval in seconds (default 30)")
parser.add_argument("--swarm-ingest", type=int, help="Swarm vector ingest interval in seconds (default 5)")
# ADR-341: authenticated rotating BLE service tokens. The secret path and
# bytes are intentionally excluded from MERGEABLE_ATTRS/state JSON.
parser.add_argument("--ble-identity-enable", type=int, choices=[0, 1],
help="Runtime BLE identity scanner switch; firmware build option is also required")
parser.add_argument("--ble-key-id", type=int, choices=range(0, 256), metavar="0..255",
help="Shared BLE HMAC key selector")
parser.add_argument("--ble-secret-file", type=str,
help="File containing exactly 32 raw key bytes or 64 hex characters; never persisted")
parser.add_argument("--cs-ingress-enable", type=int, choices=[0, 1],
help="Runtime external Channel Sounding UART switch; firmware build option is also required")
parser.add_argument("--cs-key-id", type=int, choices=range(0, 256), metavar="0..255",
help="Separate Channel Sounding companion HMAC key selector")
parser.add_argument("--cs-secret-file", type=str,
help="File containing the separate 32-byte companion key; never persisted")
parser.add_argument("--cs-source-id", type=_nonzero_u32, metavar="1..4294967295",
help="Enrolled nonzero Channel Sounding companion source identifier")
parser.add_argument("--radio-envelope-key-id", "--gateway-envelope-key-id",
dest="radio_envelope_key_id", type=int, choices=range(0, 256),
metavar="0..255", help="Gateway radio-envelope HMAC key selector")
parser.add_argument("--radio-envelope-secret-file", "--gateway-envelope-secret-file",
dest="radio_envelope_secret_file", type=str,
help="File containing the separate 32-byte gateway envelope key; never persisted")
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
parser.add_argument("--force-partial", action="store_true",
help="[deprecated since #391/#574] Suppress the missing-WiFi-trio "
@@ -386,6 +608,94 @@ def main():
print(json.dumps(merged, indent=2, sort_keys=True))
return
try:
args.ble_secret_bytes = load_ble_secret(args.ble_secret_file, "BLE")
args.cs_secret_bytes = load_ble_secret(args.cs_secret_file, "Channel Sounding")
args.radio_envelope_secret_bytes = load_ble_secret(
args.radio_envelope_secret_file, "Gateway envelope"
)
except (OSError, ValueError) as exc:
parser.error(f"Could not load radio HMAC secret: {exc}")
try:
validate_distinct_radio_secrets(
args.ble_secret_bytes,
args.cs_secret_bytes,
args.radio_envelope_secret_bytes,
)
except ValueError as exc:
parser.error(str(exc))
for option, value in [
("--ble-identity-enable", args.ble_identity_enable),
("--cs-ingress-enable", args.cs_ingress_enable),
]:
if value is not None and (type(value) is not int or value not in (0, 1)):
parser.error(f"{option} must be either 0 or 1")
for option, value in [
("--ble-key-id", args.ble_key_id),
("--cs-key-id", args.cs_key_id),
("--radio-envelope-key-id", args.radio_envelope_key_id),
]:
if value is not None and (
isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255
):
parser.error(f"{option} must be between 0 and 255")
if args.cs_source_id is not None and (
isinstance(args.cs_source_id, bool)
or not isinstance(args.cs_source_id, int)
or not 1 <= args.cs_source_id <= 0xFFFFFFFF
):
parser.error("--cs-source-id must be a nonzero 32-bit integer")
if args.ble_identity_enable == 1:
if args.ble_key_id is None:
parser.error("--ble-key-id is required when BLE identity is enabled")
if args.ble_secret_bytes is None:
parser.error(
"--ble-secret-file is required on every provisioning run while BLE identity is enabled; "
"the key is intentionally not stored in the local merge-state JSON"
)
if args.cs_ingress_enable == 1:
if args.cs_key_id is None:
parser.error("--cs-key-id is required when Channel Sounding ingress is enabled")
if args.cs_secret_bytes is None:
parser.error(
"--cs-secret-file is required on every provisioning run while Channel Sounding ingress is enabled; "
"the key is intentionally not stored in the local merge-state JSON"
)
if args.cs_source_id is None:
parser.error(
"--cs-source-id is required and must be nonzero when Channel Sounding ingress is enabled"
)
radio_evidence_enabled = (
args.ble_identity_enable == 1 or args.cs_ingress_enable == 1
)
if radio_evidence_enabled:
if args.radio_envelope_key_id is None:
parser.error(
"--radio-envelope-key-id is required when BLE identity or Channel Sounding ingress is enabled"
)
if args.radio_envelope_secret_bytes is None:
parser.error(
"--radio-envelope-secret-file is required on every provisioning run while radio evidence is enabled; "
"the gateway key is intentionally not stored in local merge-state JSON"
)
for key_option, key_id, secret_option, secret in [
("--ble-key-id", args.ble_key_id, "--ble-secret-file", args.ble_secret_bytes),
("--cs-key-id", args.cs_key_id, "--cs-secret-file", args.cs_secret_bytes),
(
"--radio-envelope-key-id",
args.radio_envelope_key_id,
"--radio-envelope-secret-file",
args.radio_envelope_secret_bytes,
),
]:
if secret is not None and key_id is None:
parser.error(f"{key_option} is required when {secret_option} is supplied")
if not has_config_value(args):
parser.error(
"At least one config value must be specified (after merging prior state). "
@@ -478,6 +788,24 @@ def main():
print(f" Swarm HB: {args.swarm_hb}s")
if args.swarm_ingest is not None:
print(f" Swarm Ingest: {args.swarm_ingest}s")
if args.ble_identity_enable is not None:
print(f" BLE Identity: {'enabled' if args.ble_identity_enable else 'disabled'}")
if args.ble_key_id is not None:
print(f" BLE Key ID: {args.ble_key_id}")
if args.ble_secret_bytes is not None:
print(" BLE Secret: (32-byte key loaded; not persisted locally)")
if args.cs_ingress_enable is not None:
print(f" CS Ingress: {'enabled' if args.cs_ingress_enable else 'disabled'}")
if args.cs_key_id is not None:
print(f" CS Key ID: {args.cs_key_id}")
if args.cs_secret_bytes is not None:
print(" CS Secret: (separate 32-byte key loaded; not persisted locally)")
if args.cs_source_id is not None:
print(f" CS Source ID: {args.cs_source_id}")
if args.radio_envelope_key_id is not None:
print(f" Radio Key ID: {args.radio_envelope_key_id}")
if args.radio_envelope_secret_bytes is not None:
print(" Radio Secret: (separate 32-byte key loaded; not persisted locally)")
csv_content = build_nvs_csv(args)
@@ -485,10 +813,15 @@ def main():
nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE)
except Exception as e:
print(f"\nError generating NVS binary: {e}", file=sys.stderr)
if has_secret_value(args):
print(
"Refusing to persist fallback NVS CSV because the configuration contains secrets.",
file=sys.stderr,
)
sys.exit(1)
print("\nFallback: save CSV and flash manually with ESP-IDF tools.", file=sys.stderr)
fallback_path = "nvs_config.csv"
with open(fallback_path, "w") as f:
f.write(csv_content)
_write_private_text(fallback_path, csv_content)
print(f"Saved NVS CSV to {fallback_path}", file=sys.stderr)
print(f"Flash with: python $IDF_PATH/components/nvs_flash/"
f"nvs_partition_generator/nvs_partition_gen.py generate "
@@ -497,8 +830,7 @@ def main():
if args.dry_run:
out = "nvs_provision.bin"
with open(out, "wb") as f:
f.write(nvs_bin)
_write_private_bytes(out, nvs_bin)
print(f"NVS binary saved to {out} ({len(nvs_bin)} bytes)")
print(f"Flash manually: python -m esptool --chip {args.chip} --port {args.port} "
f"write_flash 0x9000 {out}")

View File

@@ -29,7 +29,7 @@ FEATURE_STATE_SRCS := $(MAIN_DIR)/rv_feature_state.c
# before including the .c. The decide() body itself has no ESP-IDF deps.
# Simpler: just recompile decide() here via a small shim.
TESTS := test_adaptive_controller test_rv_feature_state test_rv_mesh
TESTS := test_adaptive_controller test_rv_feature_state test_rv_mesh test_ble_cs_protocol
all: $(TESTS)
@@ -46,12 +46,23 @@ test_rv_mesh: test_rv_mesh.c $(MAIN_DIR)/rv_mesh.c $(MAIN_DIR)/rv_mesh.h $(FEATU
test_rv_mesh.c $(MAIN_DIR)/rv_mesh.c $(FEATURE_STATE_SRCS) \
-o $@ $(LDLIBS)
test_ble_cs_protocol: test_ble_cs_protocol.c \
$(MAIN_DIR)/ble_identity_protocol.c $(MAIN_DIR)/ble_identity_protocol.h \
$(MAIN_DIR)/channel_sounding_protocol.c $(MAIN_DIR)/channel_sounding_protocol.h \
$(MAIN_DIR)/radio_gateway_protocol.c $(MAIN_DIR)/radio_gateway_protocol.h
$(CC) $(CFLAGS) test_ble_cs_protocol.c \
$(MAIN_DIR)/ble_identity_protocol.c $(MAIN_DIR)/channel_sounding_protocol.c \
$(MAIN_DIR)/radio_gateway_protocol.c \
-o $@ $(LDLIBS)
check: all
./test_adaptive_controller
@echo ""
./test_rv_feature_state
@echo ""
./test_rv_mesh
@echo ""
./test_ble_cs_protocol
clean:
rm -f $(TESTS) *.o

View File

@@ -0,0 +1,201 @@
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "ble_identity_protocol.h"
#include "channel_sounding_protocol.h"
#include "radio_gateway_protocol.h"
static void put16(uint8_t *p, uint16_t value)
{
p[0] = (uint8_t)value;
p[1] = (uint8_t)(value >> 8);
}
static void put32(uint8_t *p, uint32_t value)
{
p[0] = (uint8_t)value;
p[1] = (uint8_t)(value >> 8);
p[2] = (uint8_t)(value >> 16);
p[3] = (uint8_t)(value >> 24);
}
static size_t build_ble_advert(uint8_t *advert, size_t cap)
{
const size_t total = RV_BLE_SERVICE_DATA_SIZE + 2u;
assert(cap >= total);
advert[0] = (uint8_t)(RV_BLE_SERVICE_DATA_SIZE + 1u);
advert[1] = RV_BLE_AD_TYPE_SERVICE_DATA_UUID128;
memcpy(&advert[2], RV_BLE_SERVICE_UUID_LE, 16u);
uint8_t *body = &advert[18];
body[0] = RV_BLE_TOKEN_VERSION;
body[1] = 7u;
put32(&body[2], 30000000u);
put32(&body[6], 42u);
for (size_t i = 0u; i < RV_BLE_EPHEMERAL_ID_SIZE; i++) body[10 + i] = (uint8_t)(0xa0u + i);
for (size_t i = 0u; i < RV_BLE_AUTH_TAG_SIZE; i++) body[18 + i] = (uint8_t)(0x10u + i);
return total;
}
static void build_cs_frame(uint8_t frame[RV_CS_FRAME_SIZE])
{
memset(frame, 0, RV_CS_FRAME_SIZE);
put32(&frame[0], RV_CS_MAGIC);
frame[4] = RV_CS_VERSION;
frame[5] = RV_CS_FLAG_CALIBRATED;
frame[6] = 4u;
frame[7] = 0u;
put16(&frame[8], RV_CS_FRAME_SIZE);
put16(&frame[10], 37u);
put32(&frame[12], 9u);
put32(&frame[16], 10000u);
put32(&frame[20], 0x11223344u);
put16(&frame[24], 800u);
put16(&frame[26], 250u);
put32(&frame[28], (uint32_t)-1200);
put32(&frame[32], 50000u);
put32(&frame[36], (uint32_t)-900);
put32(&frame[40], 0x55667788u);
put32(&frame[44], 0x12345678u);
put16(&frame[48], 2u);
put16(&frame[50], 8u);
for (size_t i = 0u; i < RV_CS_AUTH_TAG_SIZE; i++) frame[52 + i] = (uint8_t)i;
put32(&frame[68], rv_cs_crc32(frame, 68u));
}
static void test_ble_parser_and_privacy_telemetry(void)
{
uint8_t advert[64];
size_t advert_len = build_ble_advert(advert, sizeof(advert));
rv_ble_token_t token;
assert(rv_ble_parse_advertisement(advert, advert_len, &token));
assert(token.key_id == 7u);
assert(token.epoch_min == 30000000u);
assert(token.ephemeral_id[0] == 0xa0u);
assert(token.auth_tag[15] == 0x1fu);
uint8_t tampered[64];
memcpy(tampered, advert, advert_len);
tampered[0] = 63u; /* field extends beyond report */
assert(!rv_ble_parse_advertisement(tampered, advert_len, &token));
memcpy(tampered, advert, advert_len);
tampered[18] = 2u; /* unsupported token version */
assert(!rv_ble_parse_advertisement(tampered, advert_len, &token));
rv_ble_telemetry_t telemetry = {
.node_id = 3u,
.flags = RV_BLE_FLAG_AUTHENTICATED | RV_BLE_FLAG_TIME_VERIFIED,
.key_id = 7u,
.sequence = 11u,
.observed_at_ms = 1200u,
.ttl_ms = 3000u,
.confidence_permille = 850u,
.rssi_dbm = -61,
.tx_power_dbm = 127,
.token_epoch_min = 30000000u,
};
memcpy(telemetry.ephemeral_id, token.ephemeral_id, 8u);
uint8_t packet[RV_BLE_TELEMETRY_SIZE];
assert(rv_ble_serialize_telemetry(&telemetry, packet, sizeof(packet)));
assert(RV_BLE_TELEMETRY_MAGIC != 0xC5110005u); /* compressed CSI */
assert(packet[0] == 0xb1u && packet[1] == 0x00u
&& packet[2] == 0x11u && packet[3] == 0xc5u);
assert(packet[4] == RV_BLE_TELEMETRY_VERSION);
assert(packet[5] == 3u);
assert(packet[20] == (uint8_t)-61);
/* The packet has no BLE address, raw advertising data, nonce or HMAC. */
assert(memcmp(&packet[24], token.ephemeral_id, 8u) == 0);
telemetry.flags = 0u;
assert(!rv_ble_serialize_telemetry(&telemetry, packet, sizeof(packet)));
}
static void test_channel_sounding_validation(void)
{
uint8_t frame[RV_CS_FRAME_SIZE];
build_cs_frame(frame);
rv_cs_measurement_t measurement;
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
600u, &measurement) == RV_CS_PARSE_OK);
assert(measurement.source_id == 0x11223344u);
assert(measurement.phase_millirad == -1200);
assert(measurement.frequency_offset_hz == -900);
assert(measurement.source_session_id == 0x55667788u);
assert(measurement.procedure_id == 0x12345678u);
assert(measurement.step_index == 2u);
assert(measurement.step_count == 8u);
frame[28] ^= 1u;
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
600u, &measurement) == RV_CS_PARSE_BAD_CRC);
build_cs_frame(frame);
put32(&frame[16], 3000000u);
put32(&frame[68], rv_cs_crc32(frame, 68u));
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
600u, &measurement) == RV_CS_PARSE_STALE);
build_cs_frame(frame);
uint8_t covered[RV_CS_MAC_INPUT_SIZE];
rv_cs_mac_input(frame, covered);
assert(memcmp(covered, "RuView/CS/v1", RV_CS_MAC_DOMAIN_SIZE) == 0);
assert(memcmp(&covered[RV_CS_MAC_DOMAIN_SIZE], frame,
RV_CS_SIGNED_PREFIX_SIZE) == 0);
assert(rv_cs_sequence_is_newer(10u, 9u));
assert(!rv_cs_sequence_is_newer(9u, 9u));
assert(rv_cs_sequence_is_newer(0u, UINT32_MAX));
build_cs_frame(frame);
put16(&frame[50], 1u);
put32(&frame[68], rv_cs_crc32(frame, 68u));
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
600u, &measurement) == RV_CS_PARSE_BAD_STEP);
}
static void test_gateway_envelope_contract(void)
{
uint8_t payload[RV_BLE_TELEMETRY_SIZE] = {0};
uint8_t frame[RV_GATEWAY_MAX_FRAME_SIZE];
size_t signed_len = 0u;
size_t frame_len = 0u;
rv_gateway_metadata_t metadata = {
.payload_type = RV_GATEWAY_PAYLOAD_BLE_IDENTITY,
.flags = RV_GATEWAY_FLAG_RX_MONOTONIC,
.key_id = 3u,
.node_id = 9u,
.sequence = 11u,
.boot_nonce = 0x0102030405060708ull,
.received_at_boot_us = 9000u,
.timing_uncertainty_us = 1000u,
};
assert(rv_gateway_build_unsigned(&metadata, payload, sizeof(payload),
frame, sizeof(frame), &signed_len,
&frame_len));
assert(signed_len == RV_GATEWAY_HEADER_SIZE + sizeof(payload));
assert(frame_len == signed_len + RV_GATEWAY_AUTH_TAG_SIZE);
assert(memcmp(&frame[0], "RVAE", 4u) == 0);
assert(frame[4] == RV_GATEWAY_VERSION);
assert(frame[5] == RV_GATEWAY_PAYLOAD_BLE_IDENTITY);
assert(frame[13] == 0u && frame[14] == 0u && frame[15] == 0u);
metadata.sequence = 0u;
assert(!rv_gateway_build_unsigned(&metadata, payload, sizeof(payload),
frame, sizeof(frame), &signed_len,
&frame_len));
uint8_t lhs[RV_GATEWAY_AUTH_TAG_SIZE] = {0};
uint8_t rhs[RV_GATEWAY_AUTH_TAG_SIZE] = {0};
assert(rv_gateway_auth_tag_equal(lhs, rhs));
rhs[15] = 1u;
assert(!rv_gateway_auth_tag_equal(lhs, rhs));
}
int main(void)
{
test_ble_parser_and_privacy_telemetry();
test_channel_sounding_validation();
test_gateway_envelope_contract();
puts("BLE identity and external Channel Sounding protocol tests passed (SYNTHETIC)");
return 0;
}

View File

@@ -1,9 +1,15 @@
import csv
import contextlib
import importlib.util
import io
import os
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
@@ -30,6 +36,8 @@ class ProvisionConfigValueTests(unittest.TestCase):
{"seed_token": "token-123"},
{"swarm_hb": 15},
{"swarm_ingest": 3},
{"cs_source_id": 1},
{"radio_envelope_key_id": 0},
]
for values in cases:
@@ -58,6 +66,230 @@ class ProvisionConfigValueTests(unittest.TestCase):
self.assertEqual(values_by_key["swarm_hb"], "15")
self.assertEqual(values_by_key["swarm_ingest"], "3")
def test_radio_secrets_source_and_key_ids_are_written_as_typed_nvs_values(self):
args = make_args(
ble_identity_enable=1,
ble_key_id=7,
ble_secret_bytes=bytes(range(32)),
radio_envelope_key_id=11,
radio_envelope_secret_bytes=bytes([0xA5]) * 32,
)
rows = csv_rows(provision.build_nvs_csv(args))
values_by_key = {row["key"]: row["value"] for row in rows}
self.assertEqual(values_by_key["ble_enable"], "1")
self.assertEqual(values_by_key["ble_key_id"], "7")
self.assertEqual(values_by_key["ble_secret"], bytes(range(32)).hex())
self.assertEqual(values_by_key["radio_key_id"], "11")
self.assertEqual(values_by_key["radio_secret"], (bytes([0xA5]) * 32).hex())
self.assertNotIn("ble_secret_bytes", provision.MERGEABLE_ATTRS)
self.assertNotIn("radio_envelope_secret_bytes", provision.MERGEABLE_ATTRS)
args.cs_ingress_enable = 1
args.cs_key_id = 9
args.cs_secret_bytes = bytes(reversed(range(32)))
args.cs_source_id = 0x11223344
rows = csv_rows(provision.build_nvs_csv(args))
values_by_key = {row["key"]: row["value"] for row in rows}
self.assertEqual(values_by_key["cs_enable"], "1")
self.assertEqual(values_by_key["cs_key_id"], "9")
self.assertEqual(values_by_key["cs_secret"], bytes(reversed(range(32))).hex())
self.assertEqual(values_by_key["cs_source_id"], str(0x11223344))
self.assertNotIn("cs_secret_bytes", provision.MERGEABLE_ATTRS)
def test_ble_secret_file_accepts_raw_and_hex(self):
encoded = bytes(range(32)).hex().encode("ascii")
for payload in (bytes(range(32)), encoded, encoded + b"\n", encoded + b"\r\n"):
with self.subTest(length=len(payload)):
with tempfile.NamedTemporaryFile() as secret_file:
secret_file.write(payload)
secret_file.flush()
self.assertEqual(provision.load_ble_secret(secret_file.name), bytes(range(32)))
def test_radio_secret_file_rejects_trailing_data(self):
for payload in (
bytes(range(32)) + b"x",
b"00" * 32 + b"\nextra",
b"00" * 32 + b"\n\nignored",
b"00" * 32 + b"\r\nextra",
):
with self.subTest(length=len(payload)):
with tempfile.NamedTemporaryFile() as secret_file:
secret_file.write(payload)
secret_file.flush()
with self.assertRaisesRegex(
ValueError, "trailing data|exactly 32 raw bytes"
):
provision.load_ble_secret(secret_file.name)
with tempfile.NamedTemporaryFile() as secret_file:
secret_file.write(b"gg" * 32)
secret_file.flush()
with self.assertRaisesRegex(ValueError, "not valid hexadecimal"):
provision.load_ble_secret(secret_file.name)
def test_radio_secrets_reject_zero_and_key_reuse(self):
for payload in (bytes(32), b"00" * 32, b"00" * 32 + b"\n"):
with self.subTest(length=len(payload)):
with tempfile.NamedTemporaryFile() as secret_file:
secret_file.write(payload)
secret_file.flush()
with self.assertRaisesRegex(ValueError, "must not be all zero"):
provision.load_ble_secret(secret_file.name)
key = bytes([0x5a]) * 32
with self.assertRaisesRegex(ValueError, "independently generated"):
provision.validate_distinct_radio_secrets(key, None, key)
provision.validate_distinct_radio_secrets(
bytes([0x11]) * 32,
bytes([0x22]) * 32,
bytes([0x33]) * 32,
)
def test_nvs_csv_rejects_non_32_byte_in_memory_secrets(self):
for attribute in (
"ble_secret_bytes",
"cs_secret_bytes",
"radio_envelope_secret_bytes",
):
with self.subTest(attribute=attribute):
args = make_args(**{attribute: b"short"})
with self.assertRaisesRegex(ValueError, "exactly 32 bytes"):
provision.build_nvs_csv(args)
args = make_args(ble_secret_bytes=bytes(32))
with self.assertRaisesRegex(ValueError, "must not be all zero"):
provision.build_nvs_csv(args)
def test_radio_evidence_requires_gateway_envelope_key_and_secret(self):
with tempfile.TemporaryDirectory() as temp_dir:
ble_secret = Path(temp_dir) / "ble.key"
ble_secret.write_bytes(bytes([0x11]) * 32)
base = [
"provision.py",
"--port", "TEST",
"--state-dir", str(Path(temp_dir) / "state"),
"--force-partial",
"--ble-identity-enable", "1",
"--ble-key-id", "7",
"--ble-secret-file", str(ble_secret),
]
stderr = io.StringIO()
with mock.patch.object(sys, "argv", base), contextlib.redirect_stderr(stderr):
with self.assertRaises(SystemExit) as raised:
provision.main()
self.assertEqual(raised.exception.code, 2)
self.assertIn("--radio-envelope-key-id is required", stderr.getvalue())
stderr = io.StringIO()
with mock.patch.object(
sys, "argv", base + ["--radio-envelope-key-id", "8"]
), contextlib.redirect_stderr(stderr):
with self.assertRaises(SystemExit) as raised:
provision.main()
self.assertEqual(raised.exception.code, 2)
self.assertIn("--radio-envelope-secret-file is required", stderr.getvalue())
def test_channel_sounding_requires_enrolled_nonzero_source(self):
with tempfile.TemporaryDirectory() as temp_dir:
cs_secret = Path(temp_dir) / "cs.key"
radio_secret = Path(temp_dir) / "radio.key"
cs_secret.write_bytes(bytes([0x22]) * 32)
radio_secret.write_bytes(bytes([0x33]) * 32)
base = [
"provision.py",
"--port", "TEST",
"--state-dir", str(Path(temp_dir) / "state"),
"--force-partial",
"--cs-ingress-enable", "1",
"--cs-key-id", "9",
"--cs-secret-file", str(cs_secret),
"--radio-envelope-key-id", "10",
"--radio-envelope-secret-file", str(radio_secret),
]
stderr = io.StringIO()
with mock.patch.object(sys, "argv", base), contextlib.redirect_stderr(stderr):
with self.assertRaises(SystemExit) as raised:
provision.main()
self.assertEqual(raised.exception.code, 2)
self.assertIn("--cs-source-id is required", stderr.getvalue())
stderr = io.StringIO()
with mock.patch.object(
sys, "argv", base + ["--cs-source-id", "0"]
), contextlib.redirect_stderr(stderr):
with self.assertRaises(SystemExit) as raised:
provision.main()
self.assertEqual(raised.exception.code, 2)
self.assertIn("must be between 1 and 4294967295", stderr.getvalue())
def test_secret_configuration_never_writes_fallback_csv(self):
with tempfile.TemporaryDirectory() as temp_dir, contextlib.chdir(temp_dir):
radio_secret = Path(temp_dir) / "radio.key"
radio_secret.write_bytes(bytes([0x44]) * 32)
argv = [
"provision.py",
"--port", "TEST",
"--state-dir", str(Path(temp_dir) / "state"),
"--force-partial",
"--radio-envelope-key-id", "12",
"--radio-envelope-secret-file", str(radio_secret),
]
stderr = io.StringIO()
with mock.patch.object(sys, "argv", argv), mock.patch.object(
provision, "generate_nvs_binary", side_effect=RuntimeError("missing")
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
with self.assertRaises(SystemExit) as raised:
provision.main()
self.assertEqual(raised.exception.code, 1)
self.assertFalse(Path("nvs_config.csv").exists())
self.assertIn("Refusing to persist fallback NVS CSV", stderr.getvalue())
@unittest.skipUnless(os.name == "posix", "POSIX permission bits required")
def test_secret_outputs_and_state_are_mode_0600(self):
with tempfile.TemporaryDirectory() as temp_dir, contextlib.chdir(temp_dir):
radio_secret = Path(temp_dir) / "radio.key"
cs_secret = Path(temp_dir) / "cs.key"
radio_secret.write_bytes(bytes([0x55]) * 32)
cs_secret.write_bytes(bytes([0x66]) * 32)
output = Path("nvs_provision.bin")
output.write_bytes(b"old")
output.chmod(0o644)
state_dir = Path(temp_dir) / "state"
state_dir.mkdir()
state_path = Path(provision._state_path_for("TEST", str(state_dir)))
state_path.write_text("{}\n")
state_path.chmod(0o644)
argv = [
"provision.py",
"--port", "TEST",
"--state-dir", str(state_dir),
"--ssid", "test-network",
"--password", "test-password",
"--target-ip", "192.0.2.10",
"--dry-run",
"--cs-ingress-enable", "1",
"--cs-key-id", "9",
"--cs-secret-file", str(cs_secret),
"--cs-source-id", "0x11223344",
"--radio-envelope-key-id", "13",
"--radio-envelope-secret-file", str(radio_secret),
]
with mock.patch.object(sys, "argv", argv), mock.patch.object(
provision, "generate_nvs_binary", return_value=b"private-nvs"
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(
io.StringIO()
):
provision.main()
self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(state_path.stat().st_mode), 0o600)
state_text = state_path.read_text()
self.assertIn('"password": "test-password"', state_text)
self.assertNotIn("radio_envelope_secret", state_text)
if __name__ == "__main__":
unittest.main()

43
v2/Cargo.lock generated
View File

@@ -1553,7 +1553,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2900,7 +2900,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3333,7 +3333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3596,6 +3596,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futf"
version = "0.1.5"
@@ -5280,7 +5290,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.2",
"system-configuration",
"tokio",
"tower-service",
@@ -5549,7 +5559,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6719,7 +6729,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7272,7 +7282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8199,7 +8209,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls",
"socket2 0.5.10",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -8238,9 +8248,9 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -9093,7 +9103,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9151,7 +9161,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9425,10 +9435,12 @@ dependencies = [
name = "ruview-fusion"
version = "0.3.1"
dependencies = [
"hmac",
"ruview-hal",
"ruview-ontology",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.18",
]
@@ -11105,7 +11117,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -13427,9 +13439,11 @@ dependencies = [
"chrono",
"clap",
"criterion",
"fs2",
"futures-util",
"hmac",
"jsonwebtoken",
"libc",
"midstreamer-attractor",
"midstreamer-temporal-compare",
"opentelemetry-appender-tracing",
@@ -13441,6 +13455,7 @@ dependencies = [
"rumqttc-v4-next",
"ruvector-mincut",
"ruview-auth",
"ruview-fusion",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -13611,7 +13626,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -11,6 +11,8 @@ thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-hal = { path = "../ruview-hal" }
hmac = "0.12"
sha2.workspace = true
[dev-dependencies]
serde_json.workspace = true

View File

@@ -110,6 +110,7 @@
pub mod estimate;
mod engine;
mod observation;
pub mod radio_fusion;
mod world;
pub use engine::{FusionConfig, FusionEngine};

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,8 @@ pub const RUVIEW_COMPRESSED_CSI_MAGIC: u32 = 0xC5110005;
pub const RUVIEW_FEATURE_STATE_MAGIC: u32 = 0xC5110006;
/// ADR-095 / #513 on-device temporal-classification packet.
pub const RUVIEW_TEMPORAL_MAGIC: u32 = 0xC5110007;
/// ADR-341 authenticated BLE and Channel Sounding gateway envelope (`RVAE`).
pub const RUVIEW_RADIO_ENVELOPE_MAGIC: u32 = 0x45415652;
/// If `magic` is a recognized RuView wire packet other than the ADR-018 raw
/// CSI frame, return a human-readable name for it; otherwise `None`.
@@ -72,6 +74,7 @@ pub fn ruview_sibling_packet_name(magic: u32) -> Option<&'static str> {
RUVIEW_COMPRESSED_CSI_MAGIC => Some("ADR-039 compressed CSI"),
RUVIEW_FEATURE_STATE_MAGIC => Some("ADR-081 feature state"),
RUVIEW_TEMPORAL_MAGIC => Some("ADR-095 temporal classification"),
RUVIEW_RADIO_ENVELOPE_MAGIC => Some("ADR-341 authenticated radio envelope"),
_ => None,
}
}
@@ -491,6 +494,7 @@ mod tests {
RUVIEW_COMPRESSED_CSI_MAGIC,
RUVIEW_FEATURE_STATE_MAGIC,
RUVIEW_TEMPORAL_MAGIC,
RUVIEW_RADIO_ENVELOPE_MAGIC,
] {
assert!(
ruview_sibling_packet_name(m).is_some(),

View File

@@ -71,7 +71,7 @@ pub use error::ParseError;
pub use esp32_parser::{
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
RUVIEW_FEATURE_MAGIC, RUVIEW_FEATURE_STATE_MAGIC, RUVIEW_FUSED_VITALS_MAGIC,
RUVIEW_TEMPORAL_MAGIC, RUVIEW_VITALS_MAGIC,
RUVIEW_RADIO_ENVELOPE_MAGIC, RUVIEW_TEMPORAL_MAGIC, RUVIEW_VITALS_MAGIC,
};
pub use radio_ops::{
crc32_ieee, decode_anomaly_alert, decode_mesh, decode_node_status, encode_health, AnomalyAlert,

View File

@@ -61,6 +61,13 @@ wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal",
# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
# ADR-341: authenticated RVAE ingress, short-lived BLE anchors, and bounded
# Channel Sounding respiration estimates. The sensing server owns durable
# replay storage; the fusion crate owns wire verification and estimation.
ruview-fusion = { path = "../ruview-fusion" }
fs2 = "0.4"
libc.workspace = true
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
# WorldGraph belief + deterministic witness. The live server data runs through
# this as a governed path whose Restricted-class decision strips per-node raw

View File

@@ -28,6 +28,9 @@ pub mod pose_physics;
/// ADR-295: canonical source-provenance state machine (synthetic can never
/// present as live).
pub mod provenance;
/// ADR-341: live authenticated BLE and Channel Sounding ingress with durable
/// replay state. Exact phase and timing samples never leave this edge module.
pub mod radio_ingress;
pub mod semantic;
/// ADR-262 P3: the live RuField surface — turns the governed sensing cycle into
/// signed RuField `FieldEvent`s on the additive `/api/field` + `/ws/field`

View File

@@ -121,6 +121,72 @@ struct Args {
#[arg(long, env = "RUVIEW_UDP_INSECURE_LAN")]
udp_insecure_lan: bool,
/// Primary ESP32-S3 node enrolled to submit authenticated RVAE envelopes.
#[arg(long, env = "RUVIEW_RADIO_GATEWAY_NODE_ID")]
radio_gateway_node_id: Option<u8>,
/// Primary gateway HMAC key selector.
#[arg(long, env = "RUVIEW_RADIO_GATEWAY_KEY_ID")]
radio_gateway_key_id: Option<u8>,
/// File containing exactly 32 raw primary gateway secret bytes.
#[arg(long, value_name = "PATH", env = "RUVIEW_RADIO_GATEWAY_SECRET_FILE")]
radio_gateway_secret_file: Option<PathBuf>,
/// Additional gateway enrollment as `NODE,KEY,SECRET_PATH`. Repeat for
/// independent gateways; node and key pairs and secrets must be unique.
#[arg(long = "radio-gateway", value_name = "NODE,KEY,SECRET_PATH")]
radio_additional_gateways: Vec<String>,
/// Distinct 32 byte deployment key used to derive host scoped `blep:`
/// tokens. Raw BLE pseudonyms are never exported or persisted.
#[arg(
long,
value_name = "PATH",
env = "RUVIEW_RADIO_HOST_PSEUDONYM_SECRET_FILE"
)]
radio_host_pseudonym_secret_file: Option<PathBuf>,
/// Private durable replay state for authenticated radio envelopes.
#[arg(
long,
value_name = "PATH",
env = "RUVIEW_RADIO_REPLAY_STATE",
default_value = "data/radio-replay-v2.json"
)]
radio_replay_state: PathBuf,
/// One-shot creation of a missing replay snapshot. Remove this option after
/// creation. If state was lost, rotate all enrolled keys before using it.
#[arg(long, env = "RUVIEW_RADIO_INITIALIZE_REPLAY_STATE")]
radio_initialize_replay_state: bool,
/// HMAC key selector for a separately enrolled Channel Sounding radio.
#[arg(long, env = "RUVIEW_RADIO_CS_KEY_ID")]
radio_cs_key_id: Option<u8>,
/// Nonzero opaque identifier for the Channel Sounding companion.
#[arg(long, env = "RUVIEW_RADIO_CS_SOURCE_ID")]
radio_cs_source_id: Option<u32>,
/// File containing exactly 32 raw companion HMAC secret bytes.
#[arg(long, value_name = "PATH", env = "RUVIEW_RADIO_CS_SECRET_FILE")]
radio_cs_secret_file: Option<PathBuf>,
/// Local deployment override for P5 BLE anchor export. This is not a
/// subject consent receipt and is accepted only with loopback, auth, and audit.
#[arg(long, env = "RUVIEW_RADIO_UNSAFE_EXPORT_P5_IDENTITY")]
radio_unsafe_export_p5_identity: bool,
/// Local deployment override for P4 aggregate respiration export. Exact
/// P0 phase, RTT, frequency offset, and step vectors never leave the edge.
#[arg(long, env = "RUVIEW_RADIO_UNSAFE_EXPORT_P4_BIOLOGICAL")]
radio_unsafe_export_p4_biological: bool,
/// Private append-only audit log required by either radio export override.
#[arg(long, value_name = "PATH", env = "RUVIEW_RADIO_EXPORT_AUDIT_LOG")]
radio_export_audit_log: Option<PathBuf>,
/// Path to UI static files (repo `ui/`; from `v2/` use `../ui` or rely on auto-detect)
#[arg(long, default_value = "../ui")]
ui_path: PathBuf,
@@ -6210,11 +6276,14 @@ async fn udp_receiver_task(
bind_ip: std::net::IpAddr,
udp_port: u16,
allowlist: std::sync::Arc<wifi_densepose_sensing_server::udp_bind::UdpSourceAllowlist>,
radio_sender: Option<
std::sync::mpsc::SyncSender<wifi_densepose_sensing_server::radio_ingress::RadioDatagram>,
>,
) {
let addr = format!("{bind_ip}:{udp_port}");
let socket = match UdpSocket::bind(&addr).await {
Ok(s) => {
info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm CSI, and RTL8720F radar frames");
info!("UDP listening on {addr} for ESP32, authenticated radio evidence, MediaTek, Qualcomm CSI, and RTL8720F radar frames");
s
}
Err(e) => {
@@ -6224,6 +6293,10 @@ async fn udp_receiver_task(
};
let mut buf = vec![0u8; wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAX_FRAME_LEN];
let mut radio_queue_drops = 0u64;
let mut radio_invalid_length_drops = 0u64;
let mut radio_worker_unhealthy = false;
let mut last_radio_queue_warning = std::time::Instant::now();
loop {
match socket.recv_from(&mut buf).await {
Ok((len, src)) => {
@@ -6236,6 +6309,65 @@ async fn udp_receiver_task(
);
continue;
}
if len >= 4
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
== ruview_fusion::radio_fusion::GATEWAY_ENVELOPE_MAGIC
{
if !wifi_densepose_sensing_server::radio_ingress::is_supported_rvae_datagram_len(
len,
) {
radio_invalid_length_drops = radio_invalid_length_drops.saturating_add(1);
if last_radio_queue_warning.elapsed() >= std::time::Duration::from_secs(1) {
warn!(
dropped = radio_invalid_length_drops,
received_len = len,
"RVAE datagrams with unsupported lengths were dropped before allocation and authentication"
);
radio_invalid_length_drops = 0;
last_radio_queue_warning = std::time::Instant::now();
}
continue;
}
let queued = match radio_sender.as_ref() {
Some(sender) => {
let datagram =
wifi_densepose_sensing_server::radio_ingress::RadioDatagram {
frame: buf[..len].to_vec(),
host_received_at_unix_us: chrono::Utc::now().timestamp_micros(),
source: src,
};
match sender.try_send(datagram) {
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => false,
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
radio_worker_unhealthy = true;
false
}
}
}
None => false,
};
if !queued {
radio_queue_drops = radio_queue_drops.saturating_add(1);
if last_radio_queue_warning.elapsed() >= std::time::Duration::from_secs(1) {
if radio_worker_unhealthy {
error!(
dropped = radio_queue_drops,
"RVAE ingress worker is unhealthy and its queue is disconnected"
);
} else {
warn!(
dropped = radio_queue_drops,
configured = radio_sender.is_some(),
"RVAE ingress queue unavailable or full; datagrams were dropped before authentication"
);
}
radio_queue_drops = 0;
last_radio_queue_warning = std::time::Instant::now();
}
}
continue;
}
if len > 0 && buf[0] == b'{' {
match serde_json::from_slice::<wifi_densepose_hardware::vendor_rf::VendorRfEvent>(&buf[..len])
.map_err(|error| error.to_string())
@@ -7708,6 +7840,114 @@ fn coalesce_ui_path(initial: std::path::PathBuf) -> std::path::PathBuf {
initial
}
fn parse_radio_gateway(
value: &str,
) -> Result<wifi_densepose_sensing_server::radio_ingress::GatewayRuntimeOptions, String> {
let mut fields = value.splitn(3, ',');
let node_id = fields
.next()
.ok_or_else(|| "radio gateway requires NODE,KEY,SECRET_PATH".to_string())?
.parse::<u8>()
.map_err(|_| "radio gateway NODE must be an integer from 1 through 255".to_string())?;
let key_id = fields
.next()
.ok_or_else(|| "radio gateway requires NODE,KEY,SECRET_PATH".to_string())?
.parse::<u8>()
.map_err(|_| "radio gateway KEY must be an integer from 0 through 255".to_string())?;
let secret = fields
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "radio gateway SECRET_PATH must not be empty".to_string())?;
if node_id == 0 {
return Err("radio gateway NODE must be nonzero".to_string());
}
Ok(
wifi_densepose_sensing_server::radio_ingress::GatewayRuntimeOptions {
node_id,
key_id,
secret_file: PathBuf::from(secret),
},
)
}
fn open_radio_ingress(
args: &Args,
) -> Result<Option<wifi_densepose_sensing_server::radio_ingress::RadioIngressRuntime>, String> {
use wifi_densepose_sensing_server::radio_ingress::{
ChannelSoundingRuntimeOptions, GatewayRuntimeOptions, RadioIngressOptions,
RadioIngressRuntime,
};
let mut gateways = Vec::new();
match (
args.radio_gateway_node_id,
args.radio_gateway_key_id,
args.radio_gateway_secret_file.as_ref(),
) {
(None, None, None) => {}
(Some(node_id), Some(key_id), Some(secret_file)) => gateways.push(GatewayRuntimeOptions {
node_id,
key_id,
secret_file: secret_file.clone(),
}),
_ => {
return Err(
"primary radio gateway node id, key id, and secret file must be supplied together"
.to_string(),
)
}
}
for value in &args.radio_additional_gateways {
gateways.push(parse_radio_gateway(value)?);
}
let companion = match (
args.radio_cs_key_id,
args.radio_cs_source_id,
args.radio_cs_secret_file.as_ref(),
) {
(None, None, None) => None,
(Some(key_id), Some(source_id), Some(secret_file)) => Some(ChannelSoundingRuntimeOptions {
key_id,
source_id,
secret_file: secret_file.clone(),
}),
_ => {
return Err(
"Channel Sounding key id, source id, and secret file must be supplied together"
.to_string(),
)
}
};
if gateways.is_empty() {
if companion.is_some() || args.radio_host_pseudonym_secret_file.is_some() {
return Err(
"Channel Sounding and host pseudonym configuration require at least one RVAE gateway"
.to_string(),
);
}
return Ok(None);
}
let host_pseudonym_secret_file = args
.radio_host_pseudonym_secret_file
.clone()
.ok_or_else(|| "radio gateways require a host pseudonym secret file".to_string())?;
RadioIngressRuntime::open(
RadioIngressOptions {
gateways,
host_pseudonym_secret_file,
replay_state_file: args.radio_replay_state.clone(),
initialize_replay_state: args.radio_initialize_replay_state,
channel_sounding: companion,
},
chrono::Utc::now().timestamp_millis(),
)
.map(Some)
.map_err(|error| error.to_string())
}
#[tokio::main]
async fn main() {
// Initialize tracing; with the `otel` feature and
@@ -8315,6 +8555,66 @@ async fn main() {
plan.bind_udp, plan.run_simulator, plan.run_wifi
);
let radio_requested = args.radio_gateway_node_id.is_some()
|| args.radio_gateway_key_id.is_some()
|| args.radio_gateway_secret_file.is_some()
|| !args.radio_additional_gateways.is_empty()
|| args.radio_host_pseudonym_secret_file.is_some()
|| args.radio_cs_key_id.is_some()
|| args.radio_cs_source_id.is_some()
|| args.radio_cs_secret_file.is_some();
if radio_requested && !plan.bind_udp {
error!("Authenticated radio ingress requires a source mode that binds UDP");
std::process::exit(1);
}
let radio_runtime = match open_radio_ingress(&args) {
Ok(runtime) => runtime,
Err(error) => {
error!("Authenticated radio ingress configuration failed: {error}");
std::process::exit(1);
}
};
if args.radio_initialize_replay_state && radio_runtime.is_none() {
error!("Replay initialization requires at least one complete gateway enrollment");
std::process::exit(1);
}
let radio_export_policy = wifi_densepose_sensing_server::radio_ingress::RadioExportPolicy {
allow_biological_p4: args.radio_unsafe_export_p4_biological,
allow_identity_p5: args.radio_unsafe_export_p5_identity,
};
if radio_export_policy.any() {
let export_bind = args
.bind_addr
.parse::<std::net::IpAddr>()
.unwrap_or_else(|_| {
error!("Invalid --bind-addr '{}'", args.bind_addr);
std::process::exit(1);
});
let auth_configured = ["RUVIEW_API_TOKEN", "RUVIEW_OAUTH_ISSUER"]
.iter()
.any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
if radio_runtime.is_none()
|| !export_bind.is_loopback()
|| !auth_configured
|| args.radio_export_audit_log.is_none()
{
error!(
"Radio P4/P5 export overrides require authenticated radio ingress, a loopback bind, configured bearer or OAuth authentication, and a private audit log"
);
std::process::exit(1);
}
}
if let Some(runtime) = radio_runtime.as_ref() {
info!(
"Authenticated RVAE ingress enabled; durable replay state: {}",
runtime.replay_state_file().display()
);
info!(
"Radio WebSocket export overrides: P4 biological={}, P5 identity={}",
radio_export_policy.allow_biological_p4, radio_export_policy.allow_identity_p5
);
}
// Shared state
// Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz)
let vital_sample_rate = 1000.0 / args.tick_ms as f64;
@@ -8445,6 +8745,23 @@ async fn main() {
};
let (tx, _) = broadcast::channel::<String>(256);
let radio_sender = match radio_runtime {
Some(runtime) => {
match wifi_densepose_sensing_server::radio_ingress::spawn_radio_ingress_worker(
runtime,
radio_export_policy,
args.radio_export_audit_log.clone(),
tx.clone(),
) {
Ok(sender) => Some(sender),
Err(error) => {
error!("Authenticated radio worker failed to start: {error}");
std::process::exit(1);
}
}
}
None => None,
};
// ADR-099: parallel broadcast for the per-frame introspection snapshot stream
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
// clients drop oldest, identical backpressure shape.
@@ -8691,6 +9008,7 @@ async fn main() {
udp_bind_ip,
args.udp_port,
udp_allowlist,
radio_sender,
));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
}

File diff suppressed because it is too large Load Diff