From 87ce7bdd5f7cc15ad67f8a83ac1a0c59c94544e2 Mon Sep 17 00:00:00 2001 From: rUv Date: Sun, 23 Aug 2026 20:45:37 -0400 Subject: [PATCH] feat: add authenticated ESP32 BLE fusion path --- ...ticated-ble-and-channel-sounding-fusion.md | 394 +++ firmware/esp32-csi-node/README.md | 89 + firmware/esp32-csi-node/main/CMakeLists.txt | 11 + .../esp32-csi-node/main/Kconfig.projbuild | 153 + firmware/esp32-csi-node/main/ble_identity.c | 341 ++ firmware/esp32-csi-node/main/ble_identity.h | 23 + .../main/ble_identity_protocol.c | 154 + .../main/ble_identity_protocol.h | 91 + .../main/channel_sounding_ingress.c | 273 ++ .../main/channel_sounding_ingress.h | 11 + .../main/channel_sounding_protocol.c | 124 + .../main/channel_sounding_protocol.h | 105 + firmware/esp32-csi-node/main/main.c | 25 +- firmware/esp32-csi-node/main/nvs_config.c | 98 + firmware/esp32-csi-node/main/nvs_config.h | 19 + .../main/radio_gateway_protocol.c | 104 + .../main/radio_gateway_protocol.h | 64 + .../main/radio_gateway_sender.c | 187 + .../main/radio_gateway_sender.h | 31 + firmware/esp32-csi-node/main/stream_sender.c | 52 +- firmware/esp32-csi-node/provision.py | 352 +- firmware/esp32-csi-node/tests/host/Makefile | 13 +- .../tests/host/test_ble_cs_protocol.c | 201 ++ .../esp32-csi-node/tests/test_provision.py | 232 ++ v2/Cargo.lock | 43 +- v2/crates/ruview-fusion/Cargo.toml | 2 + v2/crates/ruview-fusion/src/lib.rs | 1 + v2/crates/ruview-fusion/src/radio_fusion.rs | 3097 +++++++++++++++++ .../src/esp32_parser.rs | 4 + v2/crates/wifi-densepose-hardware/src/lib.rs | 2 +- .../wifi-densepose-sensing-server/Cargo.toml | 7 + .../wifi-densepose-sensing-server/src/lib.rs | 3 + .../wifi-densepose-sensing-server/src/main.rs | 320 +- .../src/radio_ingress.rs | 1724 +++++++++ 34 files changed, 8318 insertions(+), 32 deletions(-) create mode 100644 docs/adr/ADR-341-authenticated-ble-and-channel-sounding-fusion.md create mode 100644 firmware/esp32-csi-node/main/ble_identity.c create mode 100644 firmware/esp32-csi-node/main/ble_identity.h create mode 100644 firmware/esp32-csi-node/main/ble_identity_protocol.c create mode 100644 firmware/esp32-csi-node/main/ble_identity_protocol.h create mode 100644 firmware/esp32-csi-node/main/channel_sounding_ingress.c create mode 100644 firmware/esp32-csi-node/main/channel_sounding_ingress.h create mode 100644 firmware/esp32-csi-node/main/channel_sounding_protocol.c create mode 100644 firmware/esp32-csi-node/main/channel_sounding_protocol.h create mode 100644 firmware/esp32-csi-node/main/radio_gateway_protocol.c create mode 100644 firmware/esp32-csi-node/main/radio_gateway_protocol.h create mode 100644 firmware/esp32-csi-node/main/radio_gateway_sender.c create mode 100644 firmware/esp32-csi-node/main/radio_gateway_sender.h create mode 100644 firmware/esp32-csi-node/tests/host/test_ble_cs_protocol.c create mode 100644 v2/crates/ruview-fusion/src/radio_fusion.rs create mode 100644 v2/crates/wifi-densepose-sensing-server/src/radio_ingress.rs diff --git a/docs/adr/ADR-341-authenticated-ble-and-channel-sounding-fusion.md b/docs/adr/ADR-341-authenticated-ble-and-channel-sounding-fusion.md new file mode 100644 index 00000000..85c4b5ba --- /dev/null +++ b/docs/adr/ADR-341-authenticated-ble-and-channel-sounding-fusion.md @@ -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. diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index b2dda8ab..1617b8eb 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -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 diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index 0cabf5df..2d2a8a71 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -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. diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index ee1dff16..033eba93 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -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 diff --git a/firmware/esp32-csi-node/main/ble_identity.c b/firmware/esp32-csi-node/main/ble_identity.c new file mode 100644 index 00000000..1b45f300 --- /dev/null +++ b/firmware/esp32-csi-node/main/ble_identity.c @@ -0,0 +1,341 @@ +/** @file ble_identity.c */ + +#include "ble_identity.h" + +#include "sdkconfig.h" + +#if defined(CONFIG_BLE_IDENTITY_SCAN_ENABLE) + +#include +#include +#include + +#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(¶ms, 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, + ¶ms, 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 */ diff --git a/firmware/esp32-csi-node/main/ble_identity.h b/firmware/esp32-csi-node/main/ble_identity.h new file mode 100644 index 00000000..a8a9cbe9 --- /dev/null +++ b/firmware/esp32-csi-node/main/ble_identity.h @@ -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 +#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 */ diff --git a/firmware/esp32-csi-node/main/ble_identity_protocol.c b/firmware/esp32-csi-node/main/ble_identity_protocol.c new file mode 100644 index 00000000..ab7f1762 --- /dev/null +++ b/firmware/esp32-csi-node/main/ble_identity_protocol.c @@ -0,0 +1,154 @@ +/** @file ble_identity_protocol.c */ + +#include "ble_identity_protocol.h" + +#include + +#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; +} diff --git a/firmware/esp32-csi-node/main/ble_identity_protocol.h b/firmware/esp32-csi-node/main/ble_identity_protocol.h new file mode 100644 index 00000000..9381537a --- /dev/null +++ b/firmware/esp32-csi-node/main/ble_identity_protocol.h @@ -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 +#include +#include + +#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 */ diff --git a/firmware/esp32-csi-node/main/channel_sounding_ingress.c b/firmware/esp32-csi-node/main/channel_sounding_ingress.c new file mode 100644 index 00000000..bd0db7e0 --- /dev/null +++ b/firmware/esp32-csi-node/main/channel_sounding_ingress.c @@ -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 +#include + +#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 */ diff --git a/firmware/esp32-csi-node/main/channel_sounding_ingress.h b/firmware/esp32-csi-node/main/channel_sounding_ingress.h new file mode 100644 index 00000000..1b08506f --- /dev/null +++ b/firmware/esp32-csi-node/main/channel_sounding_ingress.h @@ -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 */ diff --git a/firmware/esp32-csi-node/main/channel_sounding_protocol.c b/firmware/esp32-csi-node/main/channel_sounding_protocol.c new file mode 100644 index 00000000..89bb07bd --- /dev/null +++ b/firmware/esp32-csi-node/main/channel_sounding_protocol.c @@ -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 +} diff --git a/firmware/esp32-csi-node/main/channel_sounding_protocol.h b/firmware/esp32-csi-node/main/channel_sounding_protocol.h new file mode 100644 index 00000000..6d524225 --- /dev/null +++ b/firmware/esp32-csi-node/main/channel_sounding_protocol.h @@ -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 +#include +#include + +#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 */ diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index 6991efe6..465e733e 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -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"); diff --git a/firmware/esp32-csi-node/main/nvs_config.c b/firmware/esp32-csi-node/main/nvs_config.c index dd4517ef..ae1f7c70 100644 --- a/firmware/esp32-csi-node/main/nvs_config.c +++ b/firmware/esp32-csi-node/main/nvs_config.c @@ -8,6 +8,7 @@ #include "nvs_config.h" +#include #include #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", diff --git a/firmware/esp32-csi-node/main/nvs_config.h b/firmware/esp32-csi-node/main/nvs_config.h index 225b9b89..c35e75ac 100644 --- a/firmware/esp32-csi-node/main/nvs_config.h +++ b/firmware/esp32-csi-node/main/nvs_config.h @@ -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; /** diff --git a/firmware/esp32-csi-node/main/radio_gateway_protocol.c b/firmware/esp32-csi-node/main/radio_gateway_protocol.c new file mode 100644 index 00000000..c781b3ab --- /dev/null +++ b/firmware/esp32-csi-node/main/radio_gateway_protocol.c @@ -0,0 +1,104 @@ +/** @file radio_gateway_protocol.c */ + +#include "radio_gateway_protocol.h" + +#include + +#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 +} diff --git a/firmware/esp32-csi-node/main/radio_gateway_protocol.h b/firmware/esp32-csi-node/main/radio_gateway_protocol.h new file mode 100644 index 00000000..02ac3bda --- /dev/null +++ b/firmware/esp32-csi-node/main/radio_gateway_protocol.h @@ -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 +#include +#include + +#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 */ diff --git a/firmware/esp32-csi-node/main/radio_gateway_sender.c b/firmware/esp32-csi-node/main/radio_gateway_sender.c new file mode 100644 index 00000000..f8d90dd4 --- /dev/null +++ b/firmware/esp32-csi-node/main/radio_gateway_sender.c @@ -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 +#include +#include + +#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 diff --git a/firmware/esp32-csi-node/main/radio_gateway_sender.h b/firmware/esp32-csi-node/main/radio_gateway_sender.h new file mode 100644 index 00000000..c384bc90 --- /dev/null +++ b/firmware/esp32-csi-node/main/radio_gateway_sender.h @@ -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 +#include +#include + +#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 */ diff --git a/firmware/esp32-csi-node/main/stream_sender.c b/firmware/esp32-csi-node/main/stream_sender.c index ecfc6ec9..c18d851d 100644 --- a/firmware/esp32-csi-node/main/stream_sender.c +++ b/firmware/esp32-csi-node/main/stream_sender.c @@ -7,9 +7,12 @@ #include "stream_sender.h" +#include #include #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); } diff --git a/firmware/esp32-csi-node/provision.py b/firmware/esp32-csi-node/provision.py index 88172fe4..e483f07e 100644 --- a/firmware/esp32-csi-node/provision.py +++ b/firmware/esp32-csi-node/provision.py @@ -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}") diff --git a/firmware/esp32-csi-node/tests/host/Makefile b/firmware/esp32-csi-node/tests/host/Makefile index a27f2c4a..f4c4460a 100644 --- a/firmware/esp32-csi-node/tests/host/Makefile +++ b/firmware/esp32-csi-node/tests/host/Makefile @@ -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 diff --git a/firmware/esp32-csi-node/tests/host/test_ble_cs_protocol.c b/firmware/esp32-csi-node/tests/host/test_ble_cs_protocol.c new file mode 100644 index 00000000..70c9bc72 --- /dev/null +++ b/firmware/esp32-csi-node/tests/host/test_ble_cs_protocol.c @@ -0,0 +1,201 @@ +#include +#include +#include +#include + +#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; +} diff --git a/firmware/esp32-csi-node/tests/test_provision.py b/firmware/esp32-csi-node/tests/test_provision.py index 9ea9d0f6..dea7d938 100644 --- a/firmware/esp32-csi-node/tests/test_provision.py +++ b/firmware/esp32-csi-node/tests/test_provision.py @@ -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() diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 07bac71d..4e3242f7 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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]] diff --git a/v2/crates/ruview-fusion/Cargo.toml b/v2/crates/ruview-fusion/Cargo.toml index d5b25d1c..921d8392 100644 --- a/v2/crates/ruview-fusion/Cargo.toml +++ b/v2/crates/ruview-fusion/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-fusion/src/lib.rs b/v2/crates/ruview-fusion/src/lib.rs index 8e50fad3..28a53068 100644 --- a/v2/crates/ruview-fusion/src/lib.rs +++ b/v2/crates/ruview-fusion/src/lib.rs @@ -110,6 +110,7 @@ pub mod estimate; mod engine; mod observation; +pub mod radio_fusion; mod world; pub use engine::{FusionConfig, FusionEngine}; diff --git a/v2/crates/ruview-fusion/src/radio_fusion.rs b/v2/crates/ruview-fusion/src/radio_fusion.rs new file mode 100644 index 00000000..25f59182 --- /dev/null +++ b/v2/crates/ruview-fusion/src/radio_fusion.rs @@ -0,0 +1,3097 @@ +//! Authenticated BLE identity evidence and external Bluetooth Channel Sounding +//! primitives for ADR-341. +//! +//! The module is deliberately narrow. ESP32-S3 contributes ordinary BLE +//! advertisements and RSSI only. A separate capable radio contributes phase +//! and timing primitives over the authenticated RVCS v1 frame. Neither input +//! is presented as a civil identity or clinical vital sign. The deterministic +//! replay is labelled `SYNTHETIC` and exercises expiry, spoof conflict, +//! crossing ambiguity, motion gating and abstention. + +use std::collections::{BTreeMap, BTreeSet}; +use std::f64::consts::TAU; + +use hmac::{Hmac, Mac}; +use ruview_ontology::TrackId; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +type HmacSha256 = Hmac; +type GatewayReplayKey = (u8, u8, u64); +type BleReplayKey = (u64, u8, u8, u32, [u8; 32]); +type BleReplayHighWater = (u32, i64); +type ChannelSoundingReplayKey = (u8, u32, u32); + +/// Firmware BLE identity telemetry magic. +pub const BLE_IDENTITY_MAGIC: u32 = 0xC511_00B1; +/// Firmware BLE identity telemetry version. +pub const BLE_IDENTITY_VERSION: u8 = 1; +/// Fixed BLE identity telemetry length. +pub const BLE_IDENTITY_PACKET_SIZE: usize = 36; +/// Authenticated gateway envelope magic (`RVAE`, little endian). +pub const GATEWAY_ENVELOPE_MAGIC: u32 = 0x4541_5652; +/// Authenticated gateway envelope wire version. +pub const GATEWAY_ENVELOPE_VERSION: u8 = 1; +/// Fixed gateway envelope header length. +pub const GATEWAY_ENVELOPE_HEADER_SIZE: usize = 40; +/// Truncated HMAC-SHA256 tag length used by the gateway envelope. +pub const GATEWAY_ENVELOPE_TAG_SIZE: usize = 16; +/// External Channel Sounding UART magic (`RVCS`). +pub const CHANNEL_SOUNDING_MAGIC: u32 = 0x5343_5652; +/// External Channel Sounding wire version. +pub const CHANNEL_SOUNDING_VERSION: u8 = 1; +/// Fixed authenticated Channel Sounding frame length. +pub const CHANNEL_SOUNDING_FRAME_SIZE: usize = 72; +/// Minimum BLE confidence admitted to track association. +pub const BLE_ASSOCIATION_MIN_CONFIDENCE_PERMILLE: u16 = 600; +/// Evidence label applied to every bundled deterministic replay result. +pub const SYNTHETIC_LABEL: &str = "SYNTHETIC"; + +const BLE_AUTHENTICATED: u8 = 1 << 0; +const BLE_TIME_VERIFIED: u8 = 1 << 1; +const BLE_EXTENDED_ADVERT: u8 = 1 << 2; +const BLE_ALLOWED_FLAGS: u8 = BLE_AUTHENTICATED | BLE_TIME_VERIFIED | BLE_EXTENDED_ADVERT; +const CS_CALIBRATED: u8 = 1 << 0; +const CS_MOTION: u8 = 1 << 1; +const CS_ALLOWED_FLAGS: u8 = CS_CALIBRATED | CS_MOTION; +const CS_SIGNED_PREFIX: usize = 52; +const CS_TAG_OFFSET: usize = 52; +const CS_TAG_SIZE: usize = 16; +const CS_CRC_OFFSET: usize = 68; +const CS_MAC_DOMAIN: &[u8; 12] = b"RuView/CS/v1"; +const GATEWAY_MAC_DOMAIN: &[u8; 12] = b"RuView/GW/v1"; +const GATEWAY_FLAG_RX_MONOTONIC: u8 = 1 << 0; +const GATEWAY_ALLOWED_FLAGS: u8 = GATEWAY_FLAG_RX_MONOTONIC; +const GATEWAY_PAYLOAD_BLE_IDENTITY: u8 = 1; +const GATEWAY_PAYLOAD_CHANNEL_SOUNDING: u8 = 2; +const MAX_GATEWAY_TIMING_UNCERTAINTY_US: u32 = 1_000_000; +const MAX_BLE_TTL_MS: u16 = 5_000; +const MAX_BLE_TOKEN_SKEW_MIN: u32 = 10; +const MAX_RESPIRATION_DISAGREEMENT_BPM: f64 = 57.0; +const RADIO_REPLAY_SNAPSHOT_VERSION: u8 = 2; +const BLE_REPLAY_FINGERPRINT_DOMAIN: &[u8] = b"RuView/BLEReplay/v1"; +const MAX_REPLAY_GATEWAY_SESSIONS: usize = 4_096; +const MAX_REPLAY_BLE_RECORDS: usize = 65_536; +const MAX_REPLAY_CS_SESSIONS: usize = 4_096; + +/// Boundary failures for BLE and Channel Sounding ingress. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum RadioIngressError { + /// The frame has an unexpected byte length. + #[error("unexpected frame length")] + Length, + /// Magic does not select the expected contract. + #[error("wrong frame magic")] + Magic, + /// The version is unsupported. + #[error("unsupported frame version")] + Version, + /// Reserved or unknown flag bits were set. + #[error("invalid flags")] + Flags, + /// A reserved field was nonzero. + #[error("reserved field is nonzero")] + Reserved, + /// CRC validation failed. + #[error("CRC validation failed")] + Crc, + /// The expected provisioned key was not selected. + #[error("unknown key id")] + KeyId, + /// The expected provisioned node was not selected. + #[error("unknown node id")] + NodeId, + /// The expected provisioned companion source was not selected. + #[error("unknown companion source id")] + SourceId, + /// The envelope carried the wrong payload kind. + #[error("unexpected gateway payload type")] + PayloadType, + /// HMAC validation failed. + #[error("authentication failed")] + Authentication, + /// A numeric field was outside the bounded contract. + #[error("measurement field out of bounds")] + Bounds, + /// The evidence is stale at the receive boundary. + #[error("evidence is stale")] + Stale, + /// A token epoch is outside the host freshness allowance. + #[error("token epoch is outside the freshness allowance")] + Epoch, + /// A sequence repeated or moved backwards within an authenticated source session. + #[error("replay or out-of-order sequence")] + Replay, + /// The bounded replay-source table is full. + #[error("replay-source capacity exceeded")] + Capacity, + /// A caller supplied policy is unsafe or internally inconsistent. + #[error("invalid ingress or fusion policy")] + InvalidPolicy, + /// A durable replay snapshot is malformed, unsupported, or over capacity. + #[error("invalid replay snapshot")] + InvalidSnapshot, +} + +/// Payload selector authenticated by the gateway envelope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GatewayPayloadType { + /// Privacy-minimized BLE identity telemetry. + BleIdentity, + /// Authenticated external Channel Sounding primitive. + ChannelSounding, +} + +impl GatewayPayloadType { + fn from_wire(value: u8) -> Result { + match value { + GATEWAY_PAYLOAD_BLE_IDENTITY => Ok(Self::BleIdentity), + GATEWAY_PAYLOAD_CHANNEL_SOUNDING => Ok(Self::ChannelSounding), + _ => Err(RadioIngressError::PayloadType), + } + } + + fn payload_size(self) -> usize { + match self { + Self::BleIdentity => BLE_IDENTITY_PACKET_SIZE, + Self::ChannelSounding => CHANNEL_SOUNDING_FRAME_SIZE, + } + } +} + +/// Provisioned gateway identity and host-side envelope bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GatewayIngressConfig { + /// Enrolled ESP32 gateway node identifier. + pub node_id: u8, + /// Enrolled gateway envelope key selector. + pub key_id: u8, + /// Maximum accepted gateway receive-time uncertainty. + pub max_timing_uncertainty_us: u32, +} + +impl Default for GatewayIngressConfig { + fn default() -> Self { + Self { + node_id: 0, + key_id: 0, + max_timing_uncertainty_us: 50_000, + } + } +} + +/// Authenticated metadata retained from an RVAE v1 gateway envelope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GatewayEnvelopeMetadata { + /// Authenticated payload selector. + pub payload_type: GatewayPayloadType, + /// Provisioned gateway key selector. + pub key_id: u8, + /// Provisioned gateway node identifier. + pub node_id: u8, + /// Sequence within this gateway boot session. + pub sequence: u32, + /// Random nonzero boot-session nonce. + pub boot_nonce: u64, + /// Receive timestamp on the gateway's boot-relative clock. + pub received_at_boot_us: u64, + /// Gateway-declared receive-time uncertainty. + pub timing_uncertainty_us: u32, +} + +/// An HMAC-verified RVAE v1 envelope whose payload still borrows the wire frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticatedGatewayEnvelope<'a> { + /// Authenticated envelope metadata. + pub metadata: GatewayEnvelopeMetadata, + payload: &'a [u8], +} + +impl<'a> AuthenticatedGatewayEnvelope<'a> { + /// Validate an RVAE v1 envelope against an enrolled node and gateway key. + pub fn parse( + frame: &'a [u8], + secret: &[u8; 32], + config: GatewayIngressConfig, + ) -> Result { + if config.max_timing_uncertainty_us == 0 + || config.max_timing_uncertainty_us > MAX_GATEWAY_TIMING_UNCERTAINTY_US + { + return Err(RadioIngressError::InvalidPolicy); + } + if secret.iter().all(|byte| *byte == 0) { + return Err(RadioIngressError::Authentication); + } + if frame.len() < GATEWAY_ENVELOPE_HEADER_SIZE + GATEWAY_ENVELOPE_TAG_SIZE { + return Err(RadioIngressError::Length); + } + if le_u32(frame, 0) != GATEWAY_ENVELOPE_MAGIC { + return Err(RadioIngressError::Magic); + } + if frame[4] != GATEWAY_ENVELOPE_VERSION { + return Err(RadioIngressError::Version); + } + let payload_type = GatewayPayloadType::from_wire(frame[5])?; + let flags = frame[6]; + if flags & !GATEWAY_ALLOWED_FLAGS != 0 || flags & GATEWAY_FLAG_RX_MONOTONIC == 0 { + return Err(RadioIngressError::Flags); + } + if frame[7] != config.key_id { + return Err(RadioIngressError::KeyId); + } + if frame[12] != config.node_id { + return Err(RadioIngressError::NodeId); + } + if frame[13..16] != [0, 0, 0] { + return Err(RadioIngressError::Reserved); + } + let total_len = usize::from(le_u16(frame, 8)); + let payload_len = usize::from(le_u16(frame, 10)); + if total_len != frame.len() + || payload_len != payload_type.payload_size() + || total_len != GATEWAY_ENVELOPE_HEADER_SIZE + payload_len + GATEWAY_ENVELOPE_TAG_SIZE + { + return Err(RadioIngressError::Length); + } + let sequence = le_u32(frame, 16); + let boot_nonce = le_u64(frame, 20); + let timing_uncertainty_us = le_u32(frame, 36); + if sequence == 0 + || boot_nonce == 0 + || timing_uncertainty_us > config.max_timing_uncertainty_us + { + return Err(RadioIngressError::Bounds); + } + let signed_len = GATEWAY_ENVELOPE_HEADER_SIZE + payload_len; + let mut mac = + HmacSha256::new_from_slice(secret).map_err(|_| RadioIngressError::Authentication)?; + mac.update(GATEWAY_MAC_DOMAIN); + mac.update(&frame[..signed_len]); + mac.verify_truncated_left(&frame[signed_len..total_len]) + .map_err(|_| RadioIngressError::Authentication)?; + Ok(Self { + metadata: GatewayEnvelopeMetadata { + payload_type, + key_id: frame[7], + node_id: frame[12], + sequence, + boot_nonce, + received_at_boot_us: le_u64(frame, 28), + timing_uncertainty_us, + }, + payload: &frame[GATEWAY_ENVELOPE_HEADER_SIZE..signed_len], + }) + } + + /// Return the exact authenticated inner payload. + #[must_use] + pub fn payload(&self) -> &'a [u8] { + self.payload + } +} + +/// Host parsing policy for BLE identity telemetry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BleIngressConfig { + /// Maximum accepted event lifetime. + pub max_ttl_ms: u16, + /// Maximum absolute token clock skew. + pub max_token_skew_min: u32, +} + +impl Default for BleIngressConfig { + fn default() -> Self { + Self { + max_ttl_ms: 5_000, + max_token_skew_min: 2, + } + } +} + +/// A short lived, authenticated, pseudonymous BLE anchor observation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BleIdentityEvidence { + /// ESP32 scanner node identifier, cross-checked against the gateway envelope. + pub node_id: u8, + /// Provisioned BLE advertiser key selector. + pub key_id: u8, + /// BLE scanner sequence carried by the inner telemetry record. + pub sequence: u32, + /// Gateway envelope key selector. + pub gateway_key_id: u8, + /// Gateway envelope sequence used for replay suppression. + pub gateway_sequence: u32, + /// Authenticated gateway boot-session nonce. + pub gateway_boot_nonce: u64, + /// Gateway boot relative observation time retained for diagnostics. + pub observed_at_boot_ms: u32, + /// Gateway receive timestamp on its boot-relative clock. + pub gateway_received_at_boot_us: u64, + /// Gateway receive-time uncertainty. + pub gateway_timing_uncertainty_us: u32, + /// Host receive timestamp used for expiry. + pub received_at_unix_ms: i64, + /// Host computed expiry timestamp. + pub expires_at_unix_ms: i64, + /// Authenticated rotating pseudonym. This is not a person name or device MAC. + pub pseudonym: [u8; 8], + /// Advertiser token epoch in Unix minutes. + pub token_epoch_min: u32, + /// Received signal strength. + pub rssi_dbm: i8, + /// Advertised transmit power, or `127` when absent. + pub tx_power_dbm: i8, + /// Evidence quality in permille. This is not identity probability. + pub confidence_permille: u16, + /// Whether the scanner had a valid wall clock when it checked freshness. + pub scanner_time_verified: bool, +} + +impl BleIdentityEvidence { + /// Verify an RVAE envelope, parse its BLE payload, and update replay state. + pub fn parse_gateway_authenticated( + frame: &[u8], + received_at_unix_ms: i64, + gateway_secret: &[u8; 32], + gateway_config: GatewayIngressConfig, + ble_config: BleIngressConfig, + replay: &mut RadioReplayGuard, + ) -> Result { + let envelope = AuthenticatedGatewayEnvelope::parse(frame, gateway_secret, gateway_config)?; + if envelope.metadata.payload_type != GatewayPayloadType::BleIdentity { + return Err(RadioIngressError::PayloadType); + } + let evidence = Self::parse_payload( + envelope.payload(), + received_at_unix_ms, + ble_config, + envelope.metadata, + )?; + replay.admit_ble(&envelope.metadata, &evidence)?; + Ok(evidence) + } + + fn parse_payload( + packet: &[u8], + received_at_unix_ms: i64, + config: BleIngressConfig, + gateway: GatewayEnvelopeMetadata, + ) -> Result { + if config.max_ttl_ms == 0 + || config.max_ttl_ms > MAX_BLE_TTL_MS + || config.max_token_skew_min > MAX_BLE_TOKEN_SKEW_MIN + { + return Err(RadioIngressError::InvalidPolicy); + } + if packet.len() != BLE_IDENTITY_PACKET_SIZE { + return Err(RadioIngressError::Length); + } + if le_u32(packet, 0) != BLE_IDENTITY_MAGIC { + return Err(RadioIngressError::Magic); + } + if packet[4] != BLE_IDENTITY_VERSION { + return Err(RadioIngressError::Version); + } + let flags = packet[6]; + if flags & !BLE_ALLOWED_FLAGS != 0 || flags & BLE_AUTHENTICATED == 0 { + return Err(RadioIngressError::Flags); + } + if le_u16(packet, 22) != 0 { + return Err(RadioIngressError::Reserved); + } + if packet[5] != gateway.node_id { + return Err(RadioIngressError::NodeId); + } + let ttl_ms = le_u16(packet, 16); + let confidence = le_u16(packet, 18); + let rssi = packet[20] as i8; + let tx_power = packet[21] as i8; + if le_u32(packet, 8) == 0 + || ttl_ms == 0 + || ttl_ms > config.max_ttl_ms + || confidence > 1000 + || !(-127..=20).contains(&rssi) + || !(tx_power == 127 || (-127..=20).contains(&tx_power)) + { + return Err(RadioIngressError::Bounds); + } + let token_epoch_min = le_u32(packet, 32); + let host_epoch_min = received_at_unix_ms.div_euclid(60_000); + if host_epoch_min < 0 + || host_epoch_min.abs_diff(i64::from(token_epoch_min)) + > u64::from(config.max_token_skew_min) + { + return Err(RadioIngressError::Epoch); + } + let expires_at_unix_ms = received_at_unix_ms + .checked_add(i64::from(ttl_ms)) + .ok_or(RadioIngressError::Bounds)?; + let mut pseudonym = [0u8; 8]; + pseudonym.copy_from_slice(&packet[24..32]); + Ok(Self { + node_id: packet[5], + key_id: packet[7], + sequence: le_u32(packet, 8), + gateway_key_id: gateway.key_id, + gateway_sequence: gateway.sequence, + gateway_boot_nonce: gateway.boot_nonce, + observed_at_boot_ms: le_u32(packet, 12), + gateway_received_at_boot_us: gateway.received_at_boot_us, + gateway_timing_uncertainty_us: gateway.timing_uncertainty_us, + received_at_unix_ms, + expires_at_unix_ms, + pseudonym, + token_epoch_min, + rssi_dbm: rssi, + tx_power_dbm: tx_power, + confidence_permille: confidence, + scanner_time_verified: flags & BLE_TIME_VERIFIED != 0, + }) + } + + /// Whether this evidence is still within its explicit TTL. + #[must_use] + pub fn is_live(&self, now_unix_ms: i64) -> bool { + now_unix_ms >= self.received_at_unix_ms && now_unix_ms < self.expires_at_unix_ms + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct GatewayReplaySnapshotEntry { + node_id: u8, + key_id: u8, + boot_nonce: u64, + sequence: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BleReplaySnapshotEntry { + gateway_boot_nonce: u64, + node_id: u8, + key_id: u8, + token_epoch_min: u32, + /// One-way replay fingerprint. The raw eight-byte over-air pseudonym must + /// never be serialized, even into private replay state. + pseudonym_fingerprint: [u8; 32], + sequence: u32, + retain_until_unix_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ChannelSoundingReplaySnapshotEntry { + key_id: u8, + source_id: u32, + source_session_id: u32, + sequence: u32, +} + +/// Versioned, deterministic durable replay state containing no key material. +/// +/// Callers may serialize this value, but must pass deserialized values through +/// [`RadioReplaySnapshot::validate`] or [`RadioReplayGuard::restore`] before use. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RadioReplaySnapshot { + version: u8, + max_gateway_sessions: usize, + max_ble_records: usize, + max_channel_sounding_sessions: usize, + gateway: Vec, + ble: Vec, + channel_sounding: Vec, +} + +impl RadioReplaySnapshot { + /// Validate version, capacities, record bounds, and unique replay keys. + pub fn validate(&self) -> Result<(), RadioIngressError> { + if self.version != RADIO_REPLAY_SNAPSHOT_VERSION + || self.max_gateway_sessions == 0 + || self.max_gateway_sessions > MAX_REPLAY_GATEWAY_SESSIONS + || self.max_ble_records == 0 + || self.max_ble_records > MAX_REPLAY_BLE_RECORDS + || self.max_channel_sounding_sessions == 0 + || self.max_channel_sounding_sessions > MAX_REPLAY_CS_SESSIONS + || self.gateway.len() > self.max_gateway_sessions + || self.ble.len() > self.max_ble_records + || self.channel_sounding.len() > self.max_channel_sounding_sessions + { + return Err(RadioIngressError::InvalidSnapshot); + } + + let mut gateway_keys = BTreeSet::new(); + let mut gateway_boots = BTreeSet::new(); + for entry in &self.gateway { + if entry.boot_nonce == 0 + || entry.sequence == 0 + || !gateway_keys.insert((entry.node_id, entry.key_id, entry.boot_nonce)) + { + return Err(RadioIngressError::InvalidSnapshot); + } + gateway_boots.insert((entry.node_id, entry.boot_nonce)); + } + let mut ble_keys = BTreeSet::new(); + for entry in &self.ble { + if entry.gateway_boot_nonce == 0 + || entry.sequence == 0 + || entry.retain_until_unix_ms < 0 + || !gateway_boots.contains(&(entry.node_id, entry.gateway_boot_nonce)) + || !ble_keys.insert(( + entry.gateway_boot_nonce, + entry.node_id, + entry.key_id, + entry.token_epoch_min, + entry.pseudonym_fingerprint, + )) + || entry.pseudonym_fingerprint.iter().all(|byte| *byte == 0) + { + return Err(RadioIngressError::InvalidSnapshot); + } + } + let mut channel_sounding_keys = BTreeSet::new(); + for entry in &self.channel_sounding { + if entry.source_id == 0 + || entry.source_session_id == 0 + || !channel_sounding_keys.insert(( + entry.key_id, + entry.source_id, + entry.source_session_id, + )) + { + return Err(RadioIngressError::InvalidSnapshot); + } + } + Ok(()) + } + + /// Return the snapshot format version. + #[must_use] + pub fn version(&self) -> u8 { + self.version + } + + /// Return gateway, BLE-record, and companion-session capacities. + #[must_use] + pub fn capacities(&self) -> (usize, usize, usize) { + ( + self.max_gateway_sessions, + self.max_ble_records, + self.max_channel_sounding_sessions, + ) + } +} + +/// Stateful, bounded replay protection for gateway boots, BLE records, and +/// companion Channel Sounding sessions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadioReplayGuard { + gateway_sequences: BTreeMap, + ble_sequences: BTreeMap, + channel_sounding_sequences: BTreeMap, + max_gateway_sessions: usize, + max_ble_records: usize, + max_channel_sounding_sessions: usize, +} + +impl RadioReplayGuard { + /// Construct a fail-closed guard with explicit authenticated-session bounds. + #[must_use] + pub fn new( + max_gateway_sessions: usize, + max_ble_records: usize, + max_channel_sounding_sessions: usize, + ) -> Self { + Self { + gateway_sequences: BTreeMap::new(), + ble_sequences: BTreeMap::new(), + channel_sounding_sequences: BTreeMap::new(), + max_gateway_sessions, + max_ble_records, + max_channel_sounding_sessions, + } + } + + /// Produce validated, deterministic replay state suitable for caller-owned storage. + pub fn snapshot(&self) -> Result { + let snapshot = RadioReplaySnapshot { + version: RADIO_REPLAY_SNAPSHOT_VERSION, + max_gateway_sessions: self.max_gateway_sessions, + max_ble_records: self.max_ble_records, + max_channel_sounding_sessions: self.max_channel_sounding_sessions, + gateway: self + .gateway_sequences + .iter() + .map( + |(&(node_id, key_id, boot_nonce), &sequence)| GatewayReplaySnapshotEntry { + node_id, + key_id, + boot_nonce, + sequence, + }, + ) + .collect(), + ble: self + .ble_sequences + .iter() + .map( + |( + &( + gateway_boot_nonce, + node_id, + key_id, + token_epoch_min, + pseudonym_fingerprint, + ), + &(sequence, retain_until_unix_ms), + )| BleReplaySnapshotEntry { + gateway_boot_nonce, + node_id, + key_id, + token_epoch_min, + pseudonym_fingerprint, + sequence, + retain_until_unix_ms, + }, + ) + .collect(), + channel_sounding: self + .channel_sounding_sequences + .iter() + .map(|(&(key_id, source_id, source_session_id), &sequence)| { + ChannelSoundingReplaySnapshotEntry { + key_id, + source_id, + source_session_id, + sequence, + } + }) + .collect(), + }; + snapshot.validate()?; + Ok(snapshot) + } + + /// Build a guard from validated durable state, dropping expired BLE records. + pub fn from_snapshot( + snapshot: RadioReplaySnapshot, + now_unix_ms: i64, + ) -> Result { + if now_unix_ms < 0 { + return Err(RadioIngressError::InvalidSnapshot); + } + snapshot.validate()?; + let mut guard = Self::new( + snapshot.max_gateway_sessions, + snapshot.max_ble_records, + snapshot.max_channel_sounding_sessions, + ); + for entry in snapshot.gateway { + guard.gateway_sequences.insert( + (entry.node_id, entry.key_id, entry.boot_nonce), + entry.sequence, + ); + } + for entry in snapshot.ble { + if entry.retain_until_unix_ms > now_unix_ms { + guard.ble_sequences.insert( + ( + entry.gateway_boot_nonce, + entry.node_id, + entry.key_id, + entry.token_epoch_min, + entry.pseudonym_fingerprint, + ), + (entry.sequence, entry.retain_until_unix_ms), + ); + } + } + for entry in snapshot.channel_sounding { + guard.channel_sounding_sequences.insert( + (entry.key_id, entry.source_id, entry.source_session_id), + entry.sequence, + ); + } + Ok(guard) + } + + /// Atomically replace this guard with validated durable replay state. + pub fn restore( + &mut self, + snapshot: RadioReplaySnapshot, + now_unix_ms: i64, + ) -> Result<(), RadioIngressError> { + let restored = Self::from_snapshot(snapshot, now_unix_ms)?; + *self = restored; + Ok(()) + } + + /// Atomically admit outer gateway and inner BLE sequences. + pub fn admit_ble( + &mut self, + gateway: &GatewayEnvelopeMetadata, + evidence: &BleIdentityEvidence, + ) -> Result<(), RadioIngressError> { + let gateway_source = (gateway.node_id, gateway.key_id, gateway.boot_nonce); + let ble_source = ( + gateway.boot_nonce, + evidence.node_id, + evidence.key_id, + evidence.token_epoch_min, + ble_replay_fingerprint( + gateway.boot_nonce, + evidence.node_id, + evidence.key_id, + evidence.token_epoch_min, + evidence.pseudonym, + ), + ); + if evidence.node_id != gateway.node_id { + return Err(RadioIngressError::NodeId); + } + if evidence.gateway_key_id != gateway.key_id + || evidence.gateway_sequence != gateway.sequence + || evidence.gateway_boot_nonce != gateway.boot_nonce + { + return Err(RadioIngressError::Bounds); + } + check_strict_sequence( + &self.gateway_sequences, + gateway_source, + gateway.sequence, + self.max_gateway_sessions, + )?; + let live_ble_record = self + .ble_sequences + .get(&ble_source) + .filter(|(_, retain_until_ms)| *retain_until_ms > evidence.received_at_unix_ms); + match live_ble_record { + Some((previous, _)) if evidence.sequence <= *previous => { + return Err(RadioIngressError::Replay); + } + None if self + .ble_sequences + .values() + .filter(|(_, retain_until_ms)| *retain_until_ms > evidence.received_at_unix_ms) + .count() + >= self.max_ble_records => + { + return Err(RadioIngressError::Capacity); + } + _ => {} + } + let retain_until_ms = evidence + .received_at_unix_ms + .checked_add(i64::from(MAX_BLE_TOKEN_SKEW_MIN + 1) * 60_000) + .ok_or(RadioIngressError::Bounds)?; + self.ble_sequences + .retain(|_, (_, retain_until_ms)| *retain_until_ms > evidence.received_at_unix_ms); + record_sequence( + &mut self.gateway_sequences, + gateway_source, + gateway.sequence, + ); + self.ble_sequences + .insert(ble_source, (evidence.sequence, retain_until_ms)); + Ok(()) + } + + /// Atomically admit the outer gateway sequence and inner companion sequence. + pub fn admit_channel_sounding( + &mut self, + gateway: &GatewayEnvelopeMetadata, + measurement: &ChannelSoundingMeasurement, + ) -> Result<(), RadioIngressError> { + let gateway_source = (gateway.node_id, gateway.key_id, gateway.boot_nonce); + let companion_source = ( + measurement.key_id, + measurement.source_id, + measurement.source_session_id, + ); + check_strict_sequence( + &self.gateway_sequences, + gateway_source, + gateway.sequence, + self.max_gateway_sessions, + )?; + check_wrapping_sequence( + &self.channel_sounding_sequences, + companion_source, + measurement.sequence, + self.max_channel_sounding_sessions, + )?; + record_sequence( + &mut self.gateway_sequences, + gateway_source, + gateway.sequence, + ); + record_sequence( + &mut self.channel_sounding_sequences, + companion_source, + measurement.sequence, + ); + Ok(()) + } + + /// Retire one gateway session only after a trusted replay-horizon decision. + pub fn retire_gateway_session(&mut self, node_id: u8, key_id: u8, boot_nonce: u64) { + self.gateway_sequences + .remove(&(node_id, key_id, boot_nonce)); + } + + /// Retire one BLE replay record only after its authenticated freshness horizon. + pub fn retire_ble_record( + &mut self, + gateway_boot_nonce: u64, + node_id: u8, + key_id: u8, + token_epoch_min: u32, + pseudonym: [u8; 8], + ) { + self.ble_sequences.remove(&( + gateway_boot_nonce, + node_id, + key_id, + token_epoch_min, + ble_replay_fingerprint( + gateway_boot_nonce, + node_id, + key_id, + token_epoch_min, + pseudonym, + ), + )); + } + + /// Retire one companion session only after a trusted replay-horizon decision. + pub fn retire_channel_sounding_session( + &mut self, + key_id: u8, + source_id: u32, + source_session_id: u32, + ) { + self.channel_sounding_sequences + .remove(&(key_id, source_id, source_session_id)); + } +} + +impl Default for RadioReplayGuard { + fn default() -> Self { + Self::new(64, 1024, 64) + } +} + +/// Bounded Channel Sounding host verification policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ChannelSoundingIngressConfig { + /// Expected provisioned companion key selector. + pub key_id: u8, + /// Expected provisioned companion source identifier. + pub source_id: u32, + /// Maximum admitted companion supplied sample age. + pub max_sample_age_us: u32, + /// Minimum quality in permille. + pub min_quality_permille: u16, +} + +impl Default for ChannelSoundingIngressConfig { + fn default() -> Self { + Self { + key_id: 0, + source_id: 0, + max_sample_age_us: 2_000_000, + min_quality_permille: 600, + } + } +} + +/// An authenticated phase and timing primitive from a capable companion radio. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChannelSoundingMeasurement { + /// Gateway node that authenticated and forwarded the primitive. + pub gateway_node_id: u8, + /// Gateway envelope key selector. + pub gateway_key_id: u8, + /// Gateway envelope sequence. + pub gateway_sequence: u32, + /// Authenticated gateway boot-session nonce. + pub gateway_boot_nonce: u64, + /// Companion key selector. + pub key_id: u8, + /// Per source monotonic sequence. + pub sequence: u32, + /// Opaque provisioned companion source identifier. + pub source_id: u32, + /// Nonzero companion boot or controller session identifier. + pub source_session_id: u32, + /// Nonzero Channel Sounding procedure identifier. + pub procedure_id: u32, + /// Bluetooth RF channel index. + pub channel_index: u16, + /// Zero-based step index within the procedure. + pub step_index: u16, + /// Total bounded steps declared for the procedure. + pub step_count: u16, + /// Companion supplied age at UART transmission. + pub sample_age_us: u32, + /// Gateway receive time on its boot-relative clock. + pub gateway_received_at_boot_us: u64, + /// Approximate capture time on the same gateway boot-relative clock. + pub capture_at_gateway_boot_us: u64, + /// Independent host wall-clock receipt time; never compared with gateway boot time. + pub host_received_at_unix_us: i64, + /// Gateway-declared receive-time uncertainty. + pub gateway_timing_uncertainty_us: u32, + /// Timing uncertainty declared by the companion. + pub timing_uncertainty_us: u16, + /// Corrected carrier phase in milliradians. + pub phase_millirad: i32, + /// Round trip timing estimate in picoseconds. + pub rtt_picoseconds: i32, + /// Estimated carrier frequency offset. + pub frequency_offset_hz: i32, + /// Measurement quality in permille. + pub quality_permille: u16, + /// Companion declared calibration state. + pub calibrated: bool, + /// Companion declared gross motion state. + pub motion: bool, +} + +impl ChannelSoundingMeasurement { + /// Verify the outer RVAE and inner RVCS HMACs, then update replay state. + pub fn parse_gateway_authenticated( + frame: &[u8], + host_received_at_unix_us: i64, + gateway_secret: &[u8; 32], + gateway_config: GatewayIngressConfig, + companion_secret: &[u8; 32], + companion_config: ChannelSoundingIngressConfig, + replay: &mut RadioReplayGuard, + ) -> Result { + let envelope = AuthenticatedGatewayEnvelope::parse(frame, gateway_secret, gateway_config)?; + if envelope.metadata.payload_type != GatewayPayloadType::ChannelSounding { + return Err(RadioIngressError::PayloadType); + } + let measurement = Self::parse_companion_payload( + envelope.payload(), + host_received_at_unix_us, + envelope.metadata, + companion_secret, + companion_config, + )?; + replay.admit_channel_sounding(&envelope.metadata, &measurement)?; + Ok(measurement) + } + + fn parse_companion_payload( + frame: &[u8], + host_received_at_unix_us: i64, + gateway: GatewayEnvelopeMetadata, + secret: &[u8; 32], + config: ChannelSoundingIngressConfig, + ) -> Result { + if config.source_id == 0 + || config.max_sample_age_us == 0 + || config.max_sample_age_us > 10_000_000 + || config.min_quality_permille == 0 + || config.min_quality_permille > 1000 + { + return Err(RadioIngressError::InvalidPolicy); + } + if host_received_at_unix_us < 0 { + return Err(RadioIngressError::Bounds); + } + if secret.iter().all(|byte| *byte == 0) { + return Err(RadioIngressError::Authentication); + } + if frame.len() != CHANNEL_SOUNDING_FRAME_SIZE { + return Err(RadioIngressError::Length); + } + if le_u32(frame, 0) != CHANNEL_SOUNDING_MAGIC { + return Err(RadioIngressError::Magic); + } + if frame[4] != CHANNEL_SOUNDING_VERSION { + return Err(RadioIngressError::Version); + } + let flags = frame[5]; + if flags & !CS_ALLOWED_FLAGS != 0 { + return Err(RadioIngressError::Flags); + } + if frame[7] != 0 || le_u16(frame, 8) as usize != CHANNEL_SOUNDING_FRAME_SIZE { + return Err(RadioIngressError::Reserved); + } + if crc32(&frame[..CS_CRC_OFFSET]) != le_u32(frame, CS_CRC_OFFSET) { + return Err(RadioIngressError::Crc); + } + if frame[6] != config.key_id { + return Err(RadioIngressError::KeyId); + } + let mut mac = + HmacSha256::new_from_slice(secret).map_err(|_| RadioIngressError::Authentication)?; + mac.update(CS_MAC_DOMAIN); + mac.update(&frame[..CS_SIGNED_PREFIX]); + mac.verify_truncated_left(&frame[CS_TAG_OFFSET..CS_CRC_OFFSET]) + .map_err(|_| RadioIngressError::Authentication)?; + + let sample_age_us = le_u32(frame, 16); + let source_id = le_u32(frame, 20); + let quality = le_u16(frame, 24); + let timing_uncertainty_us = le_u16(frame, 26); + let channel_index = le_u16(frame, 10); + let phase = le_i32(frame, 28); + let rtt = le_i32(frame, 32); + let offset = le_i32(frame, 36); + let source_session_id = le_u32(frame, 40); + let procedure_id = le_u32(frame, 44); + let step_index = le_u16(frame, 48); + let step_count = le_u16(frame, 50); + if source_id != config.source_id { + return Err(RadioIngressError::SourceId); + } + if source_session_id == 0 + || procedure_id == 0 + || !(4..=79).contains(&step_count) + || step_index >= step_count + || channel_index > 78 + || sample_age_us > config.max_sample_age_us + || quality < config.min_quality_permille + || quality > 1000 + || timing_uncertainty_us > 10_000 + || !(-3142..=3142).contains(&phase) + || !(0..=250_000).contains(&rtt) + || !(-500_000..=500_000).contains(&offset) + { + return Err(if sample_age_us > config.max_sample_age_us { + RadioIngressError::Stale + } else { + RadioIngressError::Bounds + }); + } + let capture_at_gateway_boot_us = gateway + .received_at_boot_us + .checked_sub(u64::from(sample_age_us)) + .ok_or(RadioIngressError::Stale)?; + Ok(Self { + gateway_node_id: gateway.node_id, + gateway_key_id: gateway.key_id, + gateway_sequence: gateway.sequence, + gateway_boot_nonce: gateway.boot_nonce, + key_id: frame[6], + sequence: le_u32(frame, 12), + source_id, + source_session_id, + procedure_id, + channel_index, + step_index, + step_count, + sample_age_us, + gateway_received_at_boot_us: gateway.received_at_boot_us, + capture_at_gateway_boot_us, + host_received_at_unix_us, + gateway_timing_uncertainty_us: gateway.timing_uncertainty_us, + timing_uncertainty_us, + phase_millirad: phase, + rtt_picoseconds: rtt, + frequency_offset_hz: offset, + quality_permille: quality, + calibrated: flags & CS_CALIBRATED != 0, + motion: flags & CS_MOTION != 0, + }) + } +} + +/// Why the respiration path refused to emit a number. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RespirationAbstainReason { + /// A caller supplied unsafe or nonsensical fusion policy. + InvalidPolicy, + /// Too few valid samples or too short a time window. + InsufficientCoverage, + /// Gross motion invalidates micromotion inference. + Motion, + /// Sources or RF channels were mixed without calibration. + IncoherentSource, + /// The phase series did not contain a dominant respiratory component. + LowSpectralConfidence, + /// CSI and Channel Sounding estimates conflict beyond the safety threshold. + SourceConflict, + /// Every available estimate expired. + Expired, +} + +/// Evidence origin. This code cannot mint a measured label. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum RadioEvidenceLabel { + /// Generated by the deterministic simulator. + Synthetic, + /// Produced by an unvalidated hardware path and therefore only claimed. + Claimed, +} + +/// Respiration estimate or explicit abstention. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum RespirationDecision { + /// A bounded, nonclinical rate estimate. + Estimated { + /// Breaths per minute. + bpm: f64, + /// Variance used for uncertainty weighted fusion. + variance: f64, + /// Quality score in `[0, 1]`. + confidence: f64, + /// Honest evidence label. + evidence: RadioEvidenceLabel, + }, + /// No number was emitted. + Abstain { + /// Explicit reason. + reason: RespirationAbstainReason, + }, +} + +/// Deterministic phase window estimator configuration. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RespirationEstimatorConfig { + /// Minimum primitives retained from complete coherent procedures. + pub min_samples: usize, + /// Minimum covered duration. + pub min_duration_us: u64, + /// Minimum accepted spectral peak ratio. + pub min_peak_ratio: f64, + /// Evidence label assigned by the caller. + pub evidence: RadioEvidenceLabel, +} + +impl Default for RespirationEstimatorConfig { + fn default() -> Self { + Self { + min_samples: 80, + min_duration_us: 8_000_000, + min_peak_ratio: 4.0, + evidence: RadioEvidenceLabel::Claimed, + } + } +} + +fn complete_coherent_procedure_samples( + samples: &[ChannelSoundingMeasurement], +) -> Result, RespirationAbstainReason> { + let Some(anchor) = samples.first() else { + return Ok(Vec::new()); + }; + if samples.iter().any(|sample| { + sample.source_id != anchor.source_id + || sample.source_session_id != anchor.source_session_id + || sample.key_id != anchor.key_id + || sample.gateway_node_id != anchor.gateway_node_id + || sample.gateway_key_id != anchor.gateway_key_id + || sample.gateway_boot_nonce != anchor.gateway_boot_nonce + || sample.gateway_sequence == 0 + || sample.gateway_boot_nonce == 0 + || sample.source_id == 0 + || sample.source_session_id == 0 + || sample.procedure_id == 0 + || sample.step_index >= sample.step_count + || sample.channel_index > 78 + || sample.quality_permille > 1000 + || sample.timing_uncertainty_us > 10_000 + || !(-3142..=3142).contains(&sample.phase_millirad) + || !(0..=250_000).contains(&sample.rtt_picoseconds) + || !(-500_000..=500_000).contains(&sample.frequency_offset_hz) + || sample.capture_at_gateway_boot_us > sample.gateway_received_at_boot_us + || sample.host_received_at_unix_us < 0 + || !sample.calibrated + }) { + return Err(RespirationAbstainReason::IncoherentSource); + } + if samples.iter().any(|sample| sample.motion) { + return Err(RespirationAbstainReason::Motion); + } + + let mut procedures: BTreeMap> = BTreeMap::new(); + for sample in samples { + procedures + .entry(sample.procedure_id) + .or_default() + .push(sample); + } + let mut expected_channel_plan: Option> = None; + let mut admitted = Vec::new(); + for procedure in procedures.values() { + let step_count = procedure[0].step_count; + if !(4..=79).contains(&step_count) + || procedure + .iter() + .any(|sample| sample.step_count != step_count) + { + return Err(RespirationAbstainReason::IncoherentSource); + } + let mut by_step = BTreeMap::new(); + let mut channels = BTreeSet::new(); + for sample in procedure { + if by_step.insert(sample.step_index, *sample).is_some() + || !channels.insert(sample.channel_index) + { + return Err(RespirationAbstainReason::IncoherentSource); + } + } + if by_step.len() < usize::from(step_count) { + continue; + } + if by_step.len() != usize::from(step_count) || channels.len() != usize::from(step_count) { + return Err(RespirationAbstainReason::IncoherentSource); + } + let channel_plan: Vec<_> = by_step + .values() + .map(|sample| sample.channel_index) + .collect(); + if let Some(expected) = &expected_channel_plan { + if *expected != channel_plan { + return Err(RespirationAbstainReason::IncoherentSource); + } + } else { + expected_channel_plan = Some(channel_plan); + } + admitted.extend(by_step.into_values()); + } + admitted.sort_by_key(|sample| sample.capture_at_gateway_boot_us); + Ok(admitted) +} + +/// Estimate respiration from complete, coherent authenticated procedures. +#[must_use] +pub fn estimate_channel_sounding_respiration( + samples: &[ChannelSoundingMeasurement], + config: RespirationEstimatorConfig, +) -> RespirationDecision { + if config.min_samples == 0 + || config.min_duration_us == 0 + || !config.min_peak_ratio.is_finite() + || config.min_peak_ratio <= 1.0 + { + return abstain(RespirationAbstainReason::InvalidPolicy); + } + if samples.len() < config.min_samples { + return abstain(RespirationAbstainReason::InsufficientCoverage); + } + let ordered = match complete_coherent_procedure_samples(samples) { + Ok(ordered) => ordered, + Err(reason) => return abstain(reason), + }; + if ordered.len() < config.min_samples { + return abstain(RespirationAbstainReason::InsufficientCoverage); + } + let duration = ordered + .last() + .map(|sample| sample.capture_at_gateway_boot_us) + .unwrap_or(ordered[0].capture_at_gateway_boot_us) + - ordered[0].capture_at_gateway_boot_us; + if duration < config.min_duration_us { + return abstain(RespirationAbstainReason::InsufficientCoverage); + } + + let t0 = ordered[0].capture_at_gateway_boot_us; + let mut channel_phase_sums: BTreeMap = BTreeMap::new(); + for sample in &ordered { + let phase = f64::from(sample.phase_millirad) / 1000.0; + let entry = channel_phase_sums + .entry(sample.channel_index) + .or_insert((0.0, 0.0)); + entry.0 += phase.sin(); + entry.1 += phase.cos(); + } + let points: Vec<(f64, f64)> = ordered + .iter() + .map(|sample| { + let phase = f64::from(sample.phase_millirad) / 1000.0; + let (sin_sum, cos_sum) = channel_phase_sums[&sample.channel_index]; + let channel_mean = sin_sum.atan2(cos_sum); + let centered_phase = (phase - channel_mean + TAU / 2.0).rem_euclid(TAU) - TAU / 2.0; + ( + (sample.capture_at_gateway_boot_us - t0) as f64 / 1_000_000.0, + centered_phase, + ) + }) + .collect(); + + let mut best_frequency = 0.0; + let mut best_power = 0.0; + let mut power_sum = 0.0; + let mut bins = 0usize; + for bin in 0..=240 { + let frequency = 0.1 + bin as f64 * 0.0025; + let mut real = 0.0; + let mut imag = 0.0; + for &(time, phase) in &points { + let angle = TAU * frequency * time; + real += phase * angle.cos(); + imag -= phase * angle.sin(); + } + let power = real * real + imag * imag; + power_sum += power; + bins += 1; + if power > best_power { + best_power = power; + best_frequency = frequency; + } + } + let background = ((power_sum - best_power) / (bins.saturating_sub(1).max(1) as f64)).max(1e-12); + let peak_ratio = best_power / background; + if !peak_ratio.is_finite() || peak_ratio < config.min_peak_ratio { + return abstain(RespirationAbstainReason::LowSpectralConfidence); + } + let mean_quality = ordered + .iter() + .map(|sample| f64::from(sample.quality_permille) / 1000.0) + .sum::() + / ordered.len() as f64; + let confidence = (mean_quality * (1.0 - 1.0 / peak_ratio)).clamp(0.0, 0.99); + RespirationDecision::Estimated { + bpm: best_frequency * 60.0, + variance: 0.25 + (1.0 - confidence) * 4.0, + confidence, + evidence: config.evidence, + } +} + +/// One prevalidated rate estimate for cross modality fusion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RespirationEvidence { + /// Breaths per minute. + pub bpm: f64, + /// Strictly positive variance. + pub variance: f64, + /// Expiry on the host time axis. + pub expires_at_ms: i64, + /// Whether gross motion was present. + pub motion: bool, + /// Evidence label. + pub evidence: RadioEvidenceLabel, +} + +/// Fuse live CSI and Channel Sounding rate evidence, or abstain on conflict. +#[must_use] +pub fn fuse_respiration( + inputs: &[RespirationEvidence], + now_ms: i64, + max_disagreement_bpm: f64, +) -> RespirationDecision { + if !max_disagreement_bpm.is_finite() + || max_disagreement_bpm <= 0.0 + || max_disagreement_bpm > MAX_RESPIRATION_DISAGREEMENT_BPM + { + return abstain(RespirationAbstainReason::InvalidPolicy); + } + let usable: Vec<_> = inputs + .iter() + .filter(|input| { + input.expires_at_ms > now_ms + && input.bpm.is_finite() + && (3.0..=60.0).contains(&input.bpm) + && input.variance.is_finite() + && input.variance > 0.0 + && (1.0 / input.variance).is_finite() + }) + .collect(); + if usable.is_empty() { + return abstain(RespirationAbstainReason::Expired); + } + if usable.iter().any(|input| input.motion) { + return abstain(RespirationAbstainReason::Motion); + } + let min = usable + .iter() + .map(|input| input.bpm) + .fold(f64::INFINITY, f64::min); + let max = usable + .iter() + .map(|input| input.bpm) + .fold(f64::NEG_INFINITY, f64::max); + if max - min > max_disagreement_bpm { + return abstain(RespirationAbstainReason::SourceConflict); + } + let precision_sum: f64 = usable.iter().map(|input| 1.0 / input.variance).sum(); + if !precision_sum.is_finite() || precision_sum <= 0.0 { + return abstain(RespirationAbstainReason::InvalidPolicy); + } + let bpm = usable + .iter() + .map(|input| input.bpm / input.variance) + .sum::() + / precision_sum; + let evidence = if usable + .iter() + .any(|input| input.evidence == RadioEvidenceLabel::Synthetic) + { + RadioEvidenceLabel::Synthetic + } else { + RadioEvidenceLabel::Claimed + }; + RespirationDecision::Estimated { + bpm, + variance: 1.0 / precision_sum, + confidence: (1.0 / (1.0 + 1.0 / precision_sum)).clamp(0.0, 0.99), + evidence, + } +} + +/// Candidate likelihood that a short lived BLE pseudonym is colocated with a track. +#[derive(Clone, Debug, PartialEq)] +pub struct TrackLikelihood { + /// Candidate persistent pseudonymous track. + pub track: TrackId, + /// Bounded spatial likelihood in `[0, 1]` from an upstream localizer. + pub likelihood: f64, +} + +/// BLE anchor evidence plus bounded spatial candidates. +#[derive(Clone, Debug, PartialEq)] +pub struct BleAssociationInput { + /// Authenticated, expiring BLE evidence. + pub evidence: BleIdentityEvidence, + /// Candidate scores derived from geometry. RSSI alone is not position. + pub candidates: Vec, +} + +/// Why a BLE pseudonym did not bind to a track. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BleAssociationAbstainReason { + /// A caller supplied an unsafe or nonsensical association policy. + InvalidPolicy, + /// The evidence TTL expired. + Expired, + /// No live observation met the minimum authenticated evidence quality. + LowQuality, + /// Candidate geometry was absent or weak. + InsufficientGeometry, + /// Top candidates were too close to call. + Ambiguous, + /// Concurrent observations strongly placed one pseudonym on incompatible tracks. + SpoofConflict, + /// Two pseudonyms competed for the same track. + OneToOneConflict, +} + +/// Short horizon pseudonymous anchor association. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum BleAssociationDecision { + /// Pseudonym bound to a track for no longer than its TTL. + Bound { + /// BLE advertiser key selector that scopes the rotating pseudonym. + key_id: u8, + /// Token epoch that scopes the rotating pseudonym. + token_epoch_min: u32, + /// Rotating pseudonym. + pseudonym: [u8; 8], + /// Existing privacy preserving track. + track: TrackId, + /// Association confidence. + confidence: f64, + /// Binding expiry. + expires_at_unix_ms: i64, + }, + /// No binding was emitted. + Abstain { + /// BLE advertiser key selector that scopes the rotating pseudonym. + key_id: u8, + /// Token epoch that scopes the rotating pseudonym. + token_epoch_min: u32, + /// Rotating pseudonym, when present. + pseudonym: [u8; 8], + /// Explicit reason. + reason: BleAssociationAbstainReason, + }, +} + +/// Associate authenticated BLE pseudonyms to tracks with fail closed ambiguity. +#[must_use] +pub fn associate_ble_to_tracks( + inputs: &[BleAssociationInput], + now_unix_ms: i64, + min_likelihood: f64, + min_margin: f64, +) -> Vec { + type IdentityKey = (u8, u32, [u8; 8]); + let mut grouped: BTreeMap> = BTreeMap::new(); + for input in inputs { + grouped + .entry(( + input.evidence.key_id, + input.evidence.token_epoch_min, + input.evidence.pseudonym, + )) + .or_default() + .push(input); + } + let mut decisions = Vec::new(); + let mut proposed: Vec<(IdentityKey, TrackId, f64, i64)> = Vec::new(); + let policy_valid = min_likelihood.is_finite() + && min_margin.is_finite() + && min_likelihood > 0.0 + && min_likelihood <= 1.0 + && min_margin > 0.0 + && min_margin <= 1.0; + for (identity, group) in grouped { + let (key_id, token_epoch_min, pseudonym) = identity; + if !policy_valid { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::InvalidPolicy, + }); + continue; + } + let live: Vec<_> = group + .into_iter() + .filter(|input| input.evidence.is_live(now_unix_ms)) + .collect(); + if live.is_empty() { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::Expired, + }); + continue; + } + let qualified: Vec<_> = live + .into_iter() + .filter(|input| { + input.evidence.confidence_permille >= BLE_ASSOCIATION_MIN_CONFIDENCE_PERMILLE + }) + .collect(); + if qualified.is_empty() { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::LowQuality, + }); + continue; + } + + let strong_tops: BTreeSet = qualified + .iter() + .filter_map(|input| best_candidate(&input.candidates)) + .filter(|(_, score, margin)| *score >= min_likelihood && *margin >= min_margin) + .map(|(track, _, _)| track.clone()) + .collect(); + if strong_tops.len() > 1 { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::SpoofConflict, + }); + continue; + } + + let mut scores: BTreeMap = BTreeMap::new(); + for input in &qualified { + for candidate in &input.candidates { + if candidate.likelihood.is_finite() && (0.0..=1.0).contains(&candidate.likelihood) { + let entry = scores.entry(candidate.track.clone()).or_insert((0.0, 0)); + entry.0 += candidate.likelihood; + entry.1 += 1; + } + } + } + let averaged: Vec<_> = scores + .into_iter() + .map(|(track, (sum, count))| TrackLikelihood { + track, + likelihood: sum / count as f64, + }) + .collect(); + let Some((track, score, margin)) = best_candidate(&averaged) else { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::InsufficientGeometry, + }); + continue; + }; + if score < min_likelihood { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::InsufficientGeometry, + }); + } else if margin < min_margin { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::Ambiguous, + }); + } else { + let expiry = qualified + .iter() + .map(|input| input.evidence.expires_at_unix_ms) + .min() + .unwrap_or(now_unix_ms); + proposed.push((identity, track.clone(), score, expiry)); + } + } + + let mut track_counts: BTreeMap = BTreeMap::new(); + for (_, track, _, _) in &proposed { + *track_counts.entry(track.clone()).or_default() += 1; + } + for ((key_id, token_epoch_min, pseudonym), track, confidence, expiry) in proposed { + if track_counts.get(&track).copied().unwrap_or(0) > 1 { + decisions.push(BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + reason: BleAssociationAbstainReason::OneToOneConflict, + }); + } else { + decisions.push(BleAssociationDecision::Bound { + key_id, + token_epoch_min, + pseudonym, + track, + confidence, + expires_at_unix_ms: expiry, + }); + } + } + decisions.sort_by_key(|decision| match decision { + BleAssociationDecision::Bound { + key_id, + token_epoch_min, + pseudonym, + .. + } + | BleAssociationDecision::Abstain { + key_id, + token_epoch_min, + pseudonym, + .. + } => (*key_id, *token_epoch_min, *pseudonym), + }); + decisions +} + +/// Deterministic replay acceptance report. Every field is `SYNTHETIC`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SyntheticReplayReport { + /// Required evidence label. + pub evidence: String, + /// Both pseudonyms bound before the crossing. + pub bound_before_crossing: bool, + /// Exact overlap caused an ambiguity abstention rather than an identity swap. + pub abstained_at_crossing: bool, + /// Both pseudonyms rebound to their original tracks after crossing. + pub stable_after_crossing: bool, + /// Incompatible duplicate evidence caused spoof abstention. + pub spoof_abstained: bool, + /// Expired evidence caused abstention. + pub expiry_abstained: bool, + /// Gross motion caused respiration abstention. + pub motion_abstained: bool, + /// Synthetic CSI plus Channel Sounding respiration output. + pub fused_respiration_bpm: Option, +} + +/// Run the built in two track deterministic simulation. +#[must_use] +pub fn run_synthetic_ble_cs_replay() -> SyntheticReplayReport { + let epoch_min = 30_000_000u32; + let now_ms = i64::from(epoch_min) * 60_000; + let track_a = TrackId::new("synthetic-track-a").expect("static id"); + let track_b = TrackId::new("synthetic-track-b").expect("static id"); + let pseudo_a = [0xa1; 8]; + let pseudo_b = [0xb2; 8]; + + let before = synthetic_association_frame( + now_ms, + epoch_min, + (&track_a, pseudo_a), + (&track_b, pseudo_b), + 4.5, + 5.5, + ); + let overlap = synthetic_association_frame( + now_ms + 500, + epoch_min, + (&track_a, pseudo_a), + (&track_b, pseudo_b), + 5.0, + 5.0, + ); + let after = synthetic_association_frame( + now_ms + 1_000, + epoch_min, + (&track_a, pseudo_a), + (&track_b, pseudo_b), + 5.5, + 4.5, + ); + + let before_decisions = associate_ble_to_tracks(&before, now_ms, 0.6, 0.15); + let overlap_decisions = associate_ble_to_tracks(&overlap, now_ms + 500, 0.6, 0.15); + let after_decisions = associate_ble_to_tracks(&after, now_ms + 1_000, 0.6, 0.15); + + let mut spoof = before.clone(); + spoof.push(BleAssociationInput { + evidence: synthetic_ble_evidence(1, pseudo_a, epoch_min, now_ms), + candidates: vec![ + TrackLikelihood { + track: track_b.clone(), + likelihood: 0.98, + }, + TrackLikelihood { + track: track_a.clone(), + likelihood: 0.02, + }, + ], + }); + let spoof_decisions = associate_ble_to_tracks(&spoof, now_ms, 0.6, 0.15); + let expiry_decisions = associate_ble_to_tracks(&before, now_ms + 10_000, 0.6, 0.15); + + let gateway_secret = [0x24u8; 32]; + let companion_secret = [0x42u8; 32]; + let gateway_config = GatewayIngressConfig { + node_id: 1, + key_id: 4, + ..Default::default() + }; + let companion_config = ChannelSoundingIngressConfig { + key_id: 9, + source_id: 101, + ..Default::default() + }; + let mut replay = RadioReplayGuard::new(1, 16, 1); + let mut cs_samples = Vec::new(); + for index in 0..400u32 { + let t = f64::from(index) / 20.0; + let phase_millirad = (900.0 * (TAU * 0.25 * t).sin()).round() as i32; + let payload = synthetic_cs_frame(index + 1, phase_millirad, false, &companion_secret); + let gateway_received_at_boot_us = 5_000_000u64 + u64::from(index) * 50_000; + let frame = synthetic_gateway_frame( + GATEWAY_PAYLOAD_CHANNEL_SOUNDING, + index + 1, + 0x0102_0304_0506_0708, + gateway_received_at_boot_us, + 8_000, + &payload, + &gateway_secret, + ); + cs_samples.push( + ChannelSoundingMeasurement::parse_gateway_authenticated( + &frame, + now_ms * 1_000 + i64::from(index) * 50_000, + &gateway_secret, + gateway_config, + &companion_secret, + companion_config, + &mut replay, + ) + .expect("synthetic frame is valid"), + ); + } + let cs_estimate = estimate_channel_sounding_respiration( + &cs_samples, + RespirationEstimatorConfig { + evidence: RadioEvidenceLabel::Synthetic, + ..Default::default() + }, + ); + let cs_rate = match cs_estimate { + RespirationDecision::Estimated { bpm, variance, .. } => Some(RespirationEvidence { + bpm, + variance, + expires_at_ms: now_ms + 2_000, + motion: false, + evidence: RadioEvidenceLabel::Synthetic, + }), + RespirationDecision::Abstain { .. } => None, + }; + let mut rates = vec![RespirationEvidence { + bpm: 14.8, + variance: 1.0, + expires_at_ms: now_ms + 2_000, + motion: false, + evidence: RadioEvidenceLabel::Synthetic, + }]; + if let Some(rate) = cs_rate { + rates.push(rate); + } + let fused = fuse_respiration(&rates, now_ms + 1_500, 3.0); + let fused_respiration_bpm = match fused { + RespirationDecision::Estimated { bpm, .. } => Some(bpm), + RespirationDecision::Abstain { .. } => None, + }; + + let mut moving = cs_samples; + moving[200].motion = true; + let motion_abstained = matches!( + estimate_channel_sounding_respiration( + &moving, + RespirationEstimatorConfig { + evidence: RadioEvidenceLabel::Synthetic, + ..Default::default() + }, + ), + RespirationDecision::Abstain { + reason: RespirationAbstainReason::Motion + } + ); + + SyntheticReplayReport { + evidence: SYNTHETIC_LABEL.to_string(), + bound_before_crossing: count_bound(&before_decisions) == 2, + abstained_at_crossing: overlap_decisions.iter().all(|decision| matches!( + decision, + BleAssociationDecision::Abstain { reason: BleAssociationAbstainReason::Ambiguous, .. } + )), + stable_after_crossing: bound_to(&after_decisions, pseudo_a, &track_a) + && bound_to(&after_decisions, pseudo_b, &track_b), + spoof_abstained: spoof_decisions.iter().any(|decision| matches!( + decision, + BleAssociationDecision::Abstain { pseudonym, reason: BleAssociationAbstainReason::SpoofConflict, .. } + if *pseudonym == pseudo_a + )), + expiry_abstained: expiry_decisions.iter().all(|decision| matches!( + decision, + BleAssociationDecision::Abstain { reason: BleAssociationAbstainReason::Expired, .. } + )), + motion_abstained, + fused_respiration_bpm, + } +} + +fn synthetic_association_frame( + now_ms: i64, + epoch_min: u32, + subject_a: (&TrackId, [u8; 8]), + subject_b: (&TrackId, [u8; 8]), + x_a: f64, + x_b: f64, +) -> Vec { + let (track_a, pseudo_a) = subject_a; + let (track_b, pseudo_b) = subject_b; + let sigma = 0.35f64; + let score = + |anchor_x: f64, track_x: f64| (-(anchor_x - track_x).powi(2) / (2.0 * sigma * sigma)).exp(); + vec![ + BleAssociationInput { + evidence: synthetic_ble_evidence(1, pseudo_a, epoch_min, now_ms), + candidates: vec![ + TrackLikelihood { + track: track_a.clone(), + likelihood: score(x_a, x_a), + }, + TrackLikelihood { + track: track_b.clone(), + likelihood: score(x_a, x_b), + }, + ], + }, + BleAssociationInput { + evidence: synthetic_ble_evidence(2, pseudo_b, epoch_min, now_ms), + candidates: vec![ + TrackLikelihood { + track: track_a.clone(), + likelihood: score(x_b, x_a), + }, + TrackLikelihood { + track: track_b.clone(), + likelihood: score(x_b, x_b), + }, + ], + }, + ] +} + +fn synthetic_ble_evidence( + sequence: u32, + pseudonym: [u8; 8], + epoch_min: u32, + now_ms: i64, +) -> BleIdentityEvidence { + BleIdentityEvidence { + node_id: 1, + key_id: 7, + sequence, + gateway_key_id: 4, + gateway_sequence: sequence, + gateway_boot_nonce: 0x0102_0304_0506_0708, + observed_at_boot_ms: sequence * 100, + gateway_received_at_boot_us: u64::from(sequence) * 100_000, + gateway_timing_uncertainty_us: 1_000, + received_at_unix_ms: now_ms, + expires_at_unix_ms: now_ms + 3_000, + pseudonym, + token_epoch_min: epoch_min, + rssi_dbm: -60, + tx_power_dbm: 127, + confidence_permille: 900, + scanner_time_verified: true, + } +} + +fn synthetic_cs_frame( + sequence: u32, + phase_millirad: i32, + motion: bool, + secret: &[u8; 32], +) -> [u8; CHANNEL_SOUNDING_FRAME_SIZE] { + let mut frame = [0u8; CHANNEL_SOUNDING_FRAME_SIZE]; + let ordinal = sequence.saturating_sub(1); + let step_index = (ordinal % 20) as u16; + put_u32(&mut frame, 0, CHANNEL_SOUNDING_MAGIC); + frame[4] = CHANNEL_SOUNDING_VERSION; + frame[5] = CS_CALIBRATED | if motion { CS_MOTION } else { 0 }; + frame[6] = 9; + put_u16(&mut frame, 8, CHANNEL_SOUNDING_FRAME_SIZE as u16); + put_u16(&mut frame, 10, step_index); + put_u32(&mut frame, 12, sequence); + put_u32(&mut frame, 16, 1_000); + put_u32(&mut frame, 20, 101); + put_u16(&mut frame, 24, 900); + put_u16(&mut frame, 26, 100); + put_i32(&mut frame, 28, phase_millirad); + put_i32(&mut frame, 32, 50_000); + put_i32(&mut frame, 36, 0); + put_u32(&mut frame, 40, 77); + put_u32(&mut frame, 44, 1 + ordinal / 20); + put_u16(&mut frame, 48, step_index); + put_u16(&mut frame, 50, 20); + let mut mac = HmacSha256::new_from_slice(secret).expect("fixed key length"); + mac.update(CS_MAC_DOMAIN); + mac.update(&frame[..CS_SIGNED_PREFIX]); + let digest = mac.finalize().into_bytes(); + frame[CS_TAG_OFFSET..CS_CRC_OFFSET].copy_from_slice(&digest[..CS_TAG_SIZE]); + let checksum = crc32(&frame[..CS_CRC_OFFSET]); + put_u32(&mut frame, CS_CRC_OFFSET, checksum); + frame +} + +#[allow(clippy::too_many_arguments)] +fn synthetic_gateway_frame( + payload_type: u8, + sequence: u32, + boot_nonce: u64, + received_at_boot_us: u64, + timing_uncertainty_us: u32, + payload: &[u8], + secret: &[u8; 32], +) -> Vec { + let signed_len = GATEWAY_ENVELOPE_HEADER_SIZE + payload.len(); + let total_len = signed_len + GATEWAY_ENVELOPE_TAG_SIZE; + let mut frame = vec![0u8; total_len]; + put_u32(&mut frame, 0, GATEWAY_ENVELOPE_MAGIC); + frame[4] = GATEWAY_ENVELOPE_VERSION; + frame[5] = payload_type; + frame[6] = GATEWAY_FLAG_RX_MONOTONIC; + frame[7] = 4; + put_u16(&mut frame, 8, total_len as u16); + put_u16(&mut frame, 10, payload.len() as u16); + frame[12] = 1; + put_u32(&mut frame, 16, sequence); + put_u64(&mut frame, 20, boot_nonce); + put_u64(&mut frame, 28, received_at_boot_us); + put_u32(&mut frame, 36, timing_uncertainty_us); + frame[GATEWAY_ENVELOPE_HEADER_SIZE..signed_len].copy_from_slice(payload); + let mut mac = HmacSha256::new_from_slice(secret).expect("fixed key length"); + mac.update(GATEWAY_MAC_DOMAIN); + mac.update(&frame[..signed_len]); + let digest = mac.finalize().into_bytes(); + frame[signed_len..].copy_from_slice(&digest[..GATEWAY_ENVELOPE_TAG_SIZE]); + frame +} + +fn best_candidate(candidates: &[TrackLikelihood]) -> Option<(&TrackId, f64, f64)> { + let mut valid: Vec<_> = candidates + .iter() + .filter(|candidate| { + candidate.likelihood.is_finite() && (0.0..=1.0).contains(&candidate.likelihood) + }) + .collect(); + valid.sort_by(|a, b| { + b.likelihood + .total_cmp(&a.likelihood) + .then_with(|| a.track.as_str().cmp(b.track.as_str())) + }); + let first = valid.first()?; + let second = valid + .get(1) + .map(|candidate| candidate.likelihood) + .unwrap_or(0.0); + Some((&first.track, first.likelihood, first.likelihood - second)) +} + +fn count_bound(decisions: &[BleAssociationDecision]) -> usize { + decisions + .iter() + .filter(|decision| matches!(decision, BleAssociationDecision::Bound { .. })) + .count() +} + +fn bound_to(decisions: &[BleAssociationDecision], pseudonym: [u8; 8], track: &TrackId) -> bool { + decisions.iter().any(|decision| { + matches!( + decision, + BleAssociationDecision::Bound { pseudonym: value, track: value_track, .. } + if *value == pseudonym && value_track == track + ) + }) +} + +fn abstain(reason: RespirationAbstainReason) -> RespirationDecision { + RespirationDecision::Abstain { reason } +} + +fn ble_replay_fingerprint( + gateway_boot_nonce: u64, + node_id: u8, + key_id: u8, + token_epoch_min: u32, + pseudonym: [u8; 8], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(BLE_REPLAY_FINGERPRINT_DOMAIN); + digest.update(gateway_boot_nonce.to_le_bytes()); + digest.update([node_id, key_id]); + digest.update(token_epoch_min.to_le_bytes()); + digest.update(pseudonym); + digest.finalize().into() +} + +fn check_strict_sequence( + sequences: &BTreeMap, + source: K, + candidate: u32, + capacity: usize, +) -> Result<(), RadioIngressError> { + if let Some(previous) = sequences.get(&source) { + return if candidate > *previous { + Ok(()) + } else { + Err(RadioIngressError::Replay) + }; + } + if sequences.len() >= capacity { + return Err(RadioIngressError::Capacity); + } + Ok(()) +} + +fn check_wrapping_sequence( + sequences: &BTreeMap, + source: K, + candidate: u32, + capacity: usize, +) -> Result<(), RadioIngressError> { + if let Some(previous) = sequences.get(&source) { + return if wrapping_sequence_is_newer(candidate, *previous) { + Ok(()) + } else { + Err(RadioIngressError::Replay) + }; + } + if sequences.len() >= capacity { + return Err(RadioIngressError::Capacity); + } + Ok(()) +} + +fn record_sequence(sequences: &mut BTreeMap, source: K, candidate: u32) { + sequences.insert(source, candidate); +} + +fn wrapping_sequence_is_newer(candidate: u32, previous: u32) -> bool { + let delta = candidate.wrapping_sub(previous); + delta != 0 && delta < 0x8000_0000 +} + +fn le_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) +} + +fn le_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +fn le_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]) +} + +fn le_i32(bytes: &[u8], offset: usize) -> i32 { + i32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +fn put_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); +} + +fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn put_u64(bytes: &mut [u8], offset: usize, value: u64) { + bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn put_i32(bytes: &mut [u8], offset: usize, value: i32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for &byte in bytes { + crc ^= u32::from(byte); + for _ in 0..8 { + let mask = 0u32.wrapping_sub(crc & 1); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + const GATEWAY_SECRET: [u8; 32] = [0x24; 32]; + const COMPANION_SECRET: [u8; 32] = [0x42; 32]; + const BOOT_NONCE: u64 = 0x0102_0304_0506_0708; + + fn gateway_config() -> GatewayIngressConfig { + GatewayIngressConfig { + node_id: 1, + key_id: 4, + ..Default::default() + } + } + + fn companion_config() -> ChannelSoundingIngressConfig { + ChannelSoundingIngressConfig { + key_id: 9, + source_id: 101, + ..Default::default() + } + } + + fn ble_payload(epoch: u32, confidence_permille: u16) -> [u8; BLE_IDENTITY_PACKET_SIZE] { + let mut packet = [0u8; BLE_IDENTITY_PACKET_SIZE]; + put_u32(&mut packet, 0, BLE_IDENTITY_MAGIC); + packet[4] = BLE_IDENTITY_VERSION; + packet[5] = 1; + packet[6] = BLE_AUTHENTICATED | BLE_TIME_VERIFIED; + packet[7] = 7; + put_u32(&mut packet, 8, 1); + put_u32(&mut packet, 12, 500); + put_u16(&mut packet, 16, 3_000); + put_u16(&mut packet, 18, confidence_permille); + packet[20] = (-60i8) as u8; + packet[21] = 127; + packet[24..32].copy_from_slice(&[0xaa; 8]); + put_u32(&mut packet, 32, epoch); + packet + } + + fn resign_cs(frame: &mut [u8; CHANNEL_SOUNDING_FRAME_SIZE]) { + let mut mac = HmacSha256::new_from_slice(&COMPANION_SECRET).unwrap(); + mac.update(CS_MAC_DOMAIN); + mac.update(&frame[..CS_SIGNED_PREFIX]); + let digest = mac.finalize().into_bytes(); + frame[CS_TAG_OFFSET..CS_CRC_OFFSET].copy_from_slice(&digest[..CS_TAG_SIZE]); + let checksum = crc32(&frame[..CS_CRC_OFFSET]); + put_u32(frame, CS_CRC_OFFSET, checksum); + } + + fn cs_gateway_frame( + gateway_sequence: u32, + companion_sequence: u32, + gateway_received_at_boot_us: u64, + ) -> Vec { + let payload = synthetic_cs_frame(companion_sequence, 100, false, &COMPANION_SECRET); + synthetic_gateway_frame( + GATEWAY_PAYLOAD_CHANNEL_SOUNDING, + gateway_sequence, + BOOT_NONCE, + gateway_received_at_boot_us, + 8_000, + &payload, + &GATEWAY_SECRET, + ) + } + + fn gateway_metadata(sequence: u32) -> GatewayEnvelopeMetadata { + GatewayEnvelopeMetadata { + payload_type: GatewayPayloadType::ChannelSounding, + key_id: 4, + node_id: 1, + sequence, + boot_nonce: BOOT_NONCE, + received_at_boot_us: 2_000_000 + u64::from(sequence), + timing_uncertainty_us: 8_000, + } + } + + fn cs_measurement( + procedure_id: u32, + step_index: u16, + step_count: u16, + capture_at_gateway_boot_us: u64, + phase_millirad: i32, + ) -> ChannelSoundingMeasurement { + let sequence = (procedure_id - 1) + .saturating_mul(u32::from(step_count)) + .saturating_add(u32::from(step_index)) + .saturating_add(1); + ChannelSoundingMeasurement { + gateway_node_id: 1, + gateway_key_id: 4, + gateway_sequence: sequence, + gateway_boot_nonce: BOOT_NONCE, + key_id: 9, + sequence, + source_id: 101, + source_session_id: 77, + procedure_id, + channel_index: step_index, + step_index, + step_count, + sample_age_us: 1_000, + gateway_received_at_boot_us: capture_at_gateway_boot_us + 1_000, + capture_at_gateway_boot_us, + host_received_at_unix_us: 1_800_000_000_000_000 + + i64::try_from(capture_at_gateway_boot_us).unwrap(), + gateway_timing_uncertainty_us: 8_000, + timing_uncertainty_us: 100, + phase_millirad, + rtt_picoseconds: 50_000, + frequency_offset_hz: 0, + quality_permille: 900, + calibrated: true, + motion: false, + } + } + + #[test] + fn synthetic_replay_covers_crossing_spoof_expiry_motion_and_fusion() { + let report = run_synthetic_ble_cs_replay(); + assert_eq!(report.evidence, SYNTHETIC_LABEL); + assert!(report.bound_before_crossing); + assert!(report.abstained_at_crossing); + assert!(report.stable_after_crossing); + assert!(report.spoof_abstained); + assert!(report.expiry_abstained); + assert!(report.motion_abstained); + let bpm = report + .fused_respiration_bpm + .expect("synthetic fusion estimate"); + assert!((bpm - 15.0).abs() < 0.3, "unexpected synthetic bpm {bpm}"); + } + + #[test] + fn gateway_envelope_validates_layout_hmac_enrollment_and_boot_replay() { + let epoch = 30_000_000u32; + let payload = ble_payload(epoch, 900); + let frame = synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + 1, + BOOT_NONCE, + 2_000_000, + 1_000, + &payload, + &GATEWAY_SECRET, + ); + assert_eq!(frame.len(), 40 + 36 + 16); + let envelope = + AuthenticatedGatewayEnvelope::parse(&frame, &GATEWAY_SECRET, gateway_config()).unwrap(); + assert_eq!( + envelope.metadata.payload_type, + GatewayPayloadType::BleIdentity + ); + assert_eq!(envelope.metadata.sequence, 1); + assert_eq!(envelope.metadata.boot_nonce, BOOT_NONCE); + assert_eq!(envelope.metadata.received_at_boot_us, 2_000_000); + assert_eq!(envelope.payload(), payload); + + let mut wrong_node = gateway_config(); + wrong_node.node_id = 2; + assert_eq!( + AuthenticatedGatewayEnvelope::parse(&frame, &GATEWAY_SECRET, wrong_node), + Err(RadioIngressError::NodeId) + ); + assert_eq!( + AuthenticatedGatewayEnvelope::parse(&frame, &[1; 32], gateway_config()), + Err(RadioIngressError::Authentication) + ); + let mut tampered = frame.clone(); + tampered[GATEWAY_ENVELOPE_HEADER_SIZE + 20] ^= 1; + assert_eq!( + AuthenticatedGatewayEnvelope::parse(&tampered, &GATEWAY_SECRET, gateway_config()), + Err(RadioIngressError::Authentication) + ); + + let mut replay = RadioReplayGuard::new(2, 16, 1); + let gateway_source = ( + envelope.metadata.node_id, + envelope.metadata.key_id, + envelope.metadata.boot_nonce, + ); + check_strict_sequence( + &replay.gateway_sequences, + gateway_source, + envelope.metadata.sequence, + replay.max_gateway_sessions, + ) + .unwrap(); + record_sequence( + &mut replay.gateway_sequences, + gateway_source, + envelope.metadata.sequence, + ); + assert_eq!( + check_strict_sequence( + &replay.gateway_sequences, + gateway_source, + envelope.metadata.sequence, + replay.max_gateway_sessions, + ), + Err(RadioIngressError::Replay) + ); + let second = synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + 2, + BOOT_NONCE, + 2_100_000, + 1_000, + &payload, + &GATEWAY_SECRET, + ); + let second = + AuthenticatedGatewayEnvelope::parse(&second, &GATEWAY_SECRET, gateway_config()) + .unwrap(); + check_strict_sequence( + &replay.gateway_sequences, + gateway_source, + second.metadata.sequence, + replay.max_gateway_sessions, + ) + .unwrap(); + record_sequence( + &mut replay.gateway_sequences, + gateway_source, + second.metadata.sequence, + ); + let next_boot = synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + 1, + BOOT_NONCE + 1, + 100, + 1_000, + &payload, + &GATEWAY_SECRET, + ); + let next_boot = + AuthenticatedGatewayEnvelope::parse(&next_boot, &GATEWAY_SECRET, gateway_config()) + .unwrap(); + let next_boot_source = ( + next_boot.metadata.node_id, + next_boot.metadata.key_id, + next_boot.metadata.boot_nonce, + ); + check_strict_sequence( + &replay.gateway_sequences, + next_boot_source, + next_boot.metadata.sequence, + replay.max_gateway_sessions, + ) + .unwrap(); + record_sequence( + &mut replay.gateway_sequences, + next_boot_source, + next_boot.metadata.sequence, + ); + let over_capacity = GatewayEnvelopeMetadata { + boot_nonce: BOOT_NONCE + 2, + ..next_boot.metadata + }; + assert_eq!( + check_strict_sequence( + &replay.gateway_sequences, + ( + over_capacity.node_id, + over_capacity.key_id, + over_capacity.boot_nonce, + ), + over_capacity.sequence, + replay.max_gateway_sessions, + ), + Err(RadioIngressError::Capacity) + ); + + let mut no_wrap = BTreeMap::new(); + no_wrap.insert(gateway_source, u32::MAX); + assert_eq!( + check_strict_sequence(&no_wrap, gateway_source, 1, 1), + Err(RadioIngressError::Replay) + ); + } + + #[test] + fn channel_sounding_validates_72_byte_contract_clocks_and_authentication() { + let frame = cs_gateway_frame(1, 1, 2_000_000); + assert_eq!(frame.len(), 40 + 72 + 16); + let mut replay = RadioReplayGuard::default(); + let measurement = ChannelSoundingMeasurement::parse_gateway_authenticated( + &frame, + 1_800_000_000_000_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut replay, + ) + .unwrap(); + assert_eq!(measurement.source_session_id, 77); + assert_eq!(measurement.procedure_id, 1); + assert_eq!(measurement.step_index, 0); + assert_eq!(measurement.step_count, 20); + assert_eq!(measurement.gateway_received_at_boot_us, 2_000_000); + assert_eq!(measurement.capture_at_gateway_boot_us, 1_999_000); + assert_eq!(measurement.host_received_at_unix_us, 1_800_000_000_000_000); + assert_eq!( + ChannelSoundingMeasurement::parse_gateway_authenticated( + &frame, + 1_800_000_000_000_001, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut replay, + ), + Err(RadioIngressError::Replay) + ); + + let mut payload = synthetic_cs_frame(2, 100, false, &COMPANION_SECRET); + payload[28] ^= 1; + let checksum = crc32(&payload[..CS_CRC_OFFSET]); + put_u32(&mut payload, CS_CRC_OFFSET, checksum); + let tampered = synthetic_gateway_frame( + GATEWAY_PAYLOAD_CHANNEL_SOUNDING, + 2, + BOOT_NONCE, + 2_050_000, + 8_000, + &payload, + &GATEWAY_SECRET, + ); + assert_eq!( + ChannelSoundingMeasurement::parse_gateway_authenticated( + &tampered, + 1_800_000_000_050_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut RadioReplayGuard::default(), + ), + Err(RadioIngressError::Authentication) + ); + + let mut stale_payload = synthetic_cs_frame(2, 100, false, &COMPANION_SECRET); + put_u32(&mut stale_payload, 16, 3_000_000); + resign_cs(&mut stale_payload); + let stale = synthetic_gateway_frame( + GATEWAY_PAYLOAD_CHANNEL_SOUNDING, + 2, + BOOT_NONCE, + 4_000_000, + 8_000, + &stale_payload, + &GATEWAY_SECRET, + ); + assert_eq!( + ChannelSoundingMeasurement::parse_gateway_authenticated( + &stale, + 1_800_000_003_000_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut RadioReplayGuard::default(), + ), + Err(RadioIngressError::Stale) + ); + } + + #[test] + fn channel_sounding_rejects_bad_procedure_and_uses_bounded_session_replay() { + let mut bad_step = synthetic_cs_frame(1, 100, false, &COMPANION_SECRET); + put_u16(&mut bad_step, 48, 20); + resign_cs(&mut bad_step); + let bad_step = synthetic_gateway_frame( + GATEWAY_PAYLOAD_CHANNEL_SOUNDING, + 1, + BOOT_NONCE, + 2_000_000, + 8_000, + &bad_step, + &GATEWAY_SECRET, + ); + assert_eq!( + ChannelSoundingMeasurement::parse_gateway_authenticated( + &bad_step, + 1_800_000_000_000_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut RadioReplayGuard::default(), + ), + Err(RadioIngressError::Bounds) + ); + + let mut replay = RadioReplayGuard::new(1, 16, 1); + let first = cs_gateway_frame(1, 1, 2_000_000); + ChannelSoundingMeasurement::parse_gateway_authenticated( + &first, + 1_800_000_000_000_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut replay, + ) + .unwrap(); + let mut new_session_payload = synthetic_cs_frame(1, 100, false, &COMPANION_SECRET); + put_u32(&mut new_session_payload, 40, 78); + resign_cs(&mut new_session_payload); + let new_session = synthetic_gateway_frame( + GATEWAY_PAYLOAD_CHANNEL_SOUNDING, + 2, + BOOT_NONCE, + 2_050_000, + 8_000, + &new_session_payload, + &GATEWAY_SECRET, + ); + assert_eq!( + ChannelSoundingMeasurement::parse_gateway_authenticated( + &new_session, + 1_800_000_000_050_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut replay, + ), + Err(RadioIngressError::Capacity) + ); + let next = cs_gateway_frame(2, 2, 2_050_000); + assert!(ChannelSoundingMeasurement::parse_gateway_authenticated( + &next, + 1_800_000_000_050_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut replay, + ) + .is_ok()); + } + + #[test] + fn gateway_sequence_is_strict_while_companion_sequence_may_wrap() { + let mut replay = RadioReplayGuard::new(1, 1, 1); + let mut measurement = cs_measurement(1, 0, 4, 1_000_000, 100); + measurement.sequence = u32::MAX; + replay + .admit_channel_sounding(&gateway_metadata(1), &measurement) + .unwrap(); + + measurement.sequence = 0; + replay + .admit_channel_sounding(&gateway_metadata(2), &measurement) + .unwrap(); + + measurement.sequence = 100; + assert_eq!( + replay.admit_channel_sounding(&gateway_metadata(1), &measurement), + Err(RadioIngressError::Replay) + ); + measurement.sequence = 1; + assert!(replay + .admit_channel_sounding(&gateway_metadata(3), &measurement) + .is_ok()); + } + + #[test] + fn respiration_admits_only_complete_unique_coherent_procedures() { + let mut complete = Vec::new(); + for procedure_id in 1..=2 { + for step_index in 0..4 { + complete.push(cs_measurement( + procedure_id, + step_index, + 4, + u64::from(procedure_id) * 1_000_000 + u64::from(step_index) * 1_000, + i32::from(step_index) * 10, + )); + } + } + assert_eq!( + complete_coherent_procedure_samples(&complete) + .unwrap() + .len(), + 8 + ); + + let mut incomplete = complete.clone(); + incomplete.pop(); + assert_eq!( + complete_coherent_procedure_samples(&incomplete) + .unwrap() + .len(), + 4 + ); + assert!(matches!( + estimate_channel_sounding_respiration( + &incomplete[4..], + RespirationEstimatorConfig { + min_samples: 1, + min_duration_us: 1, + min_peak_ratio: 2.0, + evidence: RadioEvidenceLabel::Synthetic, + } + ), + RespirationDecision::Abstain { + reason: RespirationAbstainReason::InsufficientCoverage + } + )); + + let mut duplicate_step = complete.clone(); + duplicate_step[3].step_index = duplicate_step[2].step_index; + assert_eq!( + complete_coherent_procedure_samples(&duplicate_step), + Err(RespirationAbstainReason::IncoherentSource) + ); + let mut duplicate_channel = complete.clone(); + duplicate_channel[3].channel_index = duplicate_channel[2].channel_index; + assert_eq!( + complete_coherent_procedure_samples(&duplicate_channel), + Err(RespirationAbstainReason::IncoherentSource) + ); + let too_few_channels: Vec<_> = (0..3) + .map(|step| cs_measurement(1, step, 3, 1_000_000 + u64::from(step), 100)) + .collect(); + assert_eq!( + complete_coherent_procedure_samples(&too_few_channels), + Err(RespirationAbstainReason::IncoherentSource) + ); + let too_many_channels = vec![cs_measurement(1, 0, 81, 1_000_000, 100)]; + assert_eq!( + complete_coherent_procedure_samples(&too_many_channels), + Err(RespirationAbstainReason::IncoherentSource) + ); + let mut changed_plan = complete; + changed_plan[7].channel_index = 10; + assert_eq!( + complete_coherent_procedure_samples(&changed_plan), + Err(RespirationAbstainReason::IncoherentSource) + ); + } + + #[test] + fn ble_packet_requires_authenticated_gateway_expires_and_rejects_bad_epoch() { + let epoch = 30_000_000u32; + let now = i64::from(epoch) * 60_000; + let payload = ble_payload(epoch, 900); + let frame = synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + 1, + BOOT_NONCE, + 500_999, + 1_000, + &payload, + &GATEWAY_SECRET, + ); + let mut replay = RadioReplayGuard::default(); + let evidence = BleIdentityEvidence::parse_gateway_authenticated( + &frame, + now, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ) + .unwrap(); + assert_eq!(evidence.gateway_boot_nonce, BOOT_NONCE); + assert_eq!(evidence.gateway_received_at_boot_us, 500_999); + assert!(evidence.is_live(now + 2_999)); + assert!(!evidence.is_live(now + 3_000)); + assert_eq!( + BleIdentityEvidence::parse_gateway_authenticated( + &frame, + now, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ), + Err(RadioIngressError::Replay) + ); + + let stale_payload = ble_payload(epoch - 10, 900); + let stale = synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + 2, + BOOT_NONCE, + 600_000, + 1_000, + &stale_payload, + &GATEWAY_SECRET, + ); + assert_eq!( + BleIdentityEvidence::parse_gateway_authenticated( + &stale, + now, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ), + Err(RadioIngressError::Epoch) + ); + } + + #[test] + fn ble_replay_is_atomic_strict_and_scoped_to_gateway_boot() { + let epoch = 30_000_000u32; + let now = i64::from(epoch) * 60_000; + let make_frame = |gateway_sequence, boot_nonce, ble_sequence| { + let mut payload = ble_payload(epoch, 900); + put_u32(&mut payload, 8, ble_sequence); + synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + gateway_sequence, + boot_nonce, + 500_000 + u64::from(gateway_sequence), + 1_000, + &payload, + &GATEWAY_SECRET, + ) + }; + let mut replay = RadioReplayGuard::new(2, 4, 1); + BleIdentityEvidence::parse_gateway_authenticated( + &make_frame(1, BOOT_NONCE, 10), + now, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ) + .unwrap(); + + assert_eq!( + BleIdentityEvidence::parse_gateway_authenticated( + &make_frame(2, BOOT_NONCE, 9), + now + 1, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ), + Err(RadioIngressError::Replay) + ); + assert!(BleIdentityEvidence::parse_gateway_authenticated( + &make_frame(2, BOOT_NONCE, 11), + now + 1, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ) + .is_ok()); + + assert!(BleIdentityEvidence::parse_gateway_authenticated( + &make_frame(1, BOOT_NONCE + 1, 1), + now + 2, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ) + .is_ok()); + assert_eq!(replay.ble_sequences.len(), 2); + } + + #[test] + fn replay_snapshot_round_trips_all_high_water_state_and_rejects_malformed() { + let epoch = 30_000_000u32; + let now = i64::from(epoch) * 60_000; + let payload = ble_payload(epoch, 900); + let ble_frame = synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + 1, + BOOT_NONCE, + 500_000, + 1_000, + &payload, + &GATEWAY_SECRET, + ); + let mut replay = RadioReplayGuard::new(2, 4, 2); + BleIdentityEvidence::parse_gateway_authenticated( + &ble_frame, + now, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig::default(), + &mut replay, + ) + .unwrap(); + ChannelSoundingMeasurement::parse_gateway_authenticated( + &cs_gateway_frame(2, 1, 550_000), + now * 1_000 + 50_000, + &GATEWAY_SECRET, + gateway_config(), + &COMPANION_SECRET, + companion_config(), + &mut replay, + ) + .unwrap(); + + let snapshot = replay.snapshot().unwrap(); + assert_eq!(snapshot.version(), RADIO_REPLAY_SNAPSHOT_VERSION); + assert_eq!(snapshot.capacities(), (2, 4, 2)); + let json = serde_json::to_string(&snapshot).unwrap(); + assert!(!json.contains("secret")); + let decoded: RadioReplaySnapshot = serde_json::from_str(&json).unwrap(); + decoded.validate().unwrap(); + let restored = RadioReplayGuard::from_snapshot(decoded.clone(), now).unwrap(); + assert_eq!(restored, replay); + + let after_ble_horizon = RadioReplayGuard::from_snapshot( + decoded.clone(), + now + i64::from(MAX_BLE_TOKEN_SKEW_MIN + 1) * 60_000, + ) + .unwrap(); + assert!(after_ble_horizon.ble_sequences.is_empty()); + assert_eq!(after_ble_horizon.gateway_sequences.len(), 1); + assert_eq!(after_ble_horizon.channel_sounding_sequences.len(), 1); + + let mut bad_version = decoded.clone(); + bad_version.version += 1; + assert_eq!( + bad_version.validate(), + Err(RadioIngressError::InvalidSnapshot) + ); + let mut duplicate = decoded.clone(); + duplicate.gateway.push(duplicate.gateway[0].clone()); + assert_eq!( + duplicate.validate(), + Err(RadioIngressError::InvalidSnapshot) + ); + let mut orphan_ble = decoded.clone(); + orphan_ble.ble[0].gateway_boot_nonce += 1; + assert_eq!( + orphan_ble.validate(), + Err(RadioIngressError::InvalidSnapshot) + ); + let mut over_capacity = decoded.clone(); + over_capacity.max_gateway_sessions = 1; + let mut extra_gateway = over_capacity.gateway[0].clone(); + extra_gateway.boot_nonce += 1; + extra_gateway.sequence = 1; + over_capacity.gateway.push(extra_gateway); + assert_eq!( + over_capacity.validate(), + Err(RadioIngressError::InvalidSnapshot) + ); + let mut zero_capacity = decoded; + zero_capacity.max_ble_records = 0; + let before_failed_restore = replay.clone(); + assert_eq!( + replay.restore(zero_capacity, now), + Err(RadioIngressError::InvalidSnapshot) + ); + assert_eq!(replay, before_failed_restore); + } + + #[test] + fn association_scopes_identity_and_rejects_low_quality_or_invalid_policy() { + let now = 1_800_000_000_000i64; + let track_a = TrackId::new("track-a").unwrap(); + let track_b = TrackId::new("track-b").unwrap(); + let pseudonym = [0xaa; 8]; + let candidate = |track: &TrackId| { + vec![TrackLikelihood { + track: track.clone(), + likelihood: 0.95, + }] + }; + let mut first = synthetic_ble_evidence(1, pseudonym, 30_000_000, now); + let mut second = synthetic_ble_evidence(2, pseudonym, 30_000_000, now); + second.key_id = 8; + let scoped = associate_ble_to_tracks( + &[ + BleAssociationInput { + evidence: first.clone(), + candidates: candidate(&track_a), + }, + BleAssociationInput { + evidence: second, + candidates: candidate(&track_b), + }, + ], + now, + 0.6, + 0.15, + ); + assert_eq!(count_bound(&scoped), 2); + + first.confidence_permille = BLE_ASSOCIATION_MIN_CONFIDENCE_PERMILLE - 1; + let low_quality = associate_ble_to_tracks( + &[BleAssociationInput { + evidence: first.clone(), + candidates: candidate(&track_a), + }], + now, + 0.6, + 0.15, + ); + assert!(matches!( + low_quality.as_slice(), + [BleAssociationDecision::Abstain { + reason: BleAssociationAbstainReason::LowQuality, + .. + }] + )); + let invalid = associate_ble_to_tracks( + &[BleAssociationInput { + evidence: first, + candidates: candidate(&track_a), + }], + now, + f64::NAN, + 0.15, + ); + assert!(matches!( + invalid.as_slice(), + [BleAssociationDecision::Abstain { + reason: BleAssociationAbstainReason::InvalidPolicy, + .. + }] + )); + } + + #[test] + fn public_policies_fail_closed_on_zero_nan_and_expired_motion() { + assert!(matches!( + estimate_channel_sounding_respiration( + &[], + RespirationEstimatorConfig { + min_samples: 0, + ..Default::default() + } + ), + RespirationDecision::Abstain { + reason: RespirationAbstainReason::InvalidPolicy + } + )); + assert!(matches!( + estimate_channel_sounding_respiration( + &[], + RespirationEstimatorConfig { + min_peak_ratio: f64::NAN, + ..Default::default() + } + ), + RespirationDecision::Abstain { + reason: RespirationAbstainReason::InvalidPolicy + } + )); + let expired_motion = RespirationEvidence { + bpm: 15.0, + variance: 1.0, + expires_at_ms: 1_000, + motion: true, + evidence: RadioEvidenceLabel::Synthetic, + }; + assert!(matches!( + fuse_respiration(&[expired_motion], 1_000, 3.0), + RespirationDecision::Abstain { + reason: RespirationAbstainReason::Expired + } + )); + assert!(matches!( + fuse_respiration(&[], 1_000, f64::NAN), + RespirationDecision::Abstain { + reason: RespirationAbstainReason::InvalidPolicy + } + )); + + let epoch = 30_000_000u32; + let payload = ble_payload(epoch, 900); + let frame = synthetic_gateway_frame( + GATEWAY_PAYLOAD_BLE_IDENTITY, + 1, + BOOT_NONCE, + 500_000, + 1_000, + &payload, + &GATEWAY_SECRET, + ); + assert_eq!( + BleIdentityEvidence::parse_gateway_authenticated( + &frame, + i64::from(epoch) * 60_000, + &GATEWAY_SECRET, + gateway_config(), + BleIngressConfig { + max_ttl_ms: 0, + ..Default::default() + }, + &mut RadioReplayGuard::default(), + ), + Err(RadioIngressError::InvalidPolicy) + ); + + let mut invalid_gateway = gateway_config(); + invalid_gateway.max_timing_uncertainty_us = 0; + assert_eq!( + AuthenticatedGatewayEnvelope::parse(&frame, &GATEWAY_SECRET, invalid_gateway), + Err(RadioIngressError::InvalidPolicy) + ); + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs index fffe3a22..5a78cffa 100644 --- a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs +++ b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs @@ -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(), diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index bca23585..343c9b42 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -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, diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 5099c4be..f8d8c827 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -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 diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 36958830..d029d903 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -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` diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 0b35a5c6..a57ddd73 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -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, + + /// Primary gateway HMAC key selector. + #[arg(long, env = "RUVIEW_RADIO_GATEWAY_KEY_ID")] + radio_gateway_key_id: Option, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// Nonzero opaque identifier for the Channel Sounding companion. + #[arg(long, env = "RUVIEW_RADIO_CS_SOURCE_ID")] + radio_cs_source_id: Option, + + /// 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, + + /// 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, + /// 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, + radio_sender: Option< + std::sync::mpsc::SyncSender, + >, ) { 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::(&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 { + let mut fields = value.splitn(3, ','); + let node_id = fields + .next() + .ok_or_else(|| "radio gateway requires NODE,KEY,SECRET_PATH".to_string())? + .parse::() + .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::() + .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, 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::() + .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::(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)); } diff --git a/v2/crates/wifi-densepose-sensing-server/src/radio_ingress.rs b/v2/crates/wifi-densepose-sensing-server/src/radio_ingress.rs new file mode 100644 index 00000000..28cd9686 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/radio_ingress.rs @@ -0,0 +1,1724 @@ +//! Live ADR-341 radio ingress for the sensing server. +//! +//! The boundary accepts only an HMAC-authenticated RVAE envelope. BLE records +//! expose a short-lived rotating pseudonym and RSSI. Channel Sounding records +//! remain edge-local: only a bounded aggregate decision and opaque provenance +//! leave this module. Replay high-water marks are committed atomically before +//! any update is returned to a caller for publication. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use fs2::FileExt; +use hmac::{Hmac, Mac}; +use ruview_fusion::radio_fusion::{ + estimate_channel_sounding_respiration, AuthenticatedGatewayEnvelope, BleIdentityEvidence, + BleIngressConfig, ChannelSoundingIngressConfig, ChannelSoundingMeasurement, + GatewayIngressConfig, GatewayPayloadType, RadioEvidenceLabel, RadioIngressError, + RadioReplayGuard, RadioReplaySnapshot, RespirationDecision, RespirationEstimatorConfig, + BLE_IDENTITY_PACKET_SIZE, CHANNEL_SOUNDING_FRAME_SIZE, GATEWAY_ENVELOPE_HEADER_SIZE, + GATEWAY_ENVELOPE_TAG_SIZE, +}; +use serde::Serialize; +use sha2::Sha256; +use thiserror::Error; + +type HmacSha256 = Hmac; +const SECRET_SIZE: usize = 32; +const MAX_REPLAY_FILE_SIZE: u64 = 4 * 1024 * 1024; +const MAX_CS_WINDOW_SAMPLES: usize = 4_096; +const CS_WINDOW_US: u64 = 30_000_000; +const MAX_FINALIZED_CS_PROCEDURES: usize = 1_024; +const MAX_CS_SCOPES: usize = 64; +const RADIO_EVIDENCE_TTL_US: i64 = 5_000_000; +const RADIO_WORKER_QUEUE_CAPACITY: usize = 256; +const RADIO_COMMIT_BATCH_LIMIT: usize = 256; +const RADIO_COMMIT_INTERVAL: Duration = Duration::from_secs(1); + +/// Return true only for the two exact RVAE datagram sizes implemented by +/// ADR-341. The shared UDP loop calls this before copying attacker-controlled +/// input into the bounded worker queue. +#[must_use] +pub const fn is_supported_rvae_datagram_len(len: usize) -> bool { + len == GATEWAY_ENVELOPE_HEADER_SIZE + BLE_IDENTITY_PACKET_SIZE + GATEWAY_ENVELOPE_TAG_SIZE + || len + == GATEWAY_ENVELOPE_HEADER_SIZE + + CHANNEL_SOUNDING_FRAME_SIZE + + GATEWAY_ENVELOPE_TAG_SIZE +} + +/// One independently enrolled ESP32 gateway. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GatewayRuntimeOptions { + pub node_id: u8, + pub key_id: u8, + pub secret_file: PathBuf, +} + +/// Optional companion-radio configuration. ESP32-S3 cannot produce these +/// primitives itself; this identifies a separately enrolled capable radio. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChannelSoundingRuntimeOptions { + /// Provisioned companion HMAC key selector. + pub key_id: u8, + /// Opaque, nonzero companion source identifier. + pub source_id: u32, + /// File containing exactly 32 raw secret bytes. + pub secret_file: PathBuf, +} + +/// Configuration required to enable authenticated radio ingress. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadioIngressOptions { + /// One or more independently enrolled ESP32-S3 gateways. + pub gateways: Vec, + /// Deployment-scoped key used only to derive the canonical `blep:` host + /// token. It must be distinct from all transport and companion keys. + pub host_pseudonym_secret_file: PathBuf, + /// Durable replay state. Deleting it is an explicit replay reset and must + /// be paired with key rotation or retirement of every old radio session. + pub replay_state_file: PathBuf, + /// One-shot creation of a new empty replay snapshot. The runtime rejects + /// this flag after creation. State loss requires rotation of every key. + pub initialize_replay_state: bool, + /// Optional separately enrolled Bluetooth Channel Sounding companion. + pub channel_sounding: Option, +} + +/// A privacy-minimized update safe for the normal sensing broadcast. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "modality", rename_all = "snake_case")] +pub enum RadioIngressUpdate { + /// Authenticated rotating BLE anchor. It is proximity evidence, not a + /// civil identity and not proof that a device is physically on a person. + BleIdentity { + /// This live path is hardware-unvalidated, so it cannot mint MEASURED. + evidence: RadioEvidenceLabel, + gateway_node_id: u8, + pseudonymous_token: String, + rssi_dbm: i8, + confidence_permille: u16, + expires_at_unix_ms: i64, + scanner_time_verified: bool, + gateway_timing_uncertainty_us: u32, + }, + /// Aggregate from complete, coherent Channel Sounding procedures. Exact + /// phase, RTT, frequency-offset, and per-step samples remain edge-local. + ChannelSoundingRespiration { + evidence: RadioEvidenceLabel, + gateway_node_id: u8, + source_id: u32, + source_session_id: u32, + trigger_procedure_id: u32, + complete_steps: u16, + unique_channels: u16, + window_procedure_ids: Vec, + window_sample_count: u16, + window_start_gateway_boot_us: u64, + window_end_gateway_boot_us: u64, + publish_gateway_sequence: u32, + valid_from_unix_us: i64, + expires_at_unix_us: i64, + respiration: RespirationDecision, + }, +} + +/// Privacy classes used by the explicit sensing WebSocket export gate. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub enum RadioPrivacyClass { + /// Aggregated biological signal evidence. + P4, + /// Pseudonymous identity and association evidence. + P5, +} + +/// Explicit deployment export override for the normal sensing WebSocket. This +/// is not a subject consent receipt. The binary restricts it to an audited, +/// loopback-only deployment. Ingestion and edge-local fusion continue when +/// both fields are false. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RadioExportPolicy { + pub allow_biological_p4: bool, + pub allow_identity_p5: bool, +} + +impl RadioExportPolicy { + #[must_use] + pub fn allows(self, update: &RadioIngressUpdate) -> bool { + match update.privacy_class() { + RadioPrivacyClass::P4 => self.allow_biological_p4, + RadioPrivacyClass::P5 => self.allow_identity_p5, + } + } + + #[must_use] + pub fn any(self) -> bool { + self.allow_biological_p4 || self.allow_identity_p5 + } +} + +impl RadioIngressUpdate { + #[must_use] + pub fn privacy_class(&self) -> RadioPrivacyClass { + match self { + Self::BleIdentity { .. } => RadioPrivacyClass::P5, + Self::ChannelSoundingRespiration { .. } => RadioPrivacyClass::P4, + } + } +} + +/// Fail-closed configuration, authentication, replay, and storage failures. +#[derive(Debug, Error)] +pub enum RadioRuntimeError { + #[error("radio secret file must be a regular non-symlink file")] + SecretFileType, + #[error("radio secret file permissions permit group or other access")] + SecretPermissions, + #[error("radio secret must contain exactly 32 raw bytes")] + SecretLength, + #[error("radio secret must not be all zero")] + ZeroSecret, + #[error("gateway, host pseudonym, and companion secrets must all be distinct")] + ReusedSecret, + #[error("at least one nonzero gateway enrollment is required")] + MissingGateway, + #[error("gateway enrollment node/key pairs must be unique")] + DuplicateGateway, + #[error("RVAE node/key selector is not enrolled")] + UnknownGateway, + #[error("replay state file must be a regular non-symlink file")] + ReplayFileType, + #[error("replay state file permissions permit group or other access")] + ReplayPermissions, + #[error("replay state exceeds the bounded file size")] + ReplayFileSize, + #[error( + "replay state is missing; explicitly initialize it and rotate keys after any state loss" + )] + ReplayInitializationRequired, + #[error("replay state already exists; remove the one-shot initialization option")] + ReplayAlreadyInitialized, + #[error("replay state is already locked by another sensing process")] + ReplayLocked, + #[error("an export override requires a private audit log")] + ExportAuditRequired, + #[error("radio export audit file must be a regular non-symlink file")] + AuditFileType, + #[error("radio export audit file permissions permit group or other access")] + AuditPermissions, + #[error("radio export audit file is already locked by another process")] + AuditLocked, + #[error("radio export audit file aliases replay state, a lock, or an enrolled secret")] + AuditAliasesProtectedFile, + #[error("radio export audit log has an incomplete or malformed tail")] + AuditMalformed, + #[error("invalid replay state: {0}")] + ReplayState(String), + #[error("Channel Sounding payload received without an enrolled companion")] + ChannelSoundingDisabled, + #[error("radio ingress rejected the frame: {0}")] + Ingress(#[from] RadioIngressError), + #[error("radio ingress storage failure: {0}")] + Storage(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct CsScope { + gateway_node_id: u8, + gateway_key_id: u8, + gateway_boot_nonce: u64, + source_id: u32, + source_session_id: u32, +} + +#[derive(Clone, Debug, Default)] +struct CsState { + samples: VecDeque, + finalized_procedures: VecDeque<(u64, u32, u32)>, + last_capture_at_gateway_boot_us: u64, +} + +struct SecretBytes([u8; SECRET_SIZE]); + +impl SecretBytes { + fn expose(&self) -> &[u8; SECRET_SIZE] { + &self.0 + } +} + +impl Drop for SecretBytes { + fn drop(&mut self) { + self.0.fill(0); + } +} + +struct EnrolledGateway { + secret: SecretBytes, + config: GatewayIngressConfig, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct FileIdentity { + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, + #[cfg(not(unix))] + canonical_path: PathBuf, +} + +#[derive(Default)] +struct ProtectedFiles { + identities: BTreeSet, + canonical_paths: BTreeSet, +} + +impl ProtectedFiles { + fn insert(&mut self, identity: FileIdentity, path: &Path) -> Result<(), RadioRuntimeError> { + self.identities.insert(identity); + self.canonical_paths.insert( + fs::canonicalize(path) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?, + ); + Ok(()) + } + + fn contains(&self, file: &File, path: &Path) -> Result { + let identity = file_identity(file, path)?; + let canonical = fs::canonicalize(path) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + Ok(self.identities.contains(&identity) || self.canonical_paths.contains(&canonical)) + } +} + +/// Single-owner live runtime. Production callers hand it to the bounded radio +/// worker so HMAC, estimation, and durable group commit never block CSI UDP. +pub struct RadioIngressRuntime { + gateways: Vec, + host_pseudonym_secret: SecretBytes, + companion_secret: Option, + companion_config: Option, + replay_state_file: PathBuf, + _replay_lock: File, + protected_files: ProtectedFiles, + replay: RadioReplayGuard, + cs_states: BTreeMap, +} + +impl RadioIngressRuntime { + /// Load enrolled secrets and durable replay state while holding an + /// exclusive process lock. A missing snapshot is fail closed unless the + /// caller explicitly requests initialization. + pub fn open(options: RadioIngressOptions, now_unix_ms: i64) -> Result { + if now_unix_ms < 0 { + return Err(RadioRuntimeError::ReplayState( + "host wall clock predates the Unix epoch".to_string(), + )); + } + if options.gateways.is_empty() + || options.gateways.iter().any(|gateway| gateway.node_id == 0) + { + return Err(RadioRuntimeError::MissingGateway); + } + + let mut protected_files = ProtectedFiles::default(); + let mut gateway_pairs = BTreeSet::new(); + let mut enrolled_gateways = Vec::with_capacity(options.gateways.len()); + let mut enrolled_secret_values = Vec::with_capacity(options.gateways.len()); + for gateway in options.gateways { + if !gateway_pairs.insert((gateway.node_id, gateway.key_id)) { + return Err(RadioRuntimeError::DuplicateGateway); + } + let (secret, identity) = read_secret(&gateway.secret_file)?; + protected_files.insert(identity, &gateway.secret_file)?; + if enrolled_secret_values.iter().any(|value| value == &secret) { + return Err(RadioRuntimeError::ReusedSecret); + } + enrolled_secret_values.push(secret); + enrolled_gateways.push(EnrolledGateway { + secret: SecretBytes(secret), + config: GatewayIngressConfig { + node_id: gateway.node_id, + key_id: gateway.key_id, + ..GatewayIngressConfig::default() + }, + }); + } + + let (host_pseudonym_secret_value, host_identity) = + read_secret(&options.host_pseudonym_secret_file)?; + protected_files.insert(host_identity, &options.host_pseudonym_secret_file)?; + if enrolled_secret_values + .iter() + .any(|secret| secret == &host_pseudonym_secret_value) + { + return Err(RadioRuntimeError::ReusedSecret); + } + let host_pseudonym_secret = SecretBytes(host_pseudonym_secret_value); + let (companion_secret, companion_config) = match options.channel_sounding { + Some(companion) => { + if companion.source_id == 0 { + return Err(RadioRuntimeError::ReplayState( + "companion source id must be nonzero".to_string(), + )); + } + let (secret, identity) = read_secret(&companion.secret_file)?; + protected_files.insert(identity, &companion.secret_file)?; + if enrolled_secret_values.iter().any(|value| value == &secret) + || secret == *host_pseudonym_secret.expose() + { + return Err(RadioRuntimeError::ReusedSecret); + } + ( + Some(SecretBytes(secret)), + Some(ChannelSoundingIngressConfig { + key_id: companion.key_id, + source_id: companion.source_id, + ..ChannelSoundingIngressConfig::default() + }), + ) + } + None => (None, None), + }; + + let parent = replay_parent(&options.replay_state_file); + if !parent.exists() { + if !options.initialize_replay_state { + return Err(RadioRuntimeError::ReplayInitializationRequired); + } + fs::create_dir_all(parent) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + } + let (replay_lock, replay_lock_identity, replay_lock_path) = + acquire_replay_lock(&options.replay_state_file)?; + protected_files.insert(replay_lock_identity, &replay_lock_path)?; + let (replay, replay_identity, needs_initial_snapshot) = + match read_replay_guard(&options.replay_state_file, now_unix_ms)? { + Some(_) if options.initialize_replay_state => { + return Err(RadioRuntimeError::ReplayAlreadyInitialized) + } + Some((replay, identity)) => (replay, Some(identity), false), + None if options.initialize_replay_state => { + (RadioReplayGuard::default(), None, true) + } + None => return Err(RadioRuntimeError::ReplayInitializationRequired), + }; + if let Some(identity) = replay_identity { + protected_files.insert(identity, &options.replay_state_file)?; + } + let mut runtime = Self { + gateways: enrolled_gateways, + host_pseudonym_secret, + companion_secret, + companion_config, + replay_state_file: options.replay_state_file, + _replay_lock: replay_lock, + protected_files, + replay, + cs_states: BTreeMap::new(), + }; + if needs_initial_snapshot { + runtime.persist_replay(&runtime.replay)?; + let replay_file = open_read_nofollow(&runtime.replay_state_file) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + let identity = file_identity(&replay_file, &runtime.replay_state_file)?; + runtime + .protected_files + .insert(identity, &runtime.replay_state_file)?; + } + Ok(runtime) + } + + /// Verify and stage one RVAE datagram. `Ok(None)` means a valid Channel + /// Sounding step was buffered but no complete procedure was ready. Callers + /// must call [`Self::commit`] before publishing any returned update. + pub fn stage( + &mut self, + frame: &[u8], + host_received_at_unix_us: i64, + ) -> Result, RadioRuntimeError> { + let gateway_index = self.gateway_index(frame)?; + let gateway = &self.gateways[gateway_index]; + let envelope = + AuthenticatedGatewayEnvelope::parse(frame, gateway.secret.expose(), gateway.config)?; + match envelope.metadata.payload_type { + GatewayPayloadType::BleIdentity => { + let mut candidate_replay = self.replay.clone(); + let evidence = BleIdentityEvidence::parse_gateway_authenticated( + frame, + host_received_at_unix_us.div_euclid(1_000), + gateway.secret.expose(), + gateway.config, + BleIngressConfig::default(), + &mut candidate_replay, + )?; + self.replay = candidate_replay; + Ok(Some(ble_update( + evidence, + self.host_pseudonym_secret.expose(), + ))) + } + GatewayPayloadType::ChannelSounding => { + let companion_secret = self + .companion_secret + .as_ref() + .ok_or(RadioRuntimeError::ChannelSoundingDisabled)?; + let companion_config = self + .companion_config + .ok_or(RadioRuntimeError::ChannelSoundingDisabled)?; + let mut candidate_replay = self.replay.clone(); + let measurement = ChannelSoundingMeasurement::parse_gateway_authenticated( + frame, + host_received_at_unix_us, + gateway.secret.expose(), + gateway.config, + companion_secret.expose(), + companion_config, + &mut candidate_replay, + )?; + self.ingest_channel_sounding(measurement, candidate_replay) + } + } + } + + /// Persist all staged replay high-water marks atomically. + pub fn commit(&self) -> Result<(), RadioRuntimeError> { + self.persist_replay(&self.replay) + } + + /// Test and administrative convenience path that stages and commits one + /// datagram before returning an update. + pub fn ingest_durable( + &mut self, + frame: &[u8], + host_received_at_unix_us: i64, + ) -> Result, RadioRuntimeError> { + let update = self.stage(frame, host_received_at_unix_us)?; + self.commit()?; + Ok(update) + } + + fn gateway_index(&self, frame: &[u8]) -> Result { + if frame.len() <= 12 { + return Err(RadioRuntimeError::Ingress(RadioIngressError::Length)); + } + self.gateways + .iter() + .position(|gateway| { + gateway.config.key_id == frame[7] && gateway.config.node_id == frame[12] + }) + .ok_or(RadioRuntimeError::UnknownGateway) + } + + /// Path whose atomic snapshot protects publication across process restarts. + #[must_use] + pub fn replay_state_file(&self) -> &Path { + &self.replay_state_file + } + + fn ingest_channel_sounding( + &mut self, + measurement: ChannelSoundingMeasurement, + candidate_replay: RadioReplayGuard, + ) -> Result, RadioRuntimeError> { + let scope = CsScope { + gateway_node_id: measurement.gateway_node_id, + gateway_key_id: measurement.gateway_key_id, + gateway_boot_nonce: measurement.gateway_boot_nonce, + source_id: measurement.source_id, + source_session_id: measurement.source_session_id, + }; + let candidate_state = self.cs_states.get(&scope).cloned().unwrap_or_default(); + let mut candidate_samples = candidate_state.samples; + let mut candidate_finalized = candidate_state.finalized_procedures; + let procedure_key = ( + measurement.gateway_boot_nonce, + measurement.source_session_id, + measurement.procedure_id, + ); + if candidate_finalized.contains(&procedure_key) { + self.install_cs_state( + scope, + candidate_replay, + candidate_samples, + candidate_finalized, + measurement.capture_at_gateway_boot_us, + ); + return Ok(None); + } + + candidate_samples.push_back(measurement.clone()); + let newest_capture = measurement.capture_at_gateway_boot_us; + candidate_samples.retain(|sample| { + newest_capture.saturating_sub(sample.capture_at_gateway_boot_us) <= CS_WINDOW_US + }); + while candidate_samples.len() > MAX_CS_WINDOW_SAMPLES { + candidate_samples.pop_front(); + } + + let procedure_samples: Vec<_> = candidate_samples + .iter() + .filter(|sample| sample.procedure_id == measurement.procedure_id) + .collect(); + let declared_count = usize::from(measurement.step_count); + let unique_steps: BTreeSet<_> = procedure_samples + .iter() + .map(|sample| sample.step_index) + .collect(); + let unique_channels: BTreeSet<_> = procedure_samples + .iter() + .map(|sample| sample.channel_index) + .collect(); + let invalid = !(4..=79).contains(&measurement.step_count) + || procedure_samples + .iter() + .any(|sample| sample.step_count != measurement.step_count) + || procedure_samples.len() > declared_count + || unique_steps.len() != procedure_samples.len() + || unique_channels.len() != procedure_samples.len(); + if invalid { + candidate_samples.retain(|sample| sample.procedure_id != measurement.procedure_id); + remember_finalized(&mut candidate_finalized, procedure_key); + self.install_cs_state( + scope, + candidate_replay, + candidate_samples, + candidate_finalized, + newest_capture, + ); + return Ok(None); + } + if procedure_samples.len() < declared_count { + self.install_cs_state( + scope, + candidate_replay, + candidate_samples, + candidate_finalized, + newest_capture, + ); + return Ok(None); + } + let exact_steps = (0..measurement.step_count).collect::>(); + if unique_steps != exact_steps { + candidate_samples.retain(|sample| sample.procedure_id != measurement.procedure_id); + remember_finalized(&mut candidate_finalized, procedure_key); + self.install_cs_state( + scope, + candidate_replay, + candidate_samples, + candidate_finalized, + newest_capture, + ); + return Ok(None); + } + + let mut grouped: BTreeMap> = BTreeMap::new(); + for sample in &candidate_samples { + grouped.entry(sample.procedure_id).or_default().push(sample); + } + let mut complete_groups: Vec<_> = grouped + .into_iter() + .filter(|(_, samples)| valid_complete_procedure(samples)) + .map(|(procedure_id, samples)| { + let first_capture = samples + .iter() + .map(|sample| sample.capture_at_gateway_boot_us) + .min() + .unwrap_or(0); + (first_capture, procedure_id) + }) + .collect(); + complete_groups.sort_unstable(); + let window_procedure_ids: Vec<_> = complete_groups + .iter() + .map(|(_, procedure_id)| *procedure_id) + .collect(); + let admitted_ids: BTreeSet<_> = window_procedure_ids.iter().copied().collect(); + let samples: Vec<_> = candidate_samples + .iter() + .filter(|sample| admitted_ids.contains(&sample.procedure_id)) + .cloned() + .collect(); + let respiration = estimate_channel_sounding_respiration( + &samples, + RespirationEstimatorConfig { + evidence: RadioEvidenceLabel::Claimed, + ..RespirationEstimatorConfig::default() + }, + ); + let unique_channels = u16::try_from(unique_channels.len()).unwrap_or(u16::MAX); + let window_sample_count = u16::try_from(samples.len()).unwrap_or(u16::MAX); + let window_start_gateway_boot_us = samples + .iter() + .map(|sample| sample.capture_at_gateway_boot_us) + .min() + .unwrap_or(measurement.capture_at_gateway_boot_us); + let window_end_gateway_boot_us = samples + .iter() + .map(|sample| sample.capture_at_gateway_boot_us) + .max() + .unwrap_or(measurement.capture_at_gateway_boot_us); + let expires_at_unix_us = measurement + .host_received_at_unix_us + .checked_add(RADIO_EVIDENCE_TTL_US) + .ok_or(RadioIngressError::Bounds)?; + let update = RadioIngressUpdate::ChannelSoundingRespiration { + evidence: RadioEvidenceLabel::Claimed, + gateway_node_id: measurement.gateway_node_id, + source_id: measurement.source_id, + source_session_id: measurement.source_session_id, + trigger_procedure_id: measurement.procedure_id, + complete_steps: measurement.step_count, + unique_channels, + window_procedure_ids, + window_sample_count, + window_start_gateway_boot_us, + window_end_gateway_boot_us, + publish_gateway_sequence: measurement.gateway_sequence, + valid_from_unix_us: measurement.host_received_at_unix_us, + expires_at_unix_us, + respiration, + }; + + remember_finalized(&mut candidate_finalized, procedure_key); + self.install_cs_state( + scope, + candidate_replay, + candidate_samples, + candidate_finalized, + newest_capture, + ); + Ok(Some(update)) + } + + fn install_cs_state( + &mut self, + scope: CsScope, + replay: RadioReplayGuard, + samples: VecDeque, + finalized_procedures: VecDeque<(u64, u32, u32)>, + last_capture_at_gateway_boot_us: u64, + ) { + self.replay = replay; + self.cs_states.insert( + scope, + CsState { + samples, + finalized_procedures, + last_capture_at_gateway_boot_us, + }, + ); + while self.cs_states.len() > MAX_CS_SCOPES { + let Some(oldest) = self + .cs_states + .iter() + .min_by_key(|(_, state)| state.last_capture_at_gateway_boot_us) + .map(|(scope, _)| *scope) + else { + break; + }; + self.cs_states.remove(&oldest); + } + } + + fn persist_replay(&self, replay: &RadioReplayGuard) -> Result<(), RadioRuntimeError> { + let snapshot = replay.snapshot()?; + let bytes = serde_json::to_vec(&snapshot) + .map_err(|error| RadioRuntimeError::ReplayState(error.to_string()))?; + atomic_write_private(&self.replay_state_file, &bytes) + } +} + +fn valid_complete_procedure(samples: &[&ChannelSoundingMeasurement]) -> bool { + let Some(first) = samples.first() else { + return false; + }; + if !(4..=79).contains(&first.step_count) || samples.len() != usize::from(first.step_count) { + return false; + } + let steps: BTreeSet<_> = samples.iter().map(|sample| sample.step_index).collect(); + let channels: BTreeSet<_> = samples.iter().map(|sample| sample.channel_index).collect(); + samples.iter().all(|sample| { + sample.step_count == first.step_count + && sample.source_id == first.source_id + && sample.source_session_id == first.source_session_id + && sample.gateway_node_id == first.gateway_node_id + && sample.gateway_key_id == first.gateway_key_id + && sample.gateway_boot_nonce == first.gateway_boot_nonce + }) && steps == (0..first.step_count).collect() + && channels.len() == samples.len() +} + +fn remember_finalized(finalized: &mut VecDeque<(u64, u32, u32)>, procedure: (u64, u32, u32)) { + if !finalized.contains(&procedure) { + finalized.push_back(procedure); + } + while finalized.len() > MAX_FINALIZED_CS_PROCEDURES { + finalized.pop_front(); + } +} + +fn ble_update( + evidence: BleIdentityEvidence, + host_pseudonym_secret: &[u8; 32], +) -> RadioIngressUpdate { + let mut mac = HmacSha256::new_from_slice(host_pseudonym_secret) + .expect("HMAC-SHA256 accepts a 32-byte key"); + mac.update(b"rufield.ble.identity.v1\0"); + mac.update(&evidence.pseudonym); + mac.update(&u64::from(evidence.token_epoch_min).to_le_bytes()); + let digest = mac.finalize().into_bytes(); + let pseudonymous_token = format!( + "blep:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + RadioIngressUpdate::BleIdentity { + evidence: RadioEvidenceLabel::Claimed, + gateway_node_id: evidence.node_id, + pseudonymous_token, + rssi_dbm: evidence.rssi_dbm, + confidence_permille: evidence.confidence_permille, + expires_at_unix_ms: evidence.expires_at_unix_ms, + scanner_time_verified: evidence.scanner_time_verified, + gateway_timing_uncertainty_us: evidence.gateway_timing_uncertainty_us, + } +} + +fn read_secret(path: &Path) -> Result<([u8; SECRET_SIZE], FileIdentity), RadioRuntimeError> { + let mut file = match open_read_nofollow(path) { + Ok(file) => file, + Err(error) if is_symlink_open_error(&error) => { + return Err(RadioRuntimeError::SecretFileType) + } + Err(error) => return Err(RadioRuntimeError::Storage(error.to_string())), + }; + let metadata = file + .metadata() + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + if !metadata.is_file() { + return Err(RadioRuntimeError::SecretFileType); + } + let identity = file_identity(&file, path)?; + check_private_permissions(&metadata, RadioRuntimeError::SecretPermissions)?; + if metadata.len() != SECRET_SIZE as u64 { + return Err(RadioRuntimeError::SecretLength); + } + let mut secret = [0u8; SECRET_SIZE]; + file.read_exact(&mut secret) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + if secret.iter().all(|byte| *byte == 0) { + return Err(RadioRuntimeError::ZeroSecret); + } + Ok((secret, identity)) +} + +fn read_replay_guard( + path: &Path, + now_unix_ms: i64, +) -> Result, RadioRuntimeError> { + let file = match open_read_nofollow(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) if is_symlink_open_error(&error) => { + return Err(RadioRuntimeError::ReplayFileType) + } + Err(error) => return Err(RadioRuntimeError::Storage(error.to_string())), + }; + let metadata = file + .metadata() + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + if !metadata.is_file() { + return Err(RadioRuntimeError::ReplayFileType); + } + let identity = file_identity(&file, path)?; + check_private_permissions(&metadata, RadioRuntimeError::ReplayPermissions)?; + if metadata.len() > MAX_REPLAY_FILE_SIZE { + return Err(RadioRuntimeError::ReplayFileSize); + } + let mut bytes = Vec::new(); + file.take(MAX_REPLAY_FILE_SIZE + 1) + .read_to_end(&mut bytes) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + if bytes.len() as u64 > MAX_REPLAY_FILE_SIZE { + return Err(RadioRuntimeError::ReplayFileSize); + } + let snapshot: RadioReplaySnapshot = serde_json::from_slice(&bytes) + .map_err(|error| RadioRuntimeError::ReplayState(error.to_string()))?; + RadioReplayGuard::from_snapshot(snapshot, now_unix_ms) + .map(|replay| Some((replay, identity))) + .map_err(RadioRuntimeError::from) +} + +fn replay_parent(path: &Path) -> &Path { + path.parent() + .filter(|value| !value.as_os_str().is_empty()) + .unwrap_or(Path::new(".")) +} + +fn acquire_replay_lock(path: &Path) -> Result<(File, FileIdentity, PathBuf), RadioRuntimeError> { + let mut lock_name = OsString::from(path.as_os_str()); + lock_name.push(".lock"); + let lock_path = PathBuf::from(lock_name); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = match options.open(&lock_path) { + Ok(file) => file, + Err(error) if is_symlink_open_error(&error) => { + return Err(RadioRuntimeError::ReplayFileType) + } + Err(error) => return Err(RadioRuntimeError::Storage(error.to_string())), + }; + let metadata = file + .metadata() + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + if !metadata.is_file() { + return Err(RadioRuntimeError::ReplayFileType); + } + let identity = file_identity(&file, &lock_path)?; + check_private_permissions(&metadata, RadioRuntimeError::ReplayPermissions)?; + match file.try_lock_exclusive() { + Ok(()) => Ok((file, identity, lock_path)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + Err(RadioRuntimeError::ReplayLocked) + } + Err(error) => Err(RadioRuntimeError::Storage(error.to_string())), + } +} + +#[cfg(unix)] +fn file_identity(file: &File, _path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let metadata = file + .metadata() + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + Ok(FileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(not(unix))] +fn file_identity(_file: &File, path: &Path) -> Result { + Ok(FileIdentity { + canonical_path: fs::canonicalize(path) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?, + }) +} + +fn open_read_nofollow(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + #[cfg(not(unix))] + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "symbolic links are not accepted", + )); + } + options.open(path) +} + +fn is_symlink_open_error(error: &std::io::Error) -> bool { + #[cfg(unix)] + { + error.raw_os_error() == Some(libc::ELOOP) + } + #[cfg(not(unix))] + { + error.kind() == std::io::ErrorKind::InvalidInput + } +} + +fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), RadioRuntimeError> { + if bytes.len() as u64 > MAX_REPLAY_FILE_SIZE { + return Err(RadioRuntimeError::ReplayFileSize); + } + let parent = replay_parent(path); + fs::create_dir_all(parent).map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + let name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + RadioRuntimeError::Storage("replay state path has no UTF-8 filename".to_string()) + })?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))? + .as_nanos(); + let temporary = parent.join(format!(".{name}.tmp.{}.{nonce}", std::process::id())); + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let mut file = options + .open(&temporary) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + fs::rename(&temporary, path) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +/// An authenticated-radio datagram copied out of the shared UDP receive loop. +/// The bounded worker owns all cryptography, estimation, and storage work. +#[derive(Debug)] +pub struct RadioDatagram { + pub frame: Vec, + pub host_received_at_unix_us: i64, + pub source: SocketAddr, +} + +/// Start the single-owner radio worker. At most 256 datagrams can wait in +/// memory. Accepted records are group committed at most once per second or per +/// 256 records, and updates are released only after the snapshot and audit +/// record are durable. +pub fn spawn_radio_ingress_worker( + runtime: RadioIngressRuntime, + export_policy: RadioExportPolicy, + export_audit_log: Option, + broadcast: tokio::sync::broadcast::Sender, +) -> Result, RadioRuntimeError> { + let audit = match (export_policy.any(), export_audit_log) { + (true, Some(path)) => Some(open_export_audit(&path, &runtime.protected_files)?), + (true, None) => return Err(RadioRuntimeError::ExportAuditRequired), + (false, _) => None, + }; + let (sender, receiver) = mpsc::sync_channel(RADIO_WORKER_QUEUE_CAPACITY); + thread::Builder::new() + .name("ruview-radio-ingress".to_string()) + .spawn(move || { + if let Err(error) = + run_radio_worker(runtime, export_policy, audit, broadcast, receiver) + { + tracing::error!( + %error, + "radio ingress worker stopped; its queue is disconnected and radio ingress is unhealthy" + ); + } + }) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + Ok(sender) +} + +fn run_radio_worker( + mut runtime: RadioIngressRuntime, + export_policy: RadioExportPolicy, + mut audit: Option, + broadcast: tokio::sync::broadcast::Sender, + receiver: Receiver, +) -> Result<(), RadioRuntimeError> { + let mut rejected_since_log = 0u64; + let mut first_rejection = None::; + let mut last_rejection_log = Instant::now(); + while let Ok(first) = receiver.recv() { + let started = Instant::now(); + let mut batch = vec![first]; + let mut disconnected = false; + while batch.len() < RADIO_COMMIT_BATCH_LIMIT { + let Some(remaining) = RADIO_COMMIT_INTERVAL.checked_sub(started.elapsed()) else { + break; + }; + match receiver.recv_timeout(remaining) { + Ok(datagram) => batch.push(datagram), + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + disconnected = true; + break; + } + } + } + while batch.len() < RADIO_COMMIT_BATCH_LIMIT { + match receiver.try_recv() { + Ok(datagram) => batch.push(datagram), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + let mut accepted = 0usize; + let mut pending_updates = Vec::new(); + for datagram in batch { + match runtime.stage(&datagram.frame, datagram.host_received_at_unix_us) { + Ok(update) => { + accepted += 1; + if let Some(update) = update { + pending_updates.push(update); + } + } + Err(error) => { + rejected_since_log = rejected_since_log.saturating_add(1); + first_rejection.get_or_insert_with(|| { + format!("source={}, error={error}", datagram.source) + }); + } + } + } + + if accepted > 0 { + if let Err(error) = runtime.commit() { + tracing::error!( + %error, + accepted, + "radio replay group commit failed; worker stopped and no pending evidence was published" + ); + return Err(error); + } + publish_committed_updates(&pending_updates, export_policy, audit.as_mut(), &broadcast)?; + } + + if rejected_since_log > 0 && last_rejection_log.elapsed() >= Duration::from_secs(1) { + tracing::warn!( + rejected = rejected_since_log, + first = first_rejection.as_deref().unwrap_or("unknown"), + "authenticated-radio frames rejected during the last reporting interval" + ); + rejected_since_log = 0; + first_rejection = None; + last_rejection_log = Instant::now(); + } + if disconnected { + break; + } + } + Ok(()) +} + +fn publish_committed_updates( + updates: &[RadioIngressUpdate], + export_policy: RadioExportPolicy, + audit: Option<&mut File>, + broadcast: &tokio::sync::broadcast::Sender, +) -> Result<(), RadioRuntimeError> { + let now_unix_us = unix_time_us(); + let mut messages = Vec::new(); + let mut p4_count = 0usize; + let mut p5_count = 0usize; + for update in updates { + if !export_policy.allows(update) || !update_is_live(update, now_unix_us) { + continue; + } + match update.privacy_class() { + RadioPrivacyClass::P4 => p4_count += 1, + RadioPrivacyClass::P5 => p5_count += 1, + } + let message = serde_json::json!({ + "type": "radio_evidence", + "provenance": "CLAIMED", + "privacy_class": update.privacy_class(), + "export_gate": "audited_local_override", + "data": update, + }); + messages.push( + serde_json::to_string(&message) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?, + ); + } + if messages.is_empty() { + return Ok(()); + } + let audit = audit.ok_or(RadioRuntimeError::ExportAuditRequired)?; + let audit_record = serde_json::json!({ + "event": "radio_export_batch", + "at_unix_us": now_unix_us, + "p4_count": p4_count, + "p5_count": p5_count, + }); + serde_json::to_vec(&audit_record) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + .and_then(|mut bytes| { + bytes.push(b'\n'); + audit.write_all(&bytes).and_then(|()| audit.sync_data()) + }) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + for message in messages { + let _ = broadcast.send(message); + } + Ok(()) +} + +fn update_is_live(update: &RadioIngressUpdate, now_unix_us: i64) -> bool { + match update { + RadioIngressUpdate::BleIdentity { + expires_at_unix_ms, .. + } => now_unix_us.div_euclid(1_000) < *expires_at_unix_ms, + RadioIngressUpdate::ChannelSoundingRespiration { + valid_from_unix_us, + expires_at_unix_us, + .. + } => now_unix_us >= *valid_from_unix_us && now_unix_us < *expires_at_unix_us, + } +} + +fn unix_time_us() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_micros()).ok()) + .unwrap_or(i64::MAX) +} + +fn audit_open_options(create_new: bool) -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true).append(true).create_new(create_new); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + options +} + +fn open_export_audit( + path: &Path, + protected_files: &ProtectedFiles, +) -> Result { + let parent = replay_parent(path); + if !parent.exists() { + return Err(RadioRuntimeError::Storage(format!( + "audit directory does not exist: {}", + parent.display() + ))); + } + let (mut file, created) = match audit_open_options(true).open(path) { + Ok(file) => (file, true), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + match audit_open_options(false).open(path) { + Ok(file) => (file, false), + Err(error) if is_symlink_open_error(&error) => { + return Err(RadioRuntimeError::AuditFileType) + } + Err(error) => return Err(RadioRuntimeError::Storage(error.to_string())), + } + } + Err(error) if is_symlink_open_error(&error) => { + return Err(RadioRuntimeError::AuditFileType) + } + Err(error) => return Err(RadioRuntimeError::Storage(error.to_string())), + }; + let metadata = file + .metadata() + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + if !metadata.is_file() { + return Err(RadioRuntimeError::AuditFileType); + } + check_private_permissions(&metadata, RadioRuntimeError::AuditPermissions)?; + if protected_files.contains(&file, path)? { + return Err(RadioRuntimeError::AuditAliasesProtectedFile); + } + if metadata.len() > 0 { + file.seek(SeekFrom::End(-1)) + .and_then(|_| { + let mut tail = [0u8; 1]; + file.read_exact(&mut tail).map(|()| tail) + }) + .map_err(|error| RadioRuntimeError::Storage(error.to_string())) + .and_then(|tail| { + if tail == [b'\n'] { + Ok(()) + } else { + Err(RadioRuntimeError::AuditMalformed) + } + })?; + } + match file.try_lock_exclusive() { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + return Err(RadioRuntimeError::AuditLocked) + } + Err(error) => return Err(RadioRuntimeError::Storage(error.to_string())), + } + if created { + file.sync_all() + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| RadioRuntimeError::Storage(error.to_string()))?; + } + Ok(file) +} + +#[cfg(unix)] +fn check_private_permissions( + metadata: &fs::Metadata, + error: RadioRuntimeError, +) -> Result<(), RadioRuntimeError> { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + Err(error) + } else { + Ok(()) + } +} + +#[cfg(not(unix))] +fn check_private_permissions( + _metadata: &fs::Metadata, + _error: RadioRuntimeError, +) -> Result<(), RadioRuntimeError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use tempfile::TempDir; + + type HmacSha256 = Hmac; + const GW_SECRET: [u8; 32] = [0x31; 32]; + const GW_SECRET_2: [u8; 32] = [0x53; 32]; + const HOST_SECRET: [u8; 32] = [0x42; 32]; + const CS_SECRET: [u8; 32] = [0x64; 32]; + + fn write_secret(path: &Path, secret: &[u8; 32]) { + fs::write(path, secret).expect("write secret"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("chmod secret"); + } + } + + fn options(temp: &TempDir) -> RadioIngressOptions { + let secret = temp.path().join("gateway.bin"); + let host_secret = temp.path().join("host-pseudonym.bin"); + write_secret(&secret, &GW_SECRET); + write_secret(&host_secret, &HOST_SECRET); + RadioIngressOptions { + gateways: vec![GatewayRuntimeOptions { + node_id: 7, + key_id: 3, + secret_file: secret, + }], + host_pseudonym_secret_file: host_secret, + replay_state_file: temp.path().join("replay.json"), + initialize_replay_state: true, + channel_sounding: None, + } + } + + fn ble_envelope(sequence: u32, inner_sequence: u32, epoch_min: u32) -> Vec { + ble_envelope_for(sequence, inner_sequence, epoch_min, 7, 3, &GW_SECRET) + } + + fn ble_envelope_for( + sequence: u32, + inner_sequence: u32, + epoch_min: u32, + node_id: u8, + gateway_key_id: u8, + gateway_secret: &[u8; 32], + ) -> Vec { + let mut payload = vec![0u8; 36]; + payload[0..4] + .copy_from_slice(&ruview_fusion::radio_fusion::BLE_IDENTITY_MAGIC.to_le_bytes()); + payload[4] = 1; + payload[5] = node_id; + payload[6] = 0b11; + payload[7] = 9; + payload[8..12].copy_from_slice(&inner_sequence.to_le_bytes()); + payload[12..16].copy_from_slice(&1_000u32.to_le_bytes()); + payload[16..18].copy_from_slice(&5_000u16.to_le_bytes()); + payload[18..20].copy_from_slice(&800u16.to_le_bytes()); + payload[20] = (-55i8) as u8; + payload[21] = (-8i8) as u8; + payload[24..32].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + payload[32..36].copy_from_slice(&epoch_min.to_le_bytes()); + + let total = 40 + payload.len() + 16; + let mut frame = vec![0u8; total]; + frame[0..4] + .copy_from_slice(&ruview_fusion::radio_fusion::GATEWAY_ENVELOPE_MAGIC.to_le_bytes()); + frame[4] = 1; + frame[5] = 1; + frame[6] = 1; + frame[7] = gateway_key_id; + frame[8..10].copy_from_slice(&(total as u16).to_le_bytes()); + frame[10..12].copy_from_slice(&(payload.len() as u16).to_le_bytes()); + frame[12] = node_id; + frame[16..20].copy_from_slice(&sequence.to_le_bytes()); + frame[20..28].copy_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes()); + frame[28..36].copy_from_slice(&2_000_000u64.to_le_bytes()); + frame[36..40].copy_from_slice(&2_000u32.to_le_bytes()); + frame[40..40 + payload.len()].copy_from_slice(&payload); + let signed_len = 40 + payload.len(); + let mut mac = HmacSha256::new_from_slice(gateway_secret).expect("HMAC key"); + mac.update(b"RuView/GW/v1"); + mac.update(&frame[..signed_len]); + let tag = mac.finalize().into_bytes(); + frame[signed_len..].copy_from_slice(&tag[..16]); + frame + } + + #[test] + fn missing_replay_requires_explicit_initialization_and_runtime_is_exclusive() { + let temp = TempDir::new().expect("tempdir"); + let mut uninitialized = options(&temp); + uninitialized.initialize_replay_state = false; + assert!(matches!( + RadioIngressRuntime::open(uninitialized, 1_900_000_000_000), + Err(RadioRuntimeError::ReplayInitializationRequired) + )); + + let first = + RadioIngressRuntime::open(options(&temp), 1_900_000_000_000).expect("first runtime"); + assert!(matches!( + RadioIngressRuntime::open(options(&temp), 1_900_000_000_000), + Err(RadioRuntimeError::ReplayLocked) + )); + drop(first); + assert!(matches!( + RadioIngressRuntime::open(options(&temp), 1_900_000_000_000), + Err(RadioRuntimeError::ReplayAlreadyInitialized) + )); + let mut restart = options(&temp); + restart.initialize_replay_state = false; + RadioIngressRuntime::open(restart, 1_900_000_000_000) + .expect("lock released and one-shot initialization removed"); + } + + #[test] + fn admits_only_exact_supported_rvae_envelope_lengths() { + assert!(is_supported_rvae_datagram_len(92)); + assert!(is_supported_rvae_datagram_len(128)); + for length in [0, 4, 91, 93, 127, 129, 65_507] { + assert!(!is_supported_rvae_datagram_len(length), "length={length}"); + } + } + + #[test] + fn independently_authenticates_two_enrolled_gateways() { + let temp = TempDir::new().expect("tempdir"); + let mut options = options(&temp); + let second_secret = temp.path().join("gateway-2.bin"); + write_secret(&second_secret, &GW_SECRET_2); + options.gateways.push(GatewayRuntimeOptions { + node_id: 8, + key_id: 4, + secret_file: second_secret, + }); + let now_ms = 1_900_000_000_000i64; + let epoch = (now_ms / 60_000) as u32; + let mut runtime = RadioIngressRuntime::open(options, now_ms).expect("runtime"); + runtime + .ingest_durable(&ble_envelope(1, 1, epoch), now_ms * 1_000) + .expect("primary gateway"); + let second = ble_envelope_for(1, 1, epoch, 8, 4, &GW_SECRET_2); + let update = runtime + .ingest_durable(&second, now_ms * 1_000) + .expect("second gateway") + .expect("BLE update"); + assert!(matches!( + update, + RadioIngressUpdate::BleIdentity { + gateway_node_id: 8, + .. + } + )); + } + + #[test] + fn worker_commits_and_audits_before_p5_publication() { + let temp = TempDir::new().expect("tempdir"); + let now_ms = chrono::Utc::now().timestamp_millis(); + let runtime = RadioIngressRuntime::open(options(&temp), now_ms).expect("runtime"); + let audit_path = temp.path().join("radio-audit.jsonl"); + let (broadcast, mut receiver) = tokio::sync::broadcast::channel(8); + let sender = spawn_radio_ingress_worker( + runtime, + RadioExportPolicy { + allow_biological_p4: false, + allow_identity_p5: true, + }, + Some(audit_path.clone()), + broadcast, + ) + .expect("worker"); + sender + .send(RadioDatagram { + frame: ble_envelope(1, 1, (now_ms / 60_000) as u32), + host_received_at_unix_us: now_ms * 1_000, + source: "127.0.0.1:5005".parse().expect("socket"), + }) + .expect("queue"); + std::thread::sleep(Duration::from_millis(100)); + assert!(matches!( + receiver.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + std::thread::sleep(Duration::from_millis(1_050)); + let message = receiver.try_recv().expect("publication after group commit"); + assert!(message.contains("audited_local_override")); + assert!(fs::read_to_string(&audit_path) + .expect("audit") + .contains("radio_export_batch")); + assert!(fs::read_to_string(temp.path().join("replay.json")) + .expect("replay") + .contains("gateway")); + } + + #[test] + fn persists_replay_before_return_and_rejects_after_restart() { + let temp = TempDir::new().expect("tempdir"); + let now_ms = 1_900_000_000_000i64; + let frame = ble_envelope(1, 1, (now_ms / 60_000) as u32); + let mut runtime = RadioIngressRuntime::open(options(&temp), now_ms).expect("open runtime"); + let update = runtime + .ingest_durable(&frame, now_ms * 1_000) + .expect("ingest") + .expect("BLE emits"); + assert!(matches!(update, RadioIngressUpdate::BleIdentity { .. })); + let update_json = serde_json::to_string(&update).expect("serialize update"); + assert!(update_json.contains("\"pseudonymous_token\":\"blep:")); + assert!(!update_json.contains("[1,2,3,4,5,6,7,8]")); + assert!(runtime.replay_state_file().exists()); + let replay_json = fs::read_to_string(runtime.replay_state_file()).expect("read replay"); + assert!(!replay_json.contains("\"pseudonym\"")); + assert!(!replay_json.contains("[1,2,3,4,5,6,7,8]")); + + drop(runtime); + let mut restart_options = options(&temp); + restart_options.initialize_replay_state = false; + let mut restarted = RadioIngressRuntime::open(restart_options, now_ms).expect("restart"); + assert!(matches!( + restarted.ingest_durable(&frame, now_ms * 1_000), + Err(RadioRuntimeError::Ingress(RadioIngressError::Replay)) + )); + } + + #[test] + fn creates_private_exclusively_locked_audit_file() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("radio-audit.jsonl"); + let protected = ProtectedFiles::default(); + let first = open_export_audit(&path, &protected).expect("create audit"); + assert!(path.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + first.metadata().expect("metadata").permissions().mode() & 0o077, + 0 + ); + } + assert!(matches!( + open_export_audit(&path, &protected), + Err(RadioRuntimeError::AuditLocked) + )); + } + + #[test] + fn rejects_audit_paths_aliasing_every_runtime_protected_file() { + for protected_name in ["gateway", "host", "replay", "lock", "companion"] { + let temp = TempDir::new().expect("tempdir"); + let mut runtime_options = options(&temp); + let companion_path = temp.path().join("companion.bin"); + write_secret(&companion_path, &CS_SECRET); + runtime_options.channel_sounding = Some(ChannelSoundingRuntimeOptions { + key_id: 9, + source_id: 99, + secret_file: companion_path.clone(), + }); + let replay_path = runtime_options.replay_state_file.clone(); + let gateway_path = runtime_options.gateways[0].secret_file.clone(); + let host_path = runtime_options.host_pseudonym_secret_file.clone(); + let runtime = + RadioIngressRuntime::open(runtime_options, 1_900_000_000_000).expect("runtime"); + let audit_path = match protected_name { + "gateway" => gateway_path, + "host" => host_path, + "replay" => replay_path.clone(), + "lock" => { + let mut name = OsString::from(replay_path.as_os_str()); + name.push(".lock"); + PathBuf::from(name) + } + "companion" => companion_path, + _ => unreachable!(), + }; + let (broadcast, _) = tokio::sync::broadcast::channel(1); + assert!( + matches!( + spawn_radio_ingress_worker( + runtime, + RadioExportPolicy { + allow_biological_p4: false, + allow_identity_p5: true, + }, + Some(audit_path), + broadcast, + ), + Err(RadioRuntimeError::AuditAliasesProtectedFile) + ), + "protected_name={protected_name}" + ); + } + } + + #[cfg(unix)] + #[test] + fn rejects_hard_linked_audit_alias_by_opened_file_identity() { + let temp = TempDir::new().expect("tempdir"); + let runtime_options = options(&temp); + let secret_path = runtime_options.gateways[0].secret_file.clone(); + let audit_path = temp.path().join("radio-audit.jsonl"); + fs::hard_link(&secret_path, &audit_path).expect("hard link"); + let runtime = + RadioIngressRuntime::open(runtime_options, 1_900_000_000_000).expect("runtime"); + let (broadcast, _) = tokio::sync::broadcast::channel(1); + assert!(matches!( + spawn_radio_ingress_worker( + runtime, + RadioExportPolicy { + allow_biological_p4: false, + allow_identity_p5: true, + }, + Some(audit_path), + broadcast, + ), + Err(RadioRuntimeError::AuditAliasesProtectedFile) + )); + } + + #[test] + fn malformed_audit_tail_fails_closed() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("radio-audit.jsonl"); + fs::write(&path, b"{\"incomplete\":true}").expect("write malformed audit"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod audit"); + } + assert!(matches!( + open_export_audit(&path, &ProtectedFiles::default()), + Err(RadioRuntimeError::AuditMalformed) + )); + } + + #[test] + fn audit_write_failure_stops_worker_before_publication() { + let temp = TempDir::new().expect("tempdir"); + let now_ms = chrono::Utc::now().timestamp_millis(); + let runtime = RadioIngressRuntime::open(options(&temp), now_ms).expect("runtime"); + let audit_path = temp.path().join("read-only-audit.jsonl"); + fs::write(&audit_path, b"").expect("audit file"); + let read_only_audit = File::open(&audit_path).expect("read-only audit descriptor"); + let (broadcast, mut published) = tokio::sync::broadcast::channel(1); + let (sender, receiver) = mpsc::sync_channel(1); + sender + .send(RadioDatagram { + frame: ble_envelope(1, 1, (now_ms / 60_000) as u32), + host_received_at_unix_us: now_ms * 1_000, + source: "127.0.0.1:5005".parse().expect("socket"), + }) + .expect("queue"); + drop(sender); + + assert!(matches!( + run_radio_worker( + runtime, + RadioExportPolicy { + allow_biological_p4: false, + allow_identity_p5: true, + }, + Some(read_only_audit), + broadcast, + receiver, + ), + Err(RadioRuntimeError::Storage(_)) + )); + assert!(matches!( + published.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Closed) + )); + } + + #[test] + fn rejects_zero_and_reused_secrets() { + let temp = TempDir::new().expect("tempdir"); + let zero_options = options(&temp); + write_secret(&zero_options.gateways[0].secret_file, &[0; 32]); + assert!(matches!( + RadioIngressRuntime::open(zero_options, 1), + Err(RadioRuntimeError::ZeroSecret) + )); + + let temp = TempDir::new().expect("tempdir"); + let mut reused_options = options(&temp); + let companion = temp.path().join("companion.bin"); + write_secret(&companion, &GW_SECRET); + reused_options.channel_sounding = Some(ChannelSoundingRuntimeOptions { + key_id: 4, + source_id: 99, + secret_file: companion, + }); + assert!(matches!( + RadioIngressRuntime::open(reused_options, 1), + Err(RadioRuntimeError::ReusedSecret) + )); + } + + #[cfg(unix)] + #[test] + fn rejects_world_readable_secret() { + use std::os::unix::fs::PermissionsExt; + let temp = TempDir::new().expect("tempdir"); + let options = options(&temp); + fs::set_permissions( + &options.gateways[0].secret_file, + fs::Permissions::from_mode(0o644), + ) + .expect("chmod"); + assert!(matches!( + RadioIngressRuntime::open(options, 1), + Err(RadioRuntimeError::SecretPermissions) + )); + } +}