mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 02:04:55 +00:00
feat: add authenticated ESP32 BLE fusion path
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
341
firmware/esp32-csi-node/main/ble_identity.c
Normal file
341
firmware/esp32-csi-node/main/ble_identity.c
Normal file
@@ -0,0 +1,341 @@
|
||||
/** @file ble_identity.c */
|
||||
|
||||
#include "ble_identity.h"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_BLE_IDENTITY_SCAN_ENABLE)
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "host/ble_gap.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/util/util.h"
|
||||
#include "mbedtls/md.h"
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
|
||||
#include "ble_identity_protocol.h"
|
||||
#include "csi_collector.h"
|
||||
#include "nvs_config.h"
|
||||
#include "radio_gateway_protocol.h"
|
||||
#include "radio_gateway_sender.h"
|
||||
|
||||
extern nvs_config_t g_nvs_config;
|
||||
|
||||
static const char *TAG = "ble_identity";
|
||||
static uint8_t s_own_addr_type;
|
||||
static uint32_t s_sequence;
|
||||
static uint64_t s_rate_window_start_us;
|
||||
static uint32_t s_rate_window_count;
|
||||
static uint32_t s_rate_drops;
|
||||
static uint32_t s_queue_drops;
|
||||
static EventGroupHandle_t s_start_events;
|
||||
static atomic_bool s_scan_healthy;
|
||||
static atomic_bool s_host_running;
|
||||
static int start_scan(void);
|
||||
|
||||
#define BLE_START_READY_BIT (1u << 0)
|
||||
#define BLE_START_FAILED_BIT (1u << 1)
|
||||
|
||||
static bool admit_report(uint64_t observed_at_us)
|
||||
{
|
||||
if (s_rate_window_start_us == 0u
|
||||
|| observed_at_us - s_rate_window_start_us >= 1000000u) {
|
||||
s_rate_window_start_us = observed_at_us;
|
||||
s_rate_window_count = 0u;
|
||||
}
|
||||
if (s_rate_window_count >= CONFIG_BLE_IDENTITY_MAX_REPORTS_PER_SEC) {
|
||||
s_rate_drops++;
|
||||
if ((s_rate_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "BLE token admission rate limited (drops=%lu)",
|
||||
(unsigned long)s_rate_drops);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
s_rate_window_count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool authenticate_token(const rv_ble_token_t *token)
|
||||
{
|
||||
if (token == NULL || !g_nvs_config.ble_secret_valid
|
||||
|| token->key_id != g_nvs_config.ble_key_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t covered[34];
|
||||
uint8_t digest[32];
|
||||
rv_ble_token_mac_input(token, covered);
|
||||
const mbedtls_md_info_t *sha256 = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
if (sha256 == NULL
|
||||
|| mbedtls_md_hmac(sha256,
|
||||
g_nvs_config.ble_secret,
|
||||
sizeof(g_nvs_config.ble_secret),
|
||||
covered,
|
||||
sizeof(covered),
|
||||
digest) != 0) {
|
||||
return false;
|
||||
}
|
||||
return rv_ble_auth_tag_equal(token->auth_tag, digest);
|
||||
}
|
||||
|
||||
static bool token_time_is_fresh(const rv_ble_token_t *token, bool *clock_valid)
|
||||
{
|
||||
time_t now = time(NULL);
|
||||
*clock_valid = now >= 1700000000;
|
||||
if (!*clock_valid) {
|
||||
return true; /* Host must validate token_epoch_min before association. */
|
||||
}
|
||||
uint32_t now_min = (uint32_t)((uint64_t)now / 60u);
|
||||
uint32_t delta = now_min > token->epoch_min
|
||||
? now_min - token->epoch_min
|
||||
: token->epoch_min - now_min;
|
||||
return delta <= (uint32_t)CONFIG_BLE_IDENTITY_TOKEN_SKEW_MIN;
|
||||
}
|
||||
|
||||
static uint16_t observation_confidence(int8_t rssi, bool clock_valid)
|
||||
{
|
||||
/* This is evidence quality, not probability that a tag is a person. */
|
||||
int quality = 900;
|
||||
if (!clock_valid) quality = 650;
|
||||
if (rssi < -95) quality -= 250;
|
||||
else if (rssi < -85) quality -= 120;
|
||||
if (quality < 100) quality = 100;
|
||||
return (uint16_t)quality;
|
||||
}
|
||||
|
||||
static void process_advertisement(const uint8_t *data, uint16_t length_data,
|
||||
int8_t rssi, int8_t tx_power,
|
||||
bool extended)
|
||||
{
|
||||
if (data == NULL || length_data > 200u || rssi == 127) return;
|
||||
rv_ble_token_t token;
|
||||
if (!rv_ble_parse_advertisement(data, length_data, &token)) {
|
||||
return;
|
||||
}
|
||||
uint64_t observed_at_us = (uint64_t)esp_timer_get_time();
|
||||
if (!admit_report(observed_at_us) || !authenticate_token(&token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool clock_valid = false;
|
||||
if (!token_time_is_fresh(&token, &clock_valid)) {
|
||||
ESP_LOGW(TAG, "dropping authenticated but stale BLE token (key=%u)",
|
||||
(unsigned)token.key_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (s_sequence == UINT32_MAX) {
|
||||
ESP_LOGE(TAG, "BLE telemetry sequence exhausted; reboot or rekey required");
|
||||
return;
|
||||
}
|
||||
rv_ble_telemetry_t telemetry = {
|
||||
.node_id = csi_collector_get_node_id(),
|
||||
.flags = RV_BLE_FLAG_AUTHENTICATED,
|
||||
.key_id = token.key_id,
|
||||
.sequence = ++s_sequence,
|
||||
.observed_at_ms = (uint32_t)(observed_at_us / 1000u),
|
||||
.ttl_ms = (uint16_t)CONFIG_BLE_IDENTITY_TTL_MS,
|
||||
.confidence_permille = observation_confidence(rssi, clock_valid),
|
||||
.rssi_dbm = rssi,
|
||||
.tx_power_dbm = tx_power,
|
||||
.token_epoch_min = token.epoch_min,
|
||||
};
|
||||
if (clock_valid) telemetry.flags |= RV_BLE_FLAG_TIME_VERIFIED;
|
||||
if (extended) telemetry.flags |= RV_BLE_FLAG_EXTENDED_ADVERT;
|
||||
memcpy(telemetry.ephemeral_id, token.ephemeral_id,
|
||||
sizeof(telemetry.ephemeral_id));
|
||||
|
||||
uint8_t packet[RV_BLE_TELEMETRY_SIZE];
|
||||
if (rv_ble_serialize_telemetry(&telemetry, packet, sizeof(packet))) {
|
||||
esp_err_t rc = radio_gateway_sender_enqueue(
|
||||
RV_GATEWAY_PAYLOAD_BLE_IDENTITY, packet, sizeof(packet),
|
||||
observed_at_us, 1000u);
|
||||
if (rc != ESP_OK) {
|
||||
s_queue_drops++;
|
||||
if ((s_queue_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "BLE gateway queue full or unavailable (drops=%lu)",
|
||||
(unsigned long)s_queue_drops);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int gap_event(struct ble_gap_event *event, void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
switch (event->type) {
|
||||
case BLE_GAP_EVENT_DISC:
|
||||
process_advertisement(event->disc.data, event->disc.length_data,
|
||||
event->disc.rssi, 127, false);
|
||||
return 0;
|
||||
#if defined(CONFIG_BT_NIMBLE_EXT_SCAN)
|
||||
case BLE_GAP_EVENT_EXT_DISC:
|
||||
if (event->ext_disc.data_status
|
||||
!= BLE_GAP_EXT_ADV_DATA_STATUS_COMPLETE) {
|
||||
/* Do not authenticate a prefix. Deployments keep the complete
|
||||
* advertiser payload bounded so it fits one 257-byte HCI event. */
|
||||
return 0;
|
||||
}
|
||||
process_advertisement(event->ext_disc.data,
|
||||
event->ext_disc.length_data,
|
||||
event->ext_disc.rssi,
|
||||
event->ext_disc.tx_power,
|
||||
true);
|
||||
return 0;
|
||||
#endif
|
||||
case BLE_GAP_EVENT_DISC_COMPLETE:
|
||||
atomic_store_explicit(&s_scan_healthy, false, memory_order_release);
|
||||
ESP_LOGW(TAG, "BLE scan terminated (reason=%d); restarting",
|
||||
event->disc_complete.reason);
|
||||
(void)start_scan();
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int start_scan(void)
|
||||
{
|
||||
struct ble_gap_ext_disc_params params;
|
||||
memset(¶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 */
|
||||
23
firmware/esp32-csi-node/main/ble_identity.h
Normal file
23
firmware/esp32-csi-node/main/ble_identity.h
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @file ble_identity.h
|
||||
* @brief Opt-in, privacy-minimized BLE identity-anchor scanner.
|
||||
*
|
||||
* ESP32-S3 BLE scanning provides advertising metadata and RSSI. It does not
|
||||
* expose raw CTE IQ or Bluetooth Channel Sounding measurements. This module
|
||||
* therefore accepts only authenticated RuView service tokens and emits a
|
||||
* short-lived pseudonym; it makes no civil-identity or vital-sign claim.
|
||||
*/
|
||||
|
||||
#ifndef BLE_IDENTITY_H
|
||||
#define BLE_IDENTITY_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/** Start the bounded-duty passive scanner, or return NOT_SUPPORTED/off. */
|
||||
esp_err_t ble_identity_init(void);
|
||||
|
||||
/** True only while the controller has an active extended scan procedure. */
|
||||
bool ble_identity_is_healthy(void);
|
||||
|
||||
#endif /* BLE_IDENTITY_H */
|
||||
154
firmware/esp32-csi-node/main/ble_identity_protocol.c
Normal file
154
firmware/esp32-csi-node/main/ble_identity_protocol.c
Normal file
@@ -0,0 +1,154 @@
|
||||
/** @file ble_identity_protocol.c */
|
||||
|
||||
#include "ble_identity_protocol.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include "mbedtls/constant_time.h"
|
||||
#endif
|
||||
|
||||
const uint8_t RV_BLE_SERVICE_UUID_LE[16] = {
|
||||
0x00, 0x01, 0xe0, 0xb1, 0x11, 0xc5, 0x09, 0x9f,
|
||||
0x69, 0x4d, 0x65, 0x5d, 0x40, 0xa8, 0x31, 0x6f,
|
||||
};
|
||||
|
||||
static uint32_t read_le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0]
|
||||
| ((uint32_t)p[1] << 8)
|
||||
| ((uint32_t)p[2] << 16)
|
||||
| ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static void write_le16(uint8_t *p, uint16_t v)
|
||||
{
|
||||
p[0] = (uint8_t)(v & 0xffu);
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
}
|
||||
|
||||
static void write_le32(uint8_t *p, uint32_t v)
|
||||
{
|
||||
p[0] = (uint8_t)(v & 0xffu);
|
||||
p[1] = (uint8_t)((v >> 8) & 0xffu);
|
||||
p[2] = (uint8_t)((v >> 16) & 0xffu);
|
||||
p[3] = (uint8_t)((v >> 24) & 0xffu);
|
||||
}
|
||||
|
||||
bool rv_ble_parse_advertisement(const uint8_t *advertisement,
|
||||
size_t advertisement_len,
|
||||
rv_ble_token_t *out)
|
||||
{
|
||||
if (advertisement == NULL || out == NULL || advertisement_len == 0u) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t offset = 0u;
|
||||
bool found = false;
|
||||
rv_ble_token_t parsed;
|
||||
memset(&parsed, 0, sizeof(parsed));
|
||||
|
||||
while (offset < advertisement_len) {
|
||||
uint8_t field_len = advertisement[offset];
|
||||
if (field_len == 0u) {
|
||||
break;
|
||||
}
|
||||
if ((size_t)field_len + 1u > advertisement_len - offset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t *field = &advertisement[offset + 1u];
|
||||
uint8_t ad_type = field[0];
|
||||
size_t payload_len = (size_t)field_len - 1u;
|
||||
const uint8_t *payload = &field[1];
|
||||
|
||||
if (ad_type == RV_BLE_AD_TYPE_SERVICE_DATA_UUID128
|
||||
&& payload_len >= sizeof(RV_BLE_SERVICE_UUID_LE)
|
||||
&& memcmp(payload, RV_BLE_SERVICE_UUID_LE,
|
||||
sizeof(RV_BLE_SERVICE_UUID_LE)) == 0) {
|
||||
if (found || payload_len != RV_BLE_SERVICE_DATA_SIZE) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t *body = payload + sizeof(RV_BLE_SERVICE_UUID_LE);
|
||||
parsed.version = body[0];
|
||||
parsed.key_id = body[1];
|
||||
parsed.epoch_min = read_le32(&body[2]);
|
||||
parsed.nonce = read_le32(&body[6]);
|
||||
memcpy(parsed.ephemeral_id, &body[10], RV_BLE_EPHEMERAL_ID_SIZE);
|
||||
memcpy(parsed.auth_tag, &body[18], RV_BLE_AUTH_TAG_SIZE);
|
||||
if (parsed.version != RV_BLE_TOKEN_VERSION) {
|
||||
return false;
|
||||
}
|
||||
found = true;
|
||||
}
|
||||
|
||||
offset += (size_t)field_len + 1u;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
*out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
void rv_ble_token_mac_input(const rv_ble_token_t *token, uint8_t out[34])
|
||||
{
|
||||
if (token == NULL || out == NULL) {
|
||||
return;
|
||||
}
|
||||
memcpy(out, RV_BLE_SERVICE_UUID_LE, 16u);
|
||||
out[16] = token->version;
|
||||
out[17] = token->key_id;
|
||||
write_le32(&out[18], token->epoch_min);
|
||||
write_le32(&out[22], token->nonce);
|
||||
memcpy(&out[26], token->ephemeral_id, RV_BLE_EPHEMERAL_ID_SIZE);
|
||||
}
|
||||
|
||||
bool rv_ble_auth_tag_equal(const uint8_t lhs[RV_BLE_AUTH_TAG_SIZE],
|
||||
const uint8_t rhs[RV_BLE_AUTH_TAG_SIZE])
|
||||
{
|
||||
if (lhs == NULL || rhs == NULL) {
|
||||
return false;
|
||||
}
|
||||
#ifdef ESP_PLATFORM
|
||||
return mbedtls_ct_memcmp(lhs, rhs, RV_BLE_AUTH_TAG_SIZE) == 0;
|
||||
#else
|
||||
uint8_t diff = 0u;
|
||||
for (size_t i = 0u; i < RV_BLE_AUTH_TAG_SIZE; i++) {
|
||||
diff |= (uint8_t)(lhs[i] ^ rhs[i]);
|
||||
}
|
||||
return diff == 0u;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool rv_ble_serialize_telemetry(const rv_ble_telemetry_t *telemetry,
|
||||
uint8_t *out,
|
||||
size_t out_len)
|
||||
{
|
||||
if (telemetry == NULL || out == NULL || out_len < RV_BLE_TELEMETRY_SIZE
|
||||
|| telemetry->ttl_ms == 0u
|
||||
|| telemetry->confidence_permille > 1000u
|
||||
|| telemetry->rssi_dbm == 127
|
||||
|| (telemetry->flags & ~RV_BLE_FLAGS_ALLOWED) != 0u
|
||||
|| (telemetry->flags & RV_BLE_FLAG_AUTHENTICATED) == 0u) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memset(out, 0, RV_BLE_TELEMETRY_SIZE);
|
||||
write_le32(&out[0], RV_BLE_TELEMETRY_MAGIC);
|
||||
out[4] = RV_BLE_TELEMETRY_VERSION;
|
||||
out[5] = telemetry->node_id;
|
||||
out[6] = telemetry->flags;
|
||||
out[7] = telemetry->key_id;
|
||||
write_le32(&out[8], telemetry->sequence);
|
||||
write_le32(&out[12], telemetry->observed_at_ms);
|
||||
write_le16(&out[16], telemetry->ttl_ms);
|
||||
write_le16(&out[18], telemetry->confidence_permille);
|
||||
out[20] = (uint8_t)telemetry->rssi_dbm;
|
||||
out[21] = (uint8_t)telemetry->tx_power_dbm;
|
||||
/* bytes 22..23 are reserved and remain zero */
|
||||
memcpy(&out[24], telemetry->ephemeral_id, RV_BLE_EPHEMERAL_ID_SIZE);
|
||||
write_le32(&out[32], telemetry->token_epoch_min);
|
||||
return true;
|
||||
}
|
||||
91
firmware/esp32-csi-node/main/ble_identity_protocol.h
Normal file
91
firmware/esp32-csi-node/main/ble_identity_protocol.h
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file ble_identity_protocol.h
|
||||
* @brief Pure-C RuView BLE identity-token and telemetry wire contracts.
|
||||
*
|
||||
* This module deliberately contains no ESP-IDF dependency so the untrusted
|
||||
* advertising-data boundary can be exercised by host unit tests. It parses
|
||||
* only RuView's vendor 128-bit service-data record. It never exports a BLE
|
||||
* address or general advertising payload.
|
||||
*/
|
||||
|
||||
#ifndef BLE_IDENTITY_PROTOCOL_H
|
||||
#define BLE_IDENTITY_PROTOCOL_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define RV_BLE_AD_TYPE_SERVICE_DATA_UUID128 0x21u
|
||||
#define RV_BLE_TOKEN_VERSION 1u
|
||||
#define RV_BLE_TOKEN_BODY_SIZE 34u
|
||||
#define RV_BLE_SERVICE_DATA_SIZE 50u
|
||||
#define RV_BLE_EPHEMERAL_ID_SIZE 8u
|
||||
#define RV_BLE_AUTH_TAG_SIZE 16u
|
||||
|
||||
#define RV_BLE_TELEMETRY_MAGIC 0xC51100B1u
|
||||
#define RV_BLE_TELEMETRY_VERSION 1u
|
||||
#define RV_BLE_TELEMETRY_SIZE 36u
|
||||
|
||||
#define RV_BLE_FLAG_AUTHENTICATED (1u << 0)
|
||||
#define RV_BLE_FLAG_TIME_VERIFIED (1u << 1)
|
||||
#define RV_BLE_FLAG_EXTENDED_ADVERT (1u << 2)
|
||||
#define RV_BLE_FLAGS_ALLOWED (RV_BLE_FLAG_AUTHENTICATED | \
|
||||
RV_BLE_FLAG_TIME_VERIFIED | \
|
||||
RV_BLE_FLAG_EXTENDED_ADVERT)
|
||||
|
||||
/** Raw little-endian UUID bytes as they appear in BLE service data. */
|
||||
extern const uint8_t RV_BLE_SERVICE_UUID_LE[16];
|
||||
|
||||
/** Authenticated, rotating token extracted from RuView service data. */
|
||||
typedef struct {
|
||||
uint8_t version;
|
||||
uint8_t key_id;
|
||||
uint32_t epoch_min;
|
||||
uint32_t nonce;
|
||||
uint8_t ephemeral_id[RV_BLE_EPHEMERAL_ID_SIZE];
|
||||
uint8_t auth_tag[RV_BLE_AUTH_TAG_SIZE];
|
||||
} rv_ble_token_t;
|
||||
|
||||
/** Privacy-minimized observation forwarded to the RuView host. */
|
||||
typedef struct {
|
||||
uint8_t node_id;
|
||||
uint8_t flags;
|
||||
uint8_t key_id;
|
||||
uint32_t sequence;
|
||||
uint32_t observed_at_ms;
|
||||
uint16_t ttl_ms;
|
||||
uint16_t confidence_permille;
|
||||
int8_t rssi_dbm;
|
||||
int8_t tx_power_dbm;
|
||||
uint8_t ephemeral_id[RV_BLE_EPHEMERAL_ID_SIZE];
|
||||
uint32_t token_epoch_min;
|
||||
} rv_ble_telemetry_t;
|
||||
|
||||
/**
|
||||
* Find and parse the RuView 128-bit service-data record from one advertising
|
||||
* report. Unknown AD elements are skipped. Truncation, duplicate RuView
|
||||
* elements, wrong UUIDs, or unsupported token versions fail closed.
|
||||
*/
|
||||
bool rv_ble_parse_advertisement(const uint8_t *advertisement,
|
||||
size_t advertisement_len,
|
||||
rv_ble_token_t *out);
|
||||
|
||||
/**
|
||||
* Return the bytes covered by the token HMAC.
|
||||
*
|
||||
* The covered bytes are the raw 16-byte UUID followed by token version,
|
||||
* key-id, epoch, nonce and ephemeral id. The authentication tag itself is
|
||||
* excluded. The caller supplies a 34-byte output buffer.
|
||||
*/
|
||||
void rv_ble_token_mac_input(const rv_ble_token_t *token, uint8_t out[34]);
|
||||
|
||||
/** Constant-time comparison for the truncated authentication tag. */
|
||||
bool rv_ble_auth_tag_equal(const uint8_t lhs[RV_BLE_AUTH_TAG_SIZE],
|
||||
const uint8_t rhs[RV_BLE_AUTH_TAG_SIZE]);
|
||||
|
||||
/** Serialize one telemetry packet in the fixed little-endian wire format. */
|
||||
bool rv_ble_serialize_telemetry(const rv_ble_telemetry_t *telemetry,
|
||||
uint8_t *out,
|
||||
size_t out_len);
|
||||
|
||||
#endif /* BLE_IDENTITY_PROTOCOL_H */
|
||||
273
firmware/esp32-csi-node/main/channel_sounding_ingress.c
Normal file
273
firmware/esp32-csi-node/main/channel_sounding_ingress.c
Normal file
@@ -0,0 +1,273 @@
|
||||
/** @file channel_sounding_ingress.c */
|
||||
|
||||
#include "channel_sounding_ingress.h"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE)
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/uart.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "channel_sounding_protocol.h"
|
||||
#include "mbedtls/md.h"
|
||||
#include "nvs_config.h"
|
||||
#include "radio_gateway_protocol.h"
|
||||
#include "radio_gateway_sender.h"
|
||||
|
||||
extern nvs_config_t g_nvs_config;
|
||||
|
||||
static const char *TAG = "cs_ingress";
|
||||
|
||||
typedef struct {
|
||||
uint32_t source_id;
|
||||
uint32_t source_session_id;
|
||||
uint32_t sequence;
|
||||
uint64_t last_seen_us;
|
||||
bool initialized;
|
||||
} source_sequence_t;
|
||||
|
||||
#define SOURCE_SEQUENCE_SLOTS 8u
|
||||
static source_sequence_t s_sequences[SOURCE_SEQUENCE_SLOTS];
|
||||
static uint64_t s_rate_window_start_us;
|
||||
static uint32_t s_rate_window_count;
|
||||
static uint32_t s_rate_drops;
|
||||
static uint32_t s_invalid_drops;
|
||||
static uint32_t s_auth_drops;
|
||||
static uint32_t s_replay_drops;
|
||||
static uint32_t s_queue_drops;
|
||||
|
||||
static bool authenticate_frame(const uint8_t frame[RV_CS_FRAME_SIZE])
|
||||
{
|
||||
if (!g_nvs_config.cs_secret_valid || frame[6] != g_nvs_config.cs_key_id) return false;
|
||||
uint8_t covered[RV_CS_MAC_INPUT_SIZE];
|
||||
uint8_t digest[32];
|
||||
rv_cs_mac_input(frame, covered);
|
||||
const mbedtls_md_info_t *sha256 = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
if (sha256 == NULL
|
||||
|| mbedtls_md_hmac(sha256, g_nvs_config.cs_secret,
|
||||
sizeof(g_nvs_config.cs_secret), covered,
|
||||
sizeof(covered), digest) != 0) return false;
|
||||
return rv_cs_auth_tag_equal(&frame[RV_CS_SIGNED_PREFIX_SIZE], digest);
|
||||
}
|
||||
|
||||
static bool accept_sequence(uint32_t source_id, uint32_t source_session_id,
|
||||
uint32_t sequence, uint64_t received_at_us)
|
||||
{
|
||||
size_t empty = SOURCE_SEQUENCE_SLOTS;
|
||||
size_t oldest = 0u;
|
||||
for (size_t i = 0u; i < SOURCE_SEQUENCE_SLOTS; i++) {
|
||||
if (s_sequences[i].initialized
|
||||
&& s_sequences[i].source_id == source_id
|
||||
&& s_sequences[i].source_session_id == source_session_id) {
|
||||
if (!rv_cs_sequence_is_newer(sequence, s_sequences[i].sequence)) {
|
||||
return false;
|
||||
}
|
||||
s_sequences[i].sequence = sequence;
|
||||
s_sequences[i].last_seen_us = received_at_us;
|
||||
return true;
|
||||
}
|
||||
if (!s_sequences[i].initialized && empty == SOURCE_SEQUENCE_SLOTS) empty = i;
|
||||
if (s_sequences[i].initialized
|
||||
&& s_sequences[i].last_seen_us < s_sequences[oldest].last_seen_us) {
|
||||
oldest = i;
|
||||
}
|
||||
}
|
||||
if (empty == SOURCE_SEQUENCE_SLOTS) {
|
||||
uint64_t retire_us =
|
||||
(uint64_t)CONFIG_CHANNEL_SOUNDING_SESSION_RETIRE_MS * 1000u;
|
||||
if (sequence != 1u
|
||||
|| received_at_us < s_sequences[oldest].last_seen_us
|
||||
|| received_at_us - s_sequences[oldest].last_seen_us < retire_us) {
|
||||
return false;
|
||||
}
|
||||
empty = oldest;
|
||||
}
|
||||
s_sequences[empty].source_id = source_id;
|
||||
s_sequences[empty].source_session_id = source_session_id;
|
||||
s_sequences[empty].sequence = sequence;
|
||||
s_sequences[empty].last_seen_us = received_at_us;
|
||||
s_sequences[empty].initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool admit_frame(uint64_t received_at_us)
|
||||
{
|
||||
if (s_rate_window_start_us == 0u
|
||||
|| received_at_us - s_rate_window_start_us >= 1000000u) {
|
||||
s_rate_window_start_us = received_at_us;
|
||||
s_rate_window_count = 0u;
|
||||
}
|
||||
if (s_rate_window_count >= CONFIG_CHANNEL_SOUNDING_MAX_FRAMES_PER_SEC) {
|
||||
s_rate_drops++;
|
||||
if ((s_rate_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "companion admission rate limited (drops=%lu)",
|
||||
(unsigned long)s_rate_drops);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
s_rate_window_count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void process_frame(const uint8_t frame[RV_CS_FRAME_SIZE],
|
||||
uint64_t received_at_us)
|
||||
{
|
||||
rv_cs_measurement_t measurement;
|
||||
rv_cs_parse_result_t result = rv_cs_parse_frame(
|
||||
frame, RV_CS_FRAME_SIZE,
|
||||
(uint32_t)CONFIG_CHANNEL_SOUNDING_MAX_AGE_MS * 1000u,
|
||||
(uint16_t)CONFIG_CHANNEL_SOUNDING_MIN_QUALITY_PERMILLE,
|
||||
&measurement);
|
||||
if (result != RV_CS_PARSE_OK
|
||||
|| measurement.source_id != g_nvs_config.cs_enrolled_source_id) {
|
||||
s_invalid_drops++;
|
||||
if ((s_invalid_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "invalid or unenrolled companion frame (reason=%d drops=%lu)",
|
||||
(int)result, (unsigned long)s_invalid_drops);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!admit_frame(received_at_us)) return;
|
||||
if (!authenticate_frame(frame)) {
|
||||
s_auth_drops++;
|
||||
if ((s_auth_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "unauthenticated companion frame (drops=%lu)",
|
||||
(unsigned long)s_auth_drops);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!accept_sequence(measurement.source_id,
|
||||
measurement.source_session_id,
|
||||
measurement.sequence, received_at_us)) {
|
||||
s_replay_drops++;
|
||||
if ((s_replay_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "companion replay or state capacity drop (drops=%lu)",
|
||||
(unsigned long)s_replay_drops);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t uart_uncertainty_us =
|
||||
(uint32_t)((RV_CS_FRAME_SIZE * 10u * 1000000u
|
||||
+ CONFIG_CHANNEL_SOUNDING_UART_BAUD - 1u)
|
||||
/ CONFIG_CHANNEL_SOUNDING_UART_BAUD);
|
||||
uint32_t uncertainty_us =
|
||||
(uint32_t)measurement.timing_uncertainty_us + uart_uncertainty_us;
|
||||
esp_err_t rc = radio_gateway_sender_enqueue(
|
||||
RV_GATEWAY_PAYLOAD_CHANNEL_SOUNDING, frame, RV_CS_FRAME_SIZE,
|
||||
received_at_us, uncertainty_us);
|
||||
if (rc != ESP_OK) {
|
||||
s_queue_drops++;
|
||||
if ((s_queue_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "Channel Sounding gateway queue drop (drops=%lu)",
|
||||
(unsigned long)s_queue_drops);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void uart_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
static const uint8_t magic[4] = { 'R', 'V', 'C', 'S' };
|
||||
uint8_t frame[RV_CS_FRAME_SIZE];
|
||||
uint8_t chunk[128];
|
||||
size_t used = 0u;
|
||||
|
||||
for (;;) {
|
||||
int count = uart_read_bytes(CONFIG_CHANNEL_SOUNDING_UART_NUM,
|
||||
chunk, sizeof(chunk), pdMS_TO_TICKS(100));
|
||||
if (count <= 0) continue;
|
||||
for (int index = 0; index < count; index++) {
|
||||
uint8_t byte = chunk[index];
|
||||
if (used < sizeof(magic)) {
|
||||
if (byte == magic[used]) {
|
||||
frame[used++] = byte;
|
||||
} else {
|
||||
used = byte == magic[0] ? 1u : 0u;
|
||||
if (used == 1u) frame[0] = byte;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
frame[used++] = byte;
|
||||
if (used == sizeof(frame)) {
|
||||
process_frame(frame, (uint64_t)esp_timer_get_time());
|
||||
used = 0u;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t channel_sounding_ingress_init(void)
|
||||
{
|
||||
if (!g_nvs_config.cs_ingress_enabled) {
|
||||
ESP_LOGI(TAG, "disabled by NVS (cs_enable=0)");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (!g_nvs_config.cs_secret_valid) {
|
||||
ESP_LOGE(TAG, "enabled without a 32-byte cs_secret; refusing UART ingress");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (g_nvs_config.cs_enrolled_source_id == 0u) {
|
||||
ESP_LOGE(TAG, "enabled without a nonzero enrolled cs_source_id");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (!radio_gateway_sender_is_ready()) {
|
||||
ESP_LOGE(TAG, "authenticated gateway envelope is unavailable");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (CONFIG_CHANNEL_SOUNDING_UART_NUM == UART_NUM_0) {
|
||||
ESP_LOGE(TAG, "UART0 is reserved for console and provisioning");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(s_sequences, 0, sizeof(s_sequences));
|
||||
uart_config_t config = {
|
||||
.baud_rate = CONFIG_CHANNEL_SOUNDING_UART_BAUD,
|
||||
.data_bits = UART_DATA_8_BITS,
|
||||
.parity = UART_PARITY_DISABLE,
|
||||
.stop_bits = UART_STOP_BITS_1,
|
||||
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
|
||||
.source_clk = UART_SCLK_DEFAULT,
|
||||
};
|
||||
esp_err_t rc = uart_driver_install(CONFIG_CHANNEL_SOUNDING_UART_NUM,
|
||||
RV_CS_FRAME_SIZE * 8u, 0, 0, NULL, 0);
|
||||
if (rc != ESP_OK) return rc;
|
||||
rc = uart_param_config(CONFIG_CHANNEL_SOUNDING_UART_NUM, &config);
|
||||
if (rc != ESP_OK) {
|
||||
uart_driver_delete(CONFIG_CHANNEL_SOUNDING_UART_NUM);
|
||||
return rc;
|
||||
}
|
||||
rc = uart_set_pin(CONFIG_CHANNEL_SOUNDING_UART_NUM,
|
||||
CONFIG_CHANNEL_SOUNDING_UART_TX_GPIO,
|
||||
CONFIG_CHANNEL_SOUNDING_UART_RX_GPIO,
|
||||
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
|
||||
if (rc != ESP_OK) {
|
||||
uart_driver_delete(CONFIG_CHANNEL_SOUNDING_UART_NUM);
|
||||
return rc;
|
||||
}
|
||||
|
||||
BaseType_t task = xTaskCreate(uart_task, "cs_uart", 4096,
|
||||
NULL, 5, NULL);
|
||||
if (task != pdPASS) {
|
||||
uart_driver_delete(CONFIG_CHANNEL_SOUNDING_UART_NUM);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
ESP_LOGI(TAG, "external Channel Sounding ingress active on UART%d; data is unvalidated by hardware evidence",
|
||||
CONFIG_CHANNEL_SOUNDING_UART_NUM);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
esp_err_t channel_sounding_ingress_init(void)
|
||||
{
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
#endif /* CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE */
|
||||
11
firmware/esp32-csi-node/main/channel_sounding_ingress.h
Normal file
11
firmware/esp32-csi-node/main/channel_sounding_ingress.h
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @file channel_sounding_ingress.h */
|
||||
|
||||
#ifndef CHANNEL_SOUNDING_INGRESS_H
|
||||
#define CHANNEL_SOUNDING_INGRESS_H
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/** Start the optional external Channel Sounding UART ingress. */
|
||||
esp_err_t channel_sounding_ingress_init(void);
|
||||
|
||||
#endif /* CHANNEL_SOUNDING_INGRESS_H */
|
||||
124
firmware/esp32-csi-node/main/channel_sounding_protocol.c
Normal file
124
firmware/esp32-csi-node/main/channel_sounding_protocol.c
Normal file
@@ -0,0 +1,124 @@
|
||||
/** @file channel_sounding_protocol.c */
|
||||
|
||||
#include "channel_sounding_protocol.h"
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include "mbedtls/constant_time.h"
|
||||
#endif
|
||||
|
||||
static uint16_t read_le16(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
|
||||
}
|
||||
|
||||
static uint32_t read_le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0]
|
||||
| ((uint32_t)p[1] << 8)
|
||||
| ((uint32_t)p[2] << 16)
|
||||
| ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
uint32_t rv_cs_crc32(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (data == NULL) return 0u;
|
||||
uint32_t crc = 0xffffffffu;
|
||||
for (size_t i = 0u; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (unsigned bit = 0u; bit < 8u; bit++) {
|
||||
uint32_t mask = (uint32_t)-(int32_t)(crc & 1u);
|
||||
crc = (crc >> 1) ^ (0xedb88320u & mask);
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
bool rv_cs_sequence_is_newer(uint32_t candidate, uint32_t previous)
|
||||
{
|
||||
uint32_t delta = candidate - previous;
|
||||
return delta != 0u && delta < 0x80000000u;
|
||||
}
|
||||
|
||||
rv_cs_parse_result_t rv_cs_parse_frame(const uint8_t *data,
|
||||
size_t len,
|
||||
uint32_t max_age_us,
|
||||
uint16_t min_quality_permille,
|
||||
rv_cs_measurement_t *out)
|
||||
{
|
||||
if (data == NULL || out == NULL) return RV_CS_PARSE_BAD_ARGUMENT;
|
||||
if (len != RV_CS_FRAME_SIZE) return RV_CS_PARSE_BAD_LENGTH;
|
||||
if (read_le32(&data[0]) != RV_CS_MAGIC) return RV_CS_PARSE_BAD_MAGIC;
|
||||
if (data[4] != RV_CS_VERSION) return RV_CS_PARSE_BAD_VERSION;
|
||||
if (data[7] != 0u || read_le16(&data[8]) != RV_CS_FRAME_SIZE) return RV_CS_PARSE_BAD_LENGTH;
|
||||
if ((data[5] & ~RV_CS_FLAGS_ALLOWED) != 0u) return RV_CS_PARSE_BAD_FLAGS;
|
||||
if (rv_cs_crc32(data, RV_CS_FRAME_SIZE - 4u)
|
||||
!= read_le32(&data[RV_CS_FRAME_SIZE - 4u])) {
|
||||
return RV_CS_PARSE_BAD_CRC;
|
||||
}
|
||||
|
||||
rv_cs_measurement_t parsed = {
|
||||
.flags = data[5],
|
||||
.key_id = data[6],
|
||||
.channel_index = read_le16(&data[10]),
|
||||
.sequence = read_le32(&data[12]),
|
||||
.sample_age_us = read_le32(&data[16]),
|
||||
.source_id = read_le32(&data[20]),
|
||||
.quality_permille = read_le16(&data[24]),
|
||||
.timing_uncertainty_us = read_le16(&data[26]),
|
||||
.phase_millirad = (int32_t)read_le32(&data[28]),
|
||||
.rtt_picoseconds = (int32_t)read_le32(&data[32]),
|
||||
.frequency_offset_hz = (int32_t)read_le32(&data[36]),
|
||||
.source_session_id = read_le32(&data[40]),
|
||||
.procedure_id = read_le32(&data[44]),
|
||||
.step_index = read_le16(&data[48]),
|
||||
.step_count = read_le16(&data[50]),
|
||||
};
|
||||
|
||||
if (parsed.source_id == 0u) return RV_CS_PARSE_BAD_SOURCE;
|
||||
if (parsed.source_session_id == 0u) return RV_CS_PARSE_BAD_SESSION;
|
||||
if (parsed.procedure_id == 0u) return RV_CS_PARSE_BAD_PROCEDURE;
|
||||
if (parsed.step_count < RV_CS_MIN_STEP_COUNT
|
||||
|| parsed.step_count > RV_CS_MAX_STEP_COUNT
|
||||
|| parsed.step_index >= parsed.step_count) return RV_CS_PARSE_BAD_STEP;
|
||||
if (parsed.channel_index > RV_CS_MAX_CHANNEL_INDEX) return RV_CS_PARSE_BAD_CHANNEL;
|
||||
if (parsed.quality_permille < min_quality_permille
|
||||
|| parsed.quality_permille > 1000u) return RV_CS_PARSE_BAD_QUALITY;
|
||||
if (parsed.phase_millirad < -RV_CS_MAX_PHASE_MRAD
|
||||
|| parsed.phase_millirad > RV_CS_MAX_PHASE_MRAD) return RV_CS_PARSE_BAD_PHASE;
|
||||
if (parsed.rtt_picoseconds < 0
|
||||
|| parsed.rtt_picoseconds > RV_CS_MAX_RTT_PS) return RV_CS_PARSE_BAD_RTT;
|
||||
if (parsed.frequency_offset_hz < -RV_CS_MAX_FREQ_OFFSET_HZ
|
||||
|| parsed.frequency_offset_hz > RV_CS_MAX_FREQ_OFFSET_HZ) {
|
||||
return RV_CS_PARSE_BAD_FREQUENCY_OFFSET;
|
||||
}
|
||||
if (parsed.timing_uncertainty_us > 10000u) return RV_CS_PARSE_BAD_TIMING_UNCERTAINTY;
|
||||
if (parsed.sample_age_us > max_age_us) return RV_CS_PARSE_STALE;
|
||||
*out = parsed;
|
||||
return RV_CS_PARSE_OK;
|
||||
}
|
||||
|
||||
void rv_cs_mac_input(const uint8_t frame[RV_CS_FRAME_SIZE],
|
||||
uint8_t out[RV_CS_MAC_INPUT_SIZE])
|
||||
{
|
||||
static const uint8_t domain[RV_CS_MAC_DOMAIN_SIZE] = {
|
||||
'R', 'u', 'V', 'i', 'e', 'w', '/', 'C', 'S', '/', 'v', '1'
|
||||
};
|
||||
if (frame == NULL || out == NULL) return;
|
||||
for (size_t i = 0u; i < sizeof(domain); i++) out[i] = domain[i];
|
||||
for (size_t i = 0u; i < RV_CS_SIGNED_PREFIX_SIZE; i++) {
|
||||
out[sizeof(domain) + i] = frame[i];
|
||||
}
|
||||
}
|
||||
|
||||
bool rv_cs_auth_tag_equal(const uint8_t lhs[RV_CS_AUTH_TAG_SIZE],
|
||||
const uint8_t rhs[RV_CS_AUTH_TAG_SIZE])
|
||||
{
|
||||
if (lhs == NULL || rhs == NULL) return false;
|
||||
#ifdef ESP_PLATFORM
|
||||
return mbedtls_ct_memcmp(lhs, rhs, RV_CS_AUTH_TAG_SIZE) == 0;
|
||||
#else
|
||||
uint8_t diff = 0u;
|
||||
for (size_t i = 0u; i < RV_CS_AUTH_TAG_SIZE; i++) diff |= (uint8_t)(lhs[i] ^ rhs[i]);
|
||||
return diff == 0u;
|
||||
#endif
|
||||
}
|
||||
105
firmware/esp32-csi-node/main/channel_sounding_protocol.h
Normal file
105
firmware/esp32-csi-node/main/channel_sounding_protocol.h
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file channel_sounding_protocol.h
|
||||
* @brief Versioned UART contract for an external Bluetooth Channel Sounding radio.
|
||||
*
|
||||
* ESP32-S3 cannot acquire Bluetooth 6 Channel Sounding phase or RTT. A radio
|
||||
* that can do so may send calibrated primitives over this bounded frame. The
|
||||
* ESP32 validates and forwards primitives only; it does not label them as a
|
||||
* respiration or heartbeat result.
|
||||
*/
|
||||
|
||||
#ifndef CHANNEL_SOUNDING_PROTOCOL_H
|
||||
#define CHANNEL_SOUNDING_PROTOCOL_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define RV_CS_MAGIC 0x53435652u /* "RVCS" little endian */
|
||||
#define RV_CS_VERSION 1u
|
||||
#define RV_CS_FRAME_SIZE 72u
|
||||
#define RV_CS_AUTH_TAG_SIZE 16u
|
||||
#define RV_CS_SIGNED_PREFIX_SIZE 52u
|
||||
#define RV_CS_MAC_DOMAIN_SIZE 12u
|
||||
#define RV_CS_MAC_INPUT_SIZE (RV_CS_MAC_DOMAIN_SIZE + RV_CS_SIGNED_PREFIX_SIZE)
|
||||
#define RV_CS_MAX_CHANNEL_INDEX 78u
|
||||
#define RV_CS_MAX_PHASE_MRAD 3142
|
||||
#define RV_CS_MAX_RTT_PS 250000
|
||||
#define RV_CS_MAX_FREQ_OFFSET_HZ 500000
|
||||
#define RV_CS_MIN_STEP_COUNT 4u
|
||||
#define RV_CS_MAX_STEP_COUNT 79u
|
||||
|
||||
#define RV_CS_FLAG_CALIBRATED (1u << 0)
|
||||
#define RV_CS_FLAG_MOTION (1u << 1)
|
||||
#define RV_CS_FLAGS_ALLOWED (RV_CS_FLAG_CALIBRATED | RV_CS_FLAG_MOTION)
|
||||
|
||||
typedef struct {
|
||||
uint8_t flags;
|
||||
uint8_t key_id;
|
||||
uint32_t sequence;
|
||||
uint32_t sample_age_us;
|
||||
uint32_t source_id;
|
||||
uint32_t source_session_id;
|
||||
uint32_t procedure_id;
|
||||
uint16_t channel_index;
|
||||
uint16_t step_index;
|
||||
uint16_t step_count;
|
||||
uint16_t quality_permille;
|
||||
uint16_t timing_uncertainty_us;
|
||||
int32_t phase_millirad;
|
||||
int32_t rtt_picoseconds;
|
||||
int32_t frequency_offset_hz;
|
||||
} rv_cs_measurement_t;
|
||||
|
||||
typedef enum {
|
||||
RV_CS_PARSE_OK = 0,
|
||||
RV_CS_PARSE_BAD_ARGUMENT,
|
||||
RV_CS_PARSE_BAD_MAGIC,
|
||||
RV_CS_PARSE_BAD_VERSION,
|
||||
RV_CS_PARSE_BAD_LENGTH,
|
||||
RV_CS_PARSE_BAD_FLAGS,
|
||||
RV_CS_PARSE_BAD_CRC,
|
||||
RV_CS_PARSE_BAD_SOURCE,
|
||||
RV_CS_PARSE_BAD_SESSION,
|
||||
RV_CS_PARSE_BAD_PROCEDURE,
|
||||
RV_CS_PARSE_BAD_STEP,
|
||||
RV_CS_PARSE_BAD_CHANNEL,
|
||||
RV_CS_PARSE_BAD_QUALITY,
|
||||
RV_CS_PARSE_BAD_PHASE,
|
||||
RV_CS_PARSE_BAD_RTT,
|
||||
RV_CS_PARSE_BAD_FREQUENCY_OFFSET,
|
||||
RV_CS_PARSE_BAD_TIMING_UNCERTAINTY,
|
||||
RV_CS_PARSE_STALE,
|
||||
} rv_cs_parse_result_t;
|
||||
|
||||
uint32_t rv_cs_crc32(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* Parse and validate a fixed frame. The companion supplies bounded sample age,
|
||||
* not its unrelated monotonic timestamp. The gateway receive timestamp is
|
||||
* assigned after authentication by the caller.
|
||||
*
|
||||
* @param data frame bytes
|
||||
* @param len must equal RV_CS_FRAME_SIZE
|
||||
* @param max_age_us maximum accepted age
|
||||
* @param min_quality_permille minimum admitted quality
|
||||
* @param out validated primitive
|
||||
*/
|
||||
rv_cs_parse_result_t rv_cs_parse_frame(const uint8_t *data,
|
||||
size_t len,
|
||||
uint32_t max_age_us,
|
||||
uint16_t min_quality_permille,
|
||||
rv_cs_measurement_t *out);
|
||||
|
||||
/** Build domain-separated HMAC input from the signed 52-byte prefix. */
|
||||
void rv_cs_mac_input(const uint8_t frame[RV_CS_FRAME_SIZE],
|
||||
uint8_t out[RV_CS_MAC_INPUT_SIZE]);
|
||||
|
||||
/** Constant-time comparison for the 128-bit companion authentication tag. */
|
||||
bool rv_cs_auth_tag_equal(const uint8_t lhs[RV_CS_AUTH_TAG_SIZE],
|
||||
const uint8_t rhs[RV_CS_AUTH_TAG_SIZE]);
|
||||
|
||||
/** True when candidate is strictly newer under uint32 wrap semantics. */
|
||||
bool rv_cs_sequence_is_newer(uint32_t candidate, uint32_t previous);
|
||||
|
||||
#endif /* CHANNEL_SOUNDING_PROTOCOL_H */
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "nvs_config.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "nvs_flash.h"
|
||||
@@ -16,6 +17,13 @@
|
||||
|
||||
static const char *TAG = "nvs_config";
|
||||
|
||||
static bool secret_has_nonzero_byte(const uint8_t *secret, size_t len)
|
||||
{
|
||||
uint8_t combined = 0u;
|
||||
for (size_t index = 0u; index < len; index++) combined |= secret[index];
|
||||
return combined != 0u;
|
||||
}
|
||||
|
||||
void nvs_config_load(nvs_config_t *cfg)
|
||||
{
|
||||
if (cfg == NULL) {
|
||||
@@ -94,6 +102,21 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
cfg->filter_mac_set = 0;
|
||||
memset(cfg->filter_mac, 0, 6);
|
||||
|
||||
/* ADR-341: BLE identity is runtime-off even in an enabled build. */
|
||||
cfg->ble_identity_enabled = 0;
|
||||
cfg->ble_key_id = 0;
|
||||
cfg->ble_secret_valid = 0;
|
||||
memset(cfg->ble_secret, 0, sizeof(cfg->ble_secret));
|
||||
cfg->cs_ingress_enabled = 0;
|
||||
cfg->cs_key_id = 0;
|
||||
cfg->cs_secret_valid = 0;
|
||||
memset(cfg->cs_secret, 0, sizeof(cfg->cs_secret));
|
||||
cfg->cs_enrolled_source_id = 0u;
|
||||
cfg->radio_envelope_key_id = 0u;
|
||||
cfg->radio_envelope_secret_valid = 0u;
|
||||
memset(cfg->radio_envelope_secret, 0,
|
||||
sizeof(cfg->radio_envelope_secret));
|
||||
|
||||
/* Try to override from NVS */
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open("csi_cfg", NVS_READONLY, &handle);
|
||||
@@ -317,6 +340,81 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
cfg->swarm_ingest_sec = 5;
|
||||
}
|
||||
|
||||
/* ADR-341: BLE identity anchor. The secret is never printed. */
|
||||
uint8_t ble_enable_val;
|
||||
if (nvs_get_u8(handle, "ble_enable", &ble_enable_val) == ESP_OK) {
|
||||
cfg->ble_identity_enabled = ble_enable_val ? 1u : 0u;
|
||||
ESP_LOGI(TAG, "NVS override: ble_identity_enabled=%u",
|
||||
(unsigned)cfg->ble_identity_enabled);
|
||||
}
|
||||
if (nvs_get_u8(handle, "ble_key_id", &cfg->ble_key_id) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "NVS override: ble_key_id=%u", (unsigned)cfg->ble_key_id);
|
||||
}
|
||||
size_t ble_secret_len = sizeof(cfg->ble_secret);
|
||||
if (nvs_get_blob(handle, "ble_secret", cfg->ble_secret, &ble_secret_len) == ESP_OK
|
||||
&& ble_secret_len == sizeof(cfg->ble_secret)
|
||||
&& secret_has_nonzero_byte(cfg->ble_secret, sizeof(cfg->ble_secret))) {
|
||||
cfg->ble_secret_valid = 1u;
|
||||
ESP_LOGI(TAG, "NVS: ble_secret loaded (32 bytes)");
|
||||
} else if (cfg->ble_identity_enabled) {
|
||||
ESP_LOGW(TAG, "ble_enable=1 but exact 32-byte ble_secret is absent; scanner will fail closed");
|
||||
}
|
||||
|
||||
uint8_t cs_enable_val;
|
||||
if (nvs_get_u8(handle, "cs_enable", &cs_enable_val) == ESP_OK) {
|
||||
cfg->cs_ingress_enabled = cs_enable_val ? 1u : 0u;
|
||||
ESP_LOGI(TAG, "NVS override: cs_ingress_enabled=%u",
|
||||
(unsigned)cfg->cs_ingress_enabled);
|
||||
}
|
||||
if (nvs_get_u8(handle, "cs_key_id", &cfg->cs_key_id) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "NVS override: cs_key_id=%u", (unsigned)cfg->cs_key_id);
|
||||
}
|
||||
size_t cs_secret_len = sizeof(cfg->cs_secret);
|
||||
if (nvs_get_blob(handle, "cs_secret", cfg->cs_secret, &cs_secret_len) == ESP_OK
|
||||
&& cs_secret_len == sizeof(cfg->cs_secret)
|
||||
&& secret_has_nonzero_byte(cfg->cs_secret, sizeof(cfg->cs_secret))) {
|
||||
cfg->cs_secret_valid = 1u;
|
||||
ESP_LOGI(TAG, "NVS: cs_secret loaded (32 bytes)");
|
||||
} else if (cfg->cs_ingress_enabled) {
|
||||
ESP_LOGW(TAG, "cs_enable=1 but exact 32-byte cs_secret is absent; ingress will fail closed");
|
||||
}
|
||||
if (nvs_get_u32(handle, "cs_source_id", &cfg->cs_enrolled_source_id) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "NVS: enrolled Channel Sounding source id loaded");
|
||||
}
|
||||
|
||||
if (nvs_get_u8(handle, "radio_key_id", &cfg->radio_envelope_key_id) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "NVS override: radio envelope key id=%u",
|
||||
(unsigned)cfg->radio_envelope_key_id);
|
||||
}
|
||||
size_t radio_secret_len = sizeof(cfg->radio_envelope_secret);
|
||||
if (nvs_get_blob(handle, "radio_secret", cfg->radio_envelope_secret,
|
||||
&radio_secret_len) == ESP_OK
|
||||
&& radio_secret_len == sizeof(cfg->radio_envelope_secret)
|
||||
&& secret_has_nonzero_byte(cfg->radio_envelope_secret,
|
||||
sizeof(cfg->radio_envelope_secret))) {
|
||||
cfg->radio_envelope_secret_valid = 1u;
|
||||
ESP_LOGI(TAG, "NVS: radio envelope secret loaded (32 bytes)");
|
||||
} else if (cfg->ble_identity_enabled || cfg->cs_ingress_enabled) {
|
||||
ESP_LOGW(TAG, "radio evidence enabled but gateway envelope key is absent; both paths fail closed");
|
||||
}
|
||||
|
||||
bool reused_radio_key =
|
||||
(cfg->ble_secret_valid && cfg->cs_secret_valid
|
||||
&& memcmp(cfg->ble_secret, cfg->cs_secret,
|
||||
sizeof(cfg->ble_secret)) == 0)
|
||||
|| (cfg->ble_secret_valid && cfg->radio_envelope_secret_valid
|
||||
&& memcmp(cfg->ble_secret, cfg->radio_envelope_secret,
|
||||
sizeof(cfg->ble_secret)) == 0)
|
||||
|| (cfg->cs_secret_valid && cfg->radio_envelope_secret_valid
|
||||
&& memcmp(cfg->cs_secret, cfg->radio_envelope_secret,
|
||||
sizeof(cfg->cs_secret)) == 0);
|
||||
if (reused_radio_key) {
|
||||
ESP_LOGE(TAG, "BLE, Channel Sounding and gateway envelope keys must be distinct");
|
||||
cfg->ble_secret_valid = 0u;
|
||||
cfg->cs_secret_valid = 0u;
|
||||
cfg->radio_envelope_secret_valid = 0u;
|
||||
}
|
||||
|
||||
/* Validate tdm_slot_index < tdm_node_count */
|
||||
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
|
||||
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
104
firmware/esp32-csi-node/main/radio_gateway_protocol.c
Normal file
104
firmware/esp32-csi-node/main/radio_gateway_protocol.c
Normal file
@@ -0,0 +1,104 @@
|
||||
/** @file radio_gateway_protocol.c */
|
||||
|
||||
#include "radio_gateway_protocol.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include "mbedtls/constant_time.h"
|
||||
#endif
|
||||
|
||||
const uint8_t RV_GATEWAY_MAC_DOMAIN[RV_GATEWAY_MAC_DOMAIN_SIZE] = {
|
||||
'R', 'u', 'V', 'i', 'e', 'w', '/', 'G', 'W', '/', 'v', '1'
|
||||
};
|
||||
|
||||
static void write_le16(uint8_t *p, uint16_t value)
|
||||
{
|
||||
p[0] = (uint8_t)value;
|
||||
p[1] = (uint8_t)(value >> 8);
|
||||
}
|
||||
|
||||
static void write_le32(uint8_t *p, uint32_t value)
|
||||
{
|
||||
p[0] = (uint8_t)value;
|
||||
p[1] = (uint8_t)(value >> 8);
|
||||
p[2] = (uint8_t)(value >> 16);
|
||||
p[3] = (uint8_t)(value >> 24);
|
||||
}
|
||||
|
||||
static void write_le64(uint8_t *p, uint64_t value)
|
||||
{
|
||||
for (unsigned i = 0u; i < 8u; i++) {
|
||||
p[i] = (uint8_t)(value >> (8u * i));
|
||||
}
|
||||
}
|
||||
|
||||
static bool payload_size_is_valid(uint8_t payload_type, size_t payload_len)
|
||||
{
|
||||
if (payload_type == RV_GATEWAY_PAYLOAD_BLE_IDENTITY) {
|
||||
return payload_len == 36u;
|
||||
}
|
||||
if (payload_type == RV_GATEWAY_PAYLOAD_CHANNEL_SOUNDING) {
|
||||
return payload_len == 72u;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool rv_gateway_build_unsigned(const rv_gateway_metadata_t *metadata,
|
||||
const uint8_t *payload,
|
||||
size_t payload_len,
|
||||
uint8_t *out,
|
||||
size_t out_capacity,
|
||||
size_t *signed_len,
|
||||
size_t *frame_len)
|
||||
{
|
||||
if (metadata == NULL || payload == NULL || out == NULL
|
||||
|| signed_len == NULL || frame_len == NULL
|
||||
|| !payload_size_is_valid(metadata->payload_type, payload_len)
|
||||
|| (metadata->flags & ~RV_GATEWAY_FLAGS_ALLOWED) != 0u
|
||||
|| (metadata->flags & RV_GATEWAY_FLAG_RX_MONOTONIC) == 0u
|
||||
|| metadata->sequence == 0u || metadata->boot_nonce == 0u
|
||||
|| payload_len > RV_GATEWAY_MAX_PAYLOAD_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t covered = RV_GATEWAY_HEADER_SIZE + payload_len;
|
||||
size_t total = covered + RV_GATEWAY_AUTH_TAG_SIZE;
|
||||
if (total > out_capacity || total > UINT16_MAX || payload_len > UINT16_MAX) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memset(out, 0, total);
|
||||
write_le32(&out[0], RV_GATEWAY_MAGIC);
|
||||
out[4] = RV_GATEWAY_VERSION;
|
||||
out[5] = metadata->payload_type;
|
||||
out[6] = metadata->flags;
|
||||
out[7] = metadata->key_id;
|
||||
write_le16(&out[8], (uint16_t)total);
|
||||
write_le16(&out[10], (uint16_t)payload_len);
|
||||
out[12] = metadata->node_id;
|
||||
/* bytes 13..15 are reserved and remain zero */
|
||||
write_le32(&out[16], metadata->sequence);
|
||||
write_le64(&out[20], metadata->boot_nonce);
|
||||
write_le64(&out[28], metadata->received_at_boot_us);
|
||||
write_le32(&out[36], metadata->timing_uncertainty_us);
|
||||
memcpy(&out[RV_GATEWAY_HEADER_SIZE], payload, payload_len);
|
||||
*signed_len = covered;
|
||||
*frame_len = total;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool rv_gateway_auth_tag_equal(const uint8_t lhs[RV_GATEWAY_AUTH_TAG_SIZE],
|
||||
const uint8_t rhs[RV_GATEWAY_AUTH_TAG_SIZE])
|
||||
{
|
||||
if (lhs == NULL || rhs == NULL) return false;
|
||||
#ifdef ESP_PLATFORM
|
||||
return mbedtls_ct_memcmp(lhs, rhs, RV_GATEWAY_AUTH_TAG_SIZE) == 0;
|
||||
#else
|
||||
uint8_t diff = 0u;
|
||||
for (size_t i = 0u; i < RV_GATEWAY_AUTH_TAG_SIZE; i++) {
|
||||
diff |= (uint8_t)(lhs[i] ^ rhs[i]);
|
||||
}
|
||||
return diff == 0u;
|
||||
#endif
|
||||
}
|
||||
64
firmware/esp32-csi-node/main/radio_gateway_protocol.h
Normal file
64
firmware/esp32-csi-node/main/radio_gateway_protocol.h
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file radio_gateway_protocol.h
|
||||
* @brief Authenticated gateway envelope for privacy-sensitive radio evidence.
|
||||
*/
|
||||
|
||||
#ifndef RADIO_GATEWAY_PROTOCOL_H
|
||||
#define RADIO_GATEWAY_PROTOCOL_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define RV_GATEWAY_MAGIC 0x45415652u /* "RVAE" little endian */
|
||||
#define RV_GATEWAY_VERSION 1u
|
||||
#define RV_GATEWAY_HEADER_SIZE 40u
|
||||
#define RV_GATEWAY_AUTH_TAG_SIZE 16u
|
||||
#define RV_GATEWAY_MAC_DOMAIN_SIZE 12u
|
||||
#define RV_GATEWAY_MAX_PAYLOAD_SIZE 72u
|
||||
#define RV_GATEWAY_MAX_SIGNED_SIZE \
|
||||
(RV_GATEWAY_HEADER_SIZE + RV_GATEWAY_MAX_PAYLOAD_SIZE)
|
||||
#define RV_GATEWAY_MAX_FRAME_SIZE \
|
||||
(RV_GATEWAY_MAX_SIGNED_SIZE + RV_GATEWAY_AUTH_TAG_SIZE)
|
||||
|
||||
#define RV_GATEWAY_PAYLOAD_BLE_IDENTITY 1u
|
||||
#define RV_GATEWAY_PAYLOAD_CHANNEL_SOUNDING 2u
|
||||
|
||||
#define RV_GATEWAY_FLAG_RX_MONOTONIC (1u << 0)
|
||||
#define RV_GATEWAY_FLAGS_ALLOWED RV_GATEWAY_FLAG_RX_MONOTONIC
|
||||
|
||||
/** HMAC domain separator; not NUL terminated on the wire. */
|
||||
extern const uint8_t RV_GATEWAY_MAC_DOMAIN[RV_GATEWAY_MAC_DOMAIN_SIZE];
|
||||
|
||||
/** Metadata captured by the ESP32 gateway before asynchronous UDP egress. */
|
||||
typedef struct {
|
||||
uint8_t payload_type;
|
||||
uint8_t flags;
|
||||
uint8_t key_id;
|
||||
uint8_t node_id;
|
||||
uint32_t sequence;
|
||||
uint64_t boot_nonce;
|
||||
uint64_t received_at_boot_us;
|
||||
uint32_t timing_uncertainty_us;
|
||||
} rv_gateway_metadata_t;
|
||||
|
||||
/**
|
||||
* Build the authenticated prefix and reserve a trailing 16-byte tag.
|
||||
*
|
||||
* The caller HMACs `RV_GATEWAY_MAC_DOMAIN || out[0..signed_len]`, copies the
|
||||
* first 16 digest bytes to `out[signed_len..frame_len]`, then sends exactly
|
||||
* `frame_len` bytes.
|
||||
*/
|
||||
bool rv_gateway_build_unsigned(const rv_gateway_metadata_t *metadata,
|
||||
const uint8_t *payload,
|
||||
size_t payload_len,
|
||||
uint8_t *out,
|
||||
size_t out_capacity,
|
||||
size_t *signed_len,
|
||||
size_t *frame_len);
|
||||
|
||||
/** Constant-time comparison for a truncated gateway authentication tag. */
|
||||
bool rv_gateway_auth_tag_equal(const uint8_t lhs[RV_GATEWAY_AUTH_TAG_SIZE],
|
||||
const uint8_t rhs[RV_GATEWAY_AUTH_TAG_SIZE]);
|
||||
|
||||
#endif /* RADIO_GATEWAY_PROTOCOL_H */
|
||||
187
firmware/esp32-csi-node/main/radio_gateway_sender.c
Normal file
187
firmware/esp32-csi-node/main/radio_gateway_sender.c
Normal file
@@ -0,0 +1,187 @@
|
||||
/** @file radio_gateway_sender.c */
|
||||
|
||||
#include "radio_gateway_sender.h"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_BLE_IDENTITY_SCAN_ENABLE) || \
|
||||
defined(CONFIG_CHANNEL_SOUNDING_INGRESS_ENABLE)
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdatomic.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_random.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mbedtls/md.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "nvs_config.h"
|
||||
#include "radio_gateway_protocol.h"
|
||||
#include "stream_sender.h"
|
||||
|
||||
extern nvs_config_t g_nvs_config;
|
||||
|
||||
static const char *TAG = "radio_gateway";
|
||||
|
||||
typedef struct {
|
||||
uint8_t payload_type;
|
||||
uint8_t payload_len;
|
||||
uint8_t payload[RV_GATEWAY_MAX_PAYLOAD_SIZE];
|
||||
uint64_t received_at_boot_us;
|
||||
uint32_t timing_uncertainty_us;
|
||||
} gateway_queue_item_t;
|
||||
|
||||
static QueueHandle_t s_queue;
|
||||
static uint64_t s_boot_nonce;
|
||||
static uint32_t s_sequence;
|
||||
static atomic_bool s_ready;
|
||||
static uint32_t s_delivery_drops;
|
||||
|
||||
static void sender_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
gateway_queue_item_t item;
|
||||
uint8_t frame[RV_GATEWAY_MAX_FRAME_SIZE];
|
||||
uint8_t covered[RV_GATEWAY_MAC_DOMAIN_SIZE + RV_GATEWAY_MAX_SIGNED_SIZE];
|
||||
uint8_t digest[32];
|
||||
|
||||
for (;;) {
|
||||
if (xQueueReceive(s_queue, &item, portMAX_DELAY) != pdTRUE) continue;
|
||||
if (s_sequence == UINT32_MAX) {
|
||||
ESP_LOGE(TAG, "gateway sequence exhausted; reboot or rekey required");
|
||||
atomic_store_explicit(&s_ready, false, memory_order_release);
|
||||
continue;
|
||||
}
|
||||
|
||||
rv_gateway_metadata_t metadata = {
|
||||
.payload_type = item.payload_type,
|
||||
.flags = RV_GATEWAY_FLAG_RX_MONOTONIC,
|
||||
.key_id = g_nvs_config.radio_envelope_key_id,
|
||||
.node_id = csi_collector_get_node_id(),
|
||||
.sequence = ++s_sequence,
|
||||
.boot_nonce = s_boot_nonce,
|
||||
.received_at_boot_us = item.received_at_boot_us,
|
||||
.timing_uncertainty_us = item.timing_uncertainty_us,
|
||||
};
|
||||
size_t signed_len = 0u;
|
||||
size_t frame_len = 0u;
|
||||
if (!rv_gateway_build_unsigned(&metadata, item.payload, item.payload_len,
|
||||
frame, sizeof(frame), &signed_len,
|
||||
&frame_len)) {
|
||||
ESP_LOGE(TAG, "internal gateway envelope construction failed");
|
||||
continue;
|
||||
}
|
||||
memcpy(covered, RV_GATEWAY_MAC_DOMAIN, RV_GATEWAY_MAC_DOMAIN_SIZE);
|
||||
memcpy(&covered[RV_GATEWAY_MAC_DOMAIN_SIZE], frame, signed_len);
|
||||
const mbedtls_md_info_t *sha256 =
|
||||
mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
if (sha256 == NULL
|
||||
|| mbedtls_md_hmac(sha256, g_nvs_config.radio_envelope_secret,
|
||||
sizeof(g_nvs_config.radio_envelope_secret),
|
||||
covered,
|
||||
RV_GATEWAY_MAC_DOMAIN_SIZE + signed_len,
|
||||
digest) != 0) {
|
||||
ESP_LOGE(TAG, "gateway envelope HMAC failed");
|
||||
continue;
|
||||
}
|
||||
memcpy(&frame[signed_len], digest, RV_GATEWAY_AUTH_TAG_SIZE);
|
||||
|
||||
/* Radio evidence is bulk data, not the <=48-byte <=1 Hz priority
|
||||
* control path. Normal ENOMEM backpressure applies. */
|
||||
if (stream_sender_send(frame, frame_len) != (int)frame_len) {
|
||||
s_delivery_drops++;
|
||||
if ((s_delivery_drops & 63u) == 1u) {
|
||||
ESP_LOGW(TAG, "radio envelope UDP delivery dropped (%lu total)",
|
||||
(unsigned long)s_delivery_drops);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t radio_gateway_sender_init(void)
|
||||
{
|
||||
if (!g_nvs_config.ble_identity_enabled && !g_nvs_config.cs_ingress_enabled) {
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (!g_nvs_config.radio_envelope_secret_valid) {
|
||||
ESP_LOGE(TAG, "radio evidence enabled without gateway envelope key");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (atomic_load_explicit(&s_ready, memory_order_acquire)) return ESP_OK;
|
||||
|
||||
do {
|
||||
esp_fill_random(&s_boot_nonce, sizeof(s_boot_nonce));
|
||||
} while (s_boot_nonce == 0u);
|
||||
s_sequence = 0u;
|
||||
s_queue = xQueueCreate(CONFIG_RADIO_GATEWAY_QUEUE_DEPTH,
|
||||
sizeof(gateway_queue_item_t));
|
||||
if (s_queue == NULL) return ESP_ERR_NO_MEM;
|
||||
if (xTaskCreate(sender_task, "radio_gateway", 4096, NULL, 5, NULL)
|
||||
!= pdPASS) {
|
||||
vQueueDelete(s_queue);
|
||||
s_queue = NULL;
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
atomic_store_explicit(&s_ready, true, memory_order_release);
|
||||
ESP_LOGI(TAG, "authenticated radio envelope ready (queue=%d)",
|
||||
CONFIG_RADIO_GATEWAY_QUEUE_DEPTH);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool radio_gateway_sender_is_ready(void)
|
||||
{
|
||||
return atomic_load_explicit(&s_ready, memory_order_acquire);
|
||||
}
|
||||
|
||||
esp_err_t radio_gateway_sender_enqueue(uint8_t payload_type,
|
||||
const uint8_t *payload,
|
||||
size_t payload_len,
|
||||
uint64_t received_at_boot_us,
|
||||
uint32_t timing_uncertainty_us)
|
||||
{
|
||||
if (!atomic_load_explicit(&s_ready, memory_order_acquire)
|
||||
|| s_queue == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (payload == NULL || payload_len > RV_GATEWAY_MAX_PAYLOAD_SIZE) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
gateway_queue_item_t item = {
|
||||
.payload_type = payload_type,
|
||||
.payload_len = (uint8_t)payload_len,
|
||||
.received_at_boot_us = received_at_boot_us,
|
||||
.timing_uncertainty_us = timing_uncertainty_us,
|
||||
};
|
||||
memcpy(item.payload, payload, payload_len);
|
||||
return xQueueSend(s_queue, &item, 0) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
esp_err_t radio_gateway_sender_init(void)
|
||||
{
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
bool radio_gateway_sender_is_ready(void)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_err_t radio_gateway_sender_enqueue(uint8_t payload_type,
|
||||
const uint8_t *payload,
|
||||
size_t payload_len,
|
||||
uint64_t received_at_boot_us,
|
||||
uint32_t timing_uncertainty_us)
|
||||
{
|
||||
(void)payload_type;
|
||||
(void)payload;
|
||||
(void)payload_len;
|
||||
(void)received_at_boot_us;
|
||||
(void)timing_uncertainty_us;
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
#endif
|
||||
31
firmware/esp32-csi-node/main/radio_gateway_sender.h
Normal file
31
firmware/esp32-csi-node/main/radio_gateway_sender.h
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file radio_gateway_sender.h
|
||||
* @brief Bounded asynchronous sender for authenticated radio envelopes.
|
||||
*/
|
||||
|
||||
#ifndef RADIO_GATEWAY_SENDER_H
|
||||
#define RADIO_GATEWAY_SENDER_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/** Initialize the boot session, bounded queue, and sender task. */
|
||||
esp_err_t radio_gateway_sender_init(void);
|
||||
|
||||
/** Whether authenticated envelope egress is ready. */
|
||||
bool radio_gateway_sender_is_ready(void);
|
||||
|
||||
/**
|
||||
* Nonblocking enqueue of an already sanitized radio payload.
|
||||
* Returns `ESP_ERR_TIMEOUT` when backpressure drops the record.
|
||||
*/
|
||||
esp_err_t radio_gateway_sender_enqueue(uint8_t payload_type,
|
||||
const uint8_t *payload,
|
||||
size_t payload_len,
|
||||
uint64_t received_at_boot_us,
|
||||
uint32_t timing_uncertainty_us);
|
||||
|
||||
#endif /* RADIO_GATEWAY_SENDER_H */
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
#include "stream_sender.h"
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "lwip/sockets.h"
|
||||
#include "lwip/netdb.h"
|
||||
#include "sdkconfig.h"
|
||||
@@ -18,6 +21,8 @@ static const char *TAG = "stream_sender";
|
||||
|
||||
static int s_sock = -1;
|
||||
static struct sockaddr_in s_dest_addr;
|
||||
static SemaphoreHandle_t s_send_lock;
|
||||
static atomic_uint_fast32_t s_lock_contention_drops;
|
||||
|
||||
/**
|
||||
* ENOMEM backoff state.
|
||||
@@ -39,6 +44,13 @@ static uint32_t s_enomem_streak = 0;
|
||||
|
||||
static int sender_init_internal(const char *ip, uint16_t port)
|
||||
{
|
||||
if (s_send_lock == NULL) {
|
||||
s_send_lock = xSemaphoreCreateMutex();
|
||||
if (s_send_lock == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to create UDP sender mutex");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
if (s_sock < 0) {
|
||||
ESP_LOGE(TAG, "Failed to create socket: errno %d", errno);
|
||||
@@ -72,9 +84,22 @@ int stream_sender_init_with(const char *ip, uint16_t port)
|
||||
|
||||
int stream_sender_send(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (s_sock < 0) {
|
||||
if (data == NULL || len == 0u || s_send_lock == NULL) {
|
||||
return -1;
|
||||
}
|
||||
/* CSI, edge DSP, WASM and ADR-341 tasks share one socket and one backoff
|
||||
* state machine. Never block a radio callback behind UDP I/O. */
|
||||
if (xSemaphoreTake(s_send_lock, 0) != pdTRUE) {
|
||||
uint_fast32_t drops = atomic_fetch_add_explicit(
|
||||
&s_lock_contention_drops, 1u, memory_order_relaxed) + 1u;
|
||||
if ((drops % ENOMEM_LOG_INTERVAL) == 1u) {
|
||||
ESP_LOGW(TAG, "concurrent UDP send dropped (%lu total)",
|
||||
(unsigned long)drops);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
int result = -1;
|
||||
if (s_sock < 0) goto done;
|
||||
|
||||
/* ENOMEM backoff: if we recently exhausted lwIP buffers, skip sends
|
||||
* until the cooldown expires. This prevents the cascade of failed
|
||||
@@ -87,7 +112,7 @@ int stream_sender_send(const uint8_t *data, size_t len)
|
||||
ESP_LOGW(TAG, "sendto suppressed (ENOMEM backoff, %lu dropped)",
|
||||
(unsigned long)s_enomem_suppressed);
|
||||
}
|
||||
return -1;
|
||||
goto done;
|
||||
}
|
||||
/* Cooldown expired — resume sending */
|
||||
ESP_LOGI(TAG, "ENOMEM backoff expired, resuming sends (%lu were suppressed)",
|
||||
@@ -113,17 +138,30 @@ int stream_sender_send(const uint8_t *data, size_t len)
|
||||
} else {
|
||||
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
|
||||
}
|
||||
return -1;
|
||||
goto done;
|
||||
}
|
||||
|
||||
/* A send got through — buffer pressure cleared; reset the backoff streak. */
|
||||
s_enomem_streak = 0;
|
||||
return sent;
|
||||
result = sent;
|
||||
|
||||
done:
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
int stream_sender_send_priority(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (data == NULL || len == 0u || s_send_lock == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (xSemaphoreTake(s_send_lock, 0) != pdTRUE) {
|
||||
(void)atomic_fetch_add_explicit(
|
||||
&s_lock_contention_drops, 1u, memory_order_relaxed);
|
||||
return -1;
|
||||
}
|
||||
if (s_sock < 0) {
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -144,16 +182,22 @@ int stream_sender_send_priority(const uint8_t *data, size_t len)
|
||||
if (errno != ENOMEM) {
|
||||
ESP_LOGW(TAG, "priority sendto failed: errno %d", errno);
|
||||
}
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return -1;
|
||||
}
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return sent;
|
||||
}
|
||||
|
||||
void stream_sender_deinit(void)
|
||||
{
|
||||
if (s_send_lock != NULL && xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) {
|
||||
return;
|
||||
}
|
||||
if (s_sock >= 0) {
|
||||
close(s_sock);
|
||||
s_sock = -1;
|
||||
ESP_LOGI(TAG, "UDP sender closed");
|
||||
}
|
||||
if (s_send_lock != NULL) xSemaphoreGive(s_send_lock);
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
201
firmware/esp32-csi-node/tests/host/test_ble_cs_protocol.c
Normal file
201
firmware/esp32-csi-node/tests/host/test_ble_cs_protocol.c
Normal file
@@ -0,0 +1,201 @@
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ble_identity_protocol.h"
|
||||
#include "channel_sounding_protocol.h"
|
||||
#include "radio_gateway_protocol.h"
|
||||
|
||||
static void put16(uint8_t *p, uint16_t value)
|
||||
{
|
||||
p[0] = (uint8_t)value;
|
||||
p[1] = (uint8_t)(value >> 8);
|
||||
}
|
||||
|
||||
static void put32(uint8_t *p, uint32_t value)
|
||||
{
|
||||
p[0] = (uint8_t)value;
|
||||
p[1] = (uint8_t)(value >> 8);
|
||||
p[2] = (uint8_t)(value >> 16);
|
||||
p[3] = (uint8_t)(value >> 24);
|
||||
}
|
||||
|
||||
static size_t build_ble_advert(uint8_t *advert, size_t cap)
|
||||
{
|
||||
const size_t total = RV_BLE_SERVICE_DATA_SIZE + 2u;
|
||||
assert(cap >= total);
|
||||
advert[0] = (uint8_t)(RV_BLE_SERVICE_DATA_SIZE + 1u);
|
||||
advert[1] = RV_BLE_AD_TYPE_SERVICE_DATA_UUID128;
|
||||
memcpy(&advert[2], RV_BLE_SERVICE_UUID_LE, 16u);
|
||||
uint8_t *body = &advert[18];
|
||||
body[0] = RV_BLE_TOKEN_VERSION;
|
||||
body[1] = 7u;
|
||||
put32(&body[2], 30000000u);
|
||||
put32(&body[6], 42u);
|
||||
for (size_t i = 0u; i < RV_BLE_EPHEMERAL_ID_SIZE; i++) body[10 + i] = (uint8_t)(0xa0u + i);
|
||||
for (size_t i = 0u; i < RV_BLE_AUTH_TAG_SIZE; i++) body[18 + i] = (uint8_t)(0x10u + i);
|
||||
return total;
|
||||
}
|
||||
|
||||
static void build_cs_frame(uint8_t frame[RV_CS_FRAME_SIZE])
|
||||
{
|
||||
memset(frame, 0, RV_CS_FRAME_SIZE);
|
||||
put32(&frame[0], RV_CS_MAGIC);
|
||||
frame[4] = RV_CS_VERSION;
|
||||
frame[5] = RV_CS_FLAG_CALIBRATED;
|
||||
frame[6] = 4u;
|
||||
frame[7] = 0u;
|
||||
put16(&frame[8], RV_CS_FRAME_SIZE);
|
||||
put16(&frame[10], 37u);
|
||||
put32(&frame[12], 9u);
|
||||
put32(&frame[16], 10000u);
|
||||
put32(&frame[20], 0x11223344u);
|
||||
put16(&frame[24], 800u);
|
||||
put16(&frame[26], 250u);
|
||||
put32(&frame[28], (uint32_t)-1200);
|
||||
put32(&frame[32], 50000u);
|
||||
put32(&frame[36], (uint32_t)-900);
|
||||
put32(&frame[40], 0x55667788u);
|
||||
put32(&frame[44], 0x12345678u);
|
||||
put16(&frame[48], 2u);
|
||||
put16(&frame[50], 8u);
|
||||
for (size_t i = 0u; i < RV_CS_AUTH_TAG_SIZE; i++) frame[52 + i] = (uint8_t)i;
|
||||
put32(&frame[68], rv_cs_crc32(frame, 68u));
|
||||
}
|
||||
|
||||
static void test_ble_parser_and_privacy_telemetry(void)
|
||||
{
|
||||
uint8_t advert[64];
|
||||
size_t advert_len = build_ble_advert(advert, sizeof(advert));
|
||||
rv_ble_token_t token;
|
||||
assert(rv_ble_parse_advertisement(advert, advert_len, &token));
|
||||
assert(token.key_id == 7u);
|
||||
assert(token.epoch_min == 30000000u);
|
||||
assert(token.ephemeral_id[0] == 0xa0u);
|
||||
assert(token.auth_tag[15] == 0x1fu);
|
||||
|
||||
uint8_t tampered[64];
|
||||
memcpy(tampered, advert, advert_len);
|
||||
tampered[0] = 63u; /* field extends beyond report */
|
||||
assert(!rv_ble_parse_advertisement(tampered, advert_len, &token));
|
||||
memcpy(tampered, advert, advert_len);
|
||||
tampered[18] = 2u; /* unsupported token version */
|
||||
assert(!rv_ble_parse_advertisement(tampered, advert_len, &token));
|
||||
|
||||
rv_ble_telemetry_t telemetry = {
|
||||
.node_id = 3u,
|
||||
.flags = RV_BLE_FLAG_AUTHENTICATED | RV_BLE_FLAG_TIME_VERIFIED,
|
||||
.key_id = 7u,
|
||||
.sequence = 11u,
|
||||
.observed_at_ms = 1200u,
|
||||
.ttl_ms = 3000u,
|
||||
.confidence_permille = 850u,
|
||||
.rssi_dbm = -61,
|
||||
.tx_power_dbm = 127,
|
||||
.token_epoch_min = 30000000u,
|
||||
};
|
||||
memcpy(telemetry.ephemeral_id, token.ephemeral_id, 8u);
|
||||
uint8_t packet[RV_BLE_TELEMETRY_SIZE];
|
||||
assert(rv_ble_serialize_telemetry(&telemetry, packet, sizeof(packet)));
|
||||
assert(RV_BLE_TELEMETRY_MAGIC != 0xC5110005u); /* compressed CSI */
|
||||
assert(packet[0] == 0xb1u && packet[1] == 0x00u
|
||||
&& packet[2] == 0x11u && packet[3] == 0xc5u);
|
||||
assert(packet[4] == RV_BLE_TELEMETRY_VERSION);
|
||||
assert(packet[5] == 3u);
|
||||
assert(packet[20] == (uint8_t)-61);
|
||||
/* The packet has no BLE address, raw advertising data, nonce or HMAC. */
|
||||
assert(memcmp(&packet[24], token.ephemeral_id, 8u) == 0);
|
||||
|
||||
telemetry.flags = 0u;
|
||||
assert(!rv_ble_serialize_telemetry(&telemetry, packet, sizeof(packet)));
|
||||
}
|
||||
|
||||
static void test_channel_sounding_validation(void)
|
||||
{
|
||||
uint8_t frame[RV_CS_FRAME_SIZE];
|
||||
build_cs_frame(frame);
|
||||
rv_cs_measurement_t measurement;
|
||||
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
|
||||
600u, &measurement) == RV_CS_PARSE_OK);
|
||||
assert(measurement.source_id == 0x11223344u);
|
||||
assert(measurement.phase_millirad == -1200);
|
||||
assert(measurement.frequency_offset_hz == -900);
|
||||
assert(measurement.source_session_id == 0x55667788u);
|
||||
assert(measurement.procedure_id == 0x12345678u);
|
||||
assert(measurement.step_index == 2u);
|
||||
assert(measurement.step_count == 8u);
|
||||
|
||||
frame[28] ^= 1u;
|
||||
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
|
||||
600u, &measurement) == RV_CS_PARSE_BAD_CRC);
|
||||
|
||||
build_cs_frame(frame);
|
||||
put32(&frame[16], 3000000u);
|
||||
put32(&frame[68], rv_cs_crc32(frame, 68u));
|
||||
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
|
||||
600u, &measurement) == RV_CS_PARSE_STALE);
|
||||
|
||||
build_cs_frame(frame);
|
||||
uint8_t covered[RV_CS_MAC_INPUT_SIZE];
|
||||
rv_cs_mac_input(frame, covered);
|
||||
assert(memcmp(covered, "RuView/CS/v1", RV_CS_MAC_DOMAIN_SIZE) == 0);
|
||||
assert(memcmp(&covered[RV_CS_MAC_DOMAIN_SIZE], frame,
|
||||
RV_CS_SIGNED_PREFIX_SIZE) == 0);
|
||||
|
||||
assert(rv_cs_sequence_is_newer(10u, 9u));
|
||||
assert(!rv_cs_sequence_is_newer(9u, 9u));
|
||||
assert(rv_cs_sequence_is_newer(0u, UINT32_MAX));
|
||||
|
||||
build_cs_frame(frame);
|
||||
put16(&frame[50], 1u);
|
||||
put32(&frame[68], rv_cs_crc32(frame, 68u));
|
||||
assert(rv_cs_parse_frame(frame, sizeof(frame), 2000000u,
|
||||
600u, &measurement) == RV_CS_PARSE_BAD_STEP);
|
||||
}
|
||||
|
||||
static void test_gateway_envelope_contract(void)
|
||||
{
|
||||
uint8_t payload[RV_BLE_TELEMETRY_SIZE] = {0};
|
||||
uint8_t frame[RV_GATEWAY_MAX_FRAME_SIZE];
|
||||
size_t signed_len = 0u;
|
||||
size_t frame_len = 0u;
|
||||
rv_gateway_metadata_t metadata = {
|
||||
.payload_type = RV_GATEWAY_PAYLOAD_BLE_IDENTITY,
|
||||
.flags = RV_GATEWAY_FLAG_RX_MONOTONIC,
|
||||
.key_id = 3u,
|
||||
.node_id = 9u,
|
||||
.sequence = 11u,
|
||||
.boot_nonce = 0x0102030405060708ull,
|
||||
.received_at_boot_us = 9000u,
|
||||
.timing_uncertainty_us = 1000u,
|
||||
};
|
||||
assert(rv_gateway_build_unsigned(&metadata, payload, sizeof(payload),
|
||||
frame, sizeof(frame), &signed_len,
|
||||
&frame_len));
|
||||
assert(signed_len == RV_GATEWAY_HEADER_SIZE + sizeof(payload));
|
||||
assert(frame_len == signed_len + RV_GATEWAY_AUTH_TAG_SIZE);
|
||||
assert(memcmp(&frame[0], "RVAE", 4u) == 0);
|
||||
assert(frame[4] == RV_GATEWAY_VERSION);
|
||||
assert(frame[5] == RV_GATEWAY_PAYLOAD_BLE_IDENTITY);
|
||||
assert(frame[13] == 0u && frame[14] == 0u && frame[15] == 0u);
|
||||
|
||||
metadata.sequence = 0u;
|
||||
assert(!rv_gateway_build_unsigned(&metadata, payload, sizeof(payload),
|
||||
frame, sizeof(frame), &signed_len,
|
||||
&frame_len));
|
||||
uint8_t lhs[RV_GATEWAY_AUTH_TAG_SIZE] = {0};
|
||||
uint8_t rhs[RV_GATEWAY_AUTH_TAG_SIZE] = {0};
|
||||
assert(rv_gateway_auth_tag_equal(lhs, rhs));
|
||||
rhs[15] = 1u;
|
||||
assert(!rv_gateway_auth_tag_equal(lhs, rhs));
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_ble_parser_and_privacy_telemetry();
|
||||
test_channel_sounding_validation();
|
||||
test_gateway_envelope_contract();
|
||||
puts("BLE identity and external Channel Sounding protocol tests passed (SYNTHETIC)");
|
||||
return 0;
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import csv
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
|
||||
@@ -30,6 +36,8 @@ class ProvisionConfigValueTests(unittest.TestCase):
|
||||
{"seed_token": "token-123"},
|
||||
{"swarm_hb": 15},
|
||||
{"swarm_ingest": 3},
|
||||
{"cs_source_id": 1},
|
||||
{"radio_envelope_key_id": 0},
|
||||
]
|
||||
|
||||
for values in cases:
|
||||
@@ -58,6 +66,230 @@ class ProvisionConfigValueTests(unittest.TestCase):
|
||||
self.assertEqual(values_by_key["swarm_hb"], "15")
|
||||
self.assertEqual(values_by_key["swarm_ingest"], "3")
|
||||
|
||||
def test_radio_secrets_source_and_key_ids_are_written_as_typed_nvs_values(self):
|
||||
args = make_args(
|
||||
ble_identity_enable=1,
|
||||
ble_key_id=7,
|
||||
ble_secret_bytes=bytes(range(32)),
|
||||
radio_envelope_key_id=11,
|
||||
radio_envelope_secret_bytes=bytes([0xA5]) * 32,
|
||||
)
|
||||
rows = csv_rows(provision.build_nvs_csv(args))
|
||||
values_by_key = {row["key"]: row["value"] for row in rows}
|
||||
self.assertEqual(values_by_key["ble_enable"], "1")
|
||||
self.assertEqual(values_by_key["ble_key_id"], "7")
|
||||
self.assertEqual(values_by_key["ble_secret"], bytes(range(32)).hex())
|
||||
self.assertEqual(values_by_key["radio_key_id"], "11")
|
||||
self.assertEqual(values_by_key["radio_secret"], (bytes([0xA5]) * 32).hex())
|
||||
self.assertNotIn("ble_secret_bytes", provision.MERGEABLE_ATTRS)
|
||||
self.assertNotIn("radio_envelope_secret_bytes", provision.MERGEABLE_ATTRS)
|
||||
|
||||
args.cs_ingress_enable = 1
|
||||
args.cs_key_id = 9
|
||||
args.cs_secret_bytes = bytes(reversed(range(32)))
|
||||
args.cs_source_id = 0x11223344
|
||||
rows = csv_rows(provision.build_nvs_csv(args))
|
||||
values_by_key = {row["key"]: row["value"] for row in rows}
|
||||
self.assertEqual(values_by_key["cs_enable"], "1")
|
||||
self.assertEqual(values_by_key["cs_key_id"], "9")
|
||||
self.assertEqual(values_by_key["cs_secret"], bytes(reversed(range(32))).hex())
|
||||
self.assertEqual(values_by_key["cs_source_id"], str(0x11223344))
|
||||
self.assertNotIn("cs_secret_bytes", provision.MERGEABLE_ATTRS)
|
||||
|
||||
def test_ble_secret_file_accepts_raw_and_hex(self):
|
||||
encoded = bytes(range(32)).hex().encode("ascii")
|
||||
for payload in (bytes(range(32)), encoded, encoded + b"\n", encoded + b"\r\n"):
|
||||
with self.subTest(length=len(payload)):
|
||||
with tempfile.NamedTemporaryFile() as secret_file:
|
||||
secret_file.write(payload)
|
||||
secret_file.flush()
|
||||
self.assertEqual(provision.load_ble_secret(secret_file.name), bytes(range(32)))
|
||||
|
||||
def test_radio_secret_file_rejects_trailing_data(self):
|
||||
for payload in (
|
||||
bytes(range(32)) + b"x",
|
||||
b"00" * 32 + b"\nextra",
|
||||
b"00" * 32 + b"\n\nignored",
|
||||
b"00" * 32 + b"\r\nextra",
|
||||
):
|
||||
with self.subTest(length=len(payload)):
|
||||
with tempfile.NamedTemporaryFile() as secret_file:
|
||||
secret_file.write(payload)
|
||||
secret_file.flush()
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "trailing data|exactly 32 raw bytes"
|
||||
):
|
||||
provision.load_ble_secret(secret_file.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile() as secret_file:
|
||||
secret_file.write(b"gg" * 32)
|
||||
secret_file.flush()
|
||||
with self.assertRaisesRegex(ValueError, "not valid hexadecimal"):
|
||||
provision.load_ble_secret(secret_file.name)
|
||||
|
||||
def test_radio_secrets_reject_zero_and_key_reuse(self):
|
||||
for payload in (bytes(32), b"00" * 32, b"00" * 32 + b"\n"):
|
||||
with self.subTest(length=len(payload)):
|
||||
with tempfile.NamedTemporaryFile() as secret_file:
|
||||
secret_file.write(payload)
|
||||
secret_file.flush()
|
||||
with self.assertRaisesRegex(ValueError, "must not be all zero"):
|
||||
provision.load_ble_secret(secret_file.name)
|
||||
|
||||
key = bytes([0x5a]) * 32
|
||||
with self.assertRaisesRegex(ValueError, "independently generated"):
|
||||
provision.validate_distinct_radio_secrets(key, None, key)
|
||||
provision.validate_distinct_radio_secrets(
|
||||
bytes([0x11]) * 32,
|
||||
bytes([0x22]) * 32,
|
||||
bytes([0x33]) * 32,
|
||||
)
|
||||
|
||||
def test_nvs_csv_rejects_non_32_byte_in_memory_secrets(self):
|
||||
for attribute in (
|
||||
"ble_secret_bytes",
|
||||
"cs_secret_bytes",
|
||||
"radio_envelope_secret_bytes",
|
||||
):
|
||||
with self.subTest(attribute=attribute):
|
||||
args = make_args(**{attribute: b"short"})
|
||||
with self.assertRaisesRegex(ValueError, "exactly 32 bytes"):
|
||||
provision.build_nvs_csv(args)
|
||||
|
||||
args = make_args(ble_secret_bytes=bytes(32))
|
||||
with self.assertRaisesRegex(ValueError, "must not be all zero"):
|
||||
provision.build_nvs_csv(args)
|
||||
|
||||
def test_radio_evidence_requires_gateway_envelope_key_and_secret(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
ble_secret = Path(temp_dir) / "ble.key"
|
||||
ble_secret.write_bytes(bytes([0x11]) * 32)
|
||||
base = [
|
||||
"provision.py",
|
||||
"--port", "TEST",
|
||||
"--state-dir", str(Path(temp_dir) / "state"),
|
||||
"--force-partial",
|
||||
"--ble-identity-enable", "1",
|
||||
"--ble-key-id", "7",
|
||||
"--ble-secret-file", str(ble_secret),
|
||||
]
|
||||
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", base), contextlib.redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
provision.main()
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
self.assertIn("--radio-envelope-key-id is required", stderr.getvalue())
|
||||
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(
|
||||
sys, "argv", base + ["--radio-envelope-key-id", "8"]
|
||||
), contextlib.redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
provision.main()
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
self.assertIn("--radio-envelope-secret-file is required", stderr.getvalue())
|
||||
|
||||
def test_channel_sounding_requires_enrolled_nonzero_source(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
cs_secret = Path(temp_dir) / "cs.key"
|
||||
radio_secret = Path(temp_dir) / "radio.key"
|
||||
cs_secret.write_bytes(bytes([0x22]) * 32)
|
||||
radio_secret.write_bytes(bytes([0x33]) * 32)
|
||||
base = [
|
||||
"provision.py",
|
||||
"--port", "TEST",
|
||||
"--state-dir", str(Path(temp_dir) / "state"),
|
||||
"--force-partial",
|
||||
"--cs-ingress-enable", "1",
|
||||
"--cs-key-id", "9",
|
||||
"--cs-secret-file", str(cs_secret),
|
||||
"--radio-envelope-key-id", "10",
|
||||
"--radio-envelope-secret-file", str(radio_secret),
|
||||
]
|
||||
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", base), contextlib.redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
provision.main()
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
self.assertIn("--cs-source-id is required", stderr.getvalue())
|
||||
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(
|
||||
sys, "argv", base + ["--cs-source-id", "0"]
|
||||
), contextlib.redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
provision.main()
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
self.assertIn("must be between 1 and 4294967295", stderr.getvalue())
|
||||
|
||||
def test_secret_configuration_never_writes_fallback_csv(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir, contextlib.chdir(temp_dir):
|
||||
radio_secret = Path(temp_dir) / "radio.key"
|
||||
radio_secret.write_bytes(bytes([0x44]) * 32)
|
||||
argv = [
|
||||
"provision.py",
|
||||
"--port", "TEST",
|
||||
"--state-dir", str(Path(temp_dir) / "state"),
|
||||
"--force-partial",
|
||||
"--radio-envelope-key-id", "12",
|
||||
"--radio-envelope-secret-file", str(radio_secret),
|
||||
]
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
provision, "generate_nvs_binary", side_effect=RuntimeError("missing")
|
||||
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
provision.main()
|
||||
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
self.assertFalse(Path("nvs_config.csv").exists())
|
||||
self.assertIn("Refusing to persist fallback NVS CSV", stderr.getvalue())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX permission bits required")
|
||||
def test_secret_outputs_and_state_are_mode_0600(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir, contextlib.chdir(temp_dir):
|
||||
radio_secret = Path(temp_dir) / "radio.key"
|
||||
cs_secret = Path(temp_dir) / "cs.key"
|
||||
radio_secret.write_bytes(bytes([0x55]) * 32)
|
||||
cs_secret.write_bytes(bytes([0x66]) * 32)
|
||||
output = Path("nvs_provision.bin")
|
||||
output.write_bytes(b"old")
|
||||
output.chmod(0o644)
|
||||
state_dir = Path(temp_dir) / "state"
|
||||
state_dir.mkdir()
|
||||
state_path = Path(provision._state_path_for("TEST", str(state_dir)))
|
||||
state_path.write_text("{}\n")
|
||||
state_path.chmod(0o644)
|
||||
argv = [
|
||||
"provision.py",
|
||||
"--port", "TEST",
|
||||
"--state-dir", str(state_dir),
|
||||
"--ssid", "test-network",
|
||||
"--password", "test-password",
|
||||
"--target-ip", "192.0.2.10",
|
||||
"--dry-run",
|
||||
"--cs-ingress-enable", "1",
|
||||
"--cs-key-id", "9",
|
||||
"--cs-secret-file", str(cs_secret),
|
||||
"--cs-source-id", "0x11223344",
|
||||
"--radio-envelope-key-id", "13",
|
||||
"--radio-envelope-secret-file", str(radio_secret),
|
||||
]
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
provision, "generate_nvs_binary", return_value=b"private-nvs"
|
||||
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(
|
||||
io.StringIO()
|
||||
):
|
||||
provision.main()
|
||||
|
||||
self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(state_path.stat().st_mode), 0o600)
|
||||
state_text = state_path.read_text()
|
||||
self.assertIn('"password": "test-password"', state_text)
|
||||
self.assertNotIn("radio_envelope_secret", state_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
43
v2/Cargo.lock
generated
43
v2/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
pub mod estimate;
|
||||
mod engine;
|
||||
mod observation;
|
||||
pub mod radio_fusion;
|
||||
mod world;
|
||||
|
||||
pub use engine::{FusionConfig, FusionEngine};
|
||||
|
||||
3097
v2/crates/ruview-fusion/src/radio_fusion.rs
Normal file
3097
v2/crates/ruview-fusion/src/radio_fusion.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -121,6 +121,72 @@ struct Args {
|
||||
#[arg(long, env = "RUVIEW_UDP_INSECURE_LAN")]
|
||||
udp_insecure_lan: bool,
|
||||
|
||||
/// Primary ESP32-S3 node enrolled to submit authenticated RVAE envelopes.
|
||||
#[arg(long, env = "RUVIEW_RADIO_GATEWAY_NODE_ID")]
|
||||
radio_gateway_node_id: Option<u8>,
|
||||
|
||||
/// Primary gateway HMAC key selector.
|
||||
#[arg(long, env = "RUVIEW_RADIO_GATEWAY_KEY_ID")]
|
||||
radio_gateway_key_id: Option<u8>,
|
||||
|
||||
/// File containing exactly 32 raw primary gateway secret bytes.
|
||||
#[arg(long, value_name = "PATH", env = "RUVIEW_RADIO_GATEWAY_SECRET_FILE")]
|
||||
radio_gateway_secret_file: Option<PathBuf>,
|
||||
|
||||
/// Additional gateway enrollment as `NODE,KEY,SECRET_PATH`. Repeat for
|
||||
/// independent gateways; node and key pairs and secrets must be unique.
|
||||
#[arg(long = "radio-gateway", value_name = "NODE,KEY,SECRET_PATH")]
|
||||
radio_additional_gateways: Vec<String>,
|
||||
|
||||
/// Distinct 32 byte deployment key used to derive host scoped `blep:`
|
||||
/// tokens. Raw BLE pseudonyms are never exported or persisted.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PATH",
|
||||
env = "RUVIEW_RADIO_HOST_PSEUDONYM_SECRET_FILE"
|
||||
)]
|
||||
radio_host_pseudonym_secret_file: Option<PathBuf>,
|
||||
|
||||
/// Private durable replay state for authenticated radio envelopes.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PATH",
|
||||
env = "RUVIEW_RADIO_REPLAY_STATE",
|
||||
default_value = "data/radio-replay-v2.json"
|
||||
)]
|
||||
radio_replay_state: PathBuf,
|
||||
|
||||
/// One-shot creation of a missing replay snapshot. Remove this option after
|
||||
/// creation. If state was lost, rotate all enrolled keys before using it.
|
||||
#[arg(long, env = "RUVIEW_RADIO_INITIALIZE_REPLAY_STATE")]
|
||||
radio_initialize_replay_state: bool,
|
||||
|
||||
/// HMAC key selector for a separately enrolled Channel Sounding radio.
|
||||
#[arg(long, env = "RUVIEW_RADIO_CS_KEY_ID")]
|
||||
radio_cs_key_id: Option<u8>,
|
||||
|
||||
/// Nonzero opaque identifier for the Channel Sounding companion.
|
||||
#[arg(long, env = "RUVIEW_RADIO_CS_SOURCE_ID")]
|
||||
radio_cs_source_id: Option<u32>,
|
||||
|
||||
/// File containing exactly 32 raw companion HMAC secret bytes.
|
||||
#[arg(long, value_name = "PATH", env = "RUVIEW_RADIO_CS_SECRET_FILE")]
|
||||
radio_cs_secret_file: Option<PathBuf>,
|
||||
|
||||
/// Local deployment override for P5 BLE anchor export. This is not a
|
||||
/// subject consent receipt and is accepted only with loopback, auth, and audit.
|
||||
#[arg(long, env = "RUVIEW_RADIO_UNSAFE_EXPORT_P5_IDENTITY")]
|
||||
radio_unsafe_export_p5_identity: bool,
|
||||
|
||||
/// Local deployment override for P4 aggregate respiration export. Exact
|
||||
/// P0 phase, RTT, frequency offset, and step vectors never leave the edge.
|
||||
#[arg(long, env = "RUVIEW_RADIO_UNSAFE_EXPORT_P4_BIOLOGICAL")]
|
||||
radio_unsafe_export_p4_biological: bool,
|
||||
|
||||
/// Private append-only audit log required by either radio export override.
|
||||
#[arg(long, value_name = "PATH", env = "RUVIEW_RADIO_EXPORT_AUDIT_LOG")]
|
||||
radio_export_audit_log: Option<PathBuf>,
|
||||
|
||||
/// Path to UI static files (repo `ui/`; from `v2/` use `../ui` or rely on auto-detect)
|
||||
#[arg(long, default_value = "../ui")]
|
||||
ui_path: PathBuf,
|
||||
@@ -6210,11 +6276,14 @@ async fn udp_receiver_task(
|
||||
bind_ip: std::net::IpAddr,
|
||||
udp_port: u16,
|
||||
allowlist: std::sync::Arc<wifi_densepose_sensing_server::udp_bind::UdpSourceAllowlist>,
|
||||
radio_sender: Option<
|
||||
std::sync::mpsc::SyncSender<wifi_densepose_sensing_server::radio_ingress::RadioDatagram>,
|
||||
>,
|
||||
) {
|
||||
let addr = format!("{bind_ip}:{udp_port}");
|
||||
let socket = match UdpSocket::bind(&addr).await {
|
||||
Ok(s) => {
|
||||
info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm CSI, and RTL8720F radar frames");
|
||||
info!("UDP listening on {addr} for ESP32, authenticated radio evidence, MediaTek, Qualcomm CSI, and RTL8720F radar frames");
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -6224,6 +6293,10 @@ async fn udp_receiver_task(
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAX_FRAME_LEN];
|
||||
let mut radio_queue_drops = 0u64;
|
||||
let mut radio_invalid_length_drops = 0u64;
|
||||
let mut radio_worker_unhealthy = false;
|
||||
let mut last_radio_queue_warning = std::time::Instant::now();
|
||||
loop {
|
||||
match socket.recv_from(&mut buf).await {
|
||||
Ok((len, src)) => {
|
||||
@@ -6236,6 +6309,65 @@ async fn udp_receiver_task(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if len >= 4
|
||||
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
|
||||
== ruview_fusion::radio_fusion::GATEWAY_ENVELOPE_MAGIC
|
||||
{
|
||||
if !wifi_densepose_sensing_server::radio_ingress::is_supported_rvae_datagram_len(
|
||||
len,
|
||||
) {
|
||||
radio_invalid_length_drops = radio_invalid_length_drops.saturating_add(1);
|
||||
if last_radio_queue_warning.elapsed() >= std::time::Duration::from_secs(1) {
|
||||
warn!(
|
||||
dropped = radio_invalid_length_drops,
|
||||
received_len = len,
|
||||
"RVAE datagrams with unsupported lengths were dropped before allocation and authentication"
|
||||
);
|
||||
radio_invalid_length_drops = 0;
|
||||
last_radio_queue_warning = std::time::Instant::now();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let queued = match radio_sender.as_ref() {
|
||||
Some(sender) => {
|
||||
let datagram =
|
||||
wifi_densepose_sensing_server::radio_ingress::RadioDatagram {
|
||||
frame: buf[..len].to_vec(),
|
||||
host_received_at_unix_us: chrono::Utc::now().timestamp_micros(),
|
||||
source: src,
|
||||
};
|
||||
match sender.try_send(datagram) {
|
||||
Ok(()) => true,
|
||||
Err(std::sync::mpsc::TrySendError::Full(_)) => false,
|
||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
|
||||
radio_worker_unhealthy = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !queued {
|
||||
radio_queue_drops = radio_queue_drops.saturating_add(1);
|
||||
if last_radio_queue_warning.elapsed() >= std::time::Duration::from_secs(1) {
|
||||
if radio_worker_unhealthy {
|
||||
error!(
|
||||
dropped = radio_queue_drops,
|
||||
"RVAE ingress worker is unhealthy and its queue is disconnected"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
dropped = radio_queue_drops,
|
||||
configured = radio_sender.is_some(),
|
||||
"RVAE ingress queue unavailable or full; datagrams were dropped before authentication"
|
||||
);
|
||||
}
|
||||
radio_queue_drops = 0;
|
||||
last_radio_queue_warning = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if len > 0 && buf[0] == b'{' {
|
||||
match serde_json::from_slice::<wifi_densepose_hardware::vendor_rf::VendorRfEvent>(&buf[..len])
|
||||
.map_err(|error| error.to_string())
|
||||
@@ -7708,6 +7840,114 @@ fn coalesce_ui_path(initial: std::path::PathBuf) -> std::path::PathBuf {
|
||||
initial
|
||||
}
|
||||
|
||||
fn parse_radio_gateway(
|
||||
value: &str,
|
||||
) -> Result<wifi_densepose_sensing_server::radio_ingress::GatewayRuntimeOptions, String> {
|
||||
let mut fields = value.splitn(3, ',');
|
||||
let node_id = fields
|
||||
.next()
|
||||
.ok_or_else(|| "radio gateway requires NODE,KEY,SECRET_PATH".to_string())?
|
||||
.parse::<u8>()
|
||||
.map_err(|_| "radio gateway NODE must be an integer from 1 through 255".to_string())?;
|
||||
let key_id = fields
|
||||
.next()
|
||||
.ok_or_else(|| "radio gateway requires NODE,KEY,SECRET_PATH".to_string())?
|
||||
.parse::<u8>()
|
||||
.map_err(|_| "radio gateway KEY must be an integer from 0 through 255".to_string())?;
|
||||
let secret = fields
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "radio gateway SECRET_PATH must not be empty".to_string())?;
|
||||
if node_id == 0 {
|
||||
return Err("radio gateway NODE must be nonzero".to_string());
|
||||
}
|
||||
Ok(
|
||||
wifi_densepose_sensing_server::radio_ingress::GatewayRuntimeOptions {
|
||||
node_id,
|
||||
key_id,
|
||||
secret_file: PathBuf::from(secret),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn open_radio_ingress(
|
||||
args: &Args,
|
||||
) -> Result<Option<wifi_densepose_sensing_server::radio_ingress::RadioIngressRuntime>, String> {
|
||||
use wifi_densepose_sensing_server::radio_ingress::{
|
||||
ChannelSoundingRuntimeOptions, GatewayRuntimeOptions, RadioIngressOptions,
|
||||
RadioIngressRuntime,
|
||||
};
|
||||
|
||||
let mut gateways = Vec::new();
|
||||
match (
|
||||
args.radio_gateway_node_id,
|
||||
args.radio_gateway_key_id,
|
||||
args.radio_gateway_secret_file.as_ref(),
|
||||
) {
|
||||
(None, None, None) => {}
|
||||
(Some(node_id), Some(key_id), Some(secret_file)) => gateways.push(GatewayRuntimeOptions {
|
||||
node_id,
|
||||
key_id,
|
||||
secret_file: secret_file.clone(),
|
||||
}),
|
||||
_ => {
|
||||
return Err(
|
||||
"primary radio gateway node id, key id, and secret file must be supplied together"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
for value in &args.radio_additional_gateways {
|
||||
gateways.push(parse_radio_gateway(value)?);
|
||||
}
|
||||
|
||||
let companion = match (
|
||||
args.radio_cs_key_id,
|
||||
args.radio_cs_source_id,
|
||||
args.radio_cs_secret_file.as_ref(),
|
||||
) {
|
||||
(None, None, None) => None,
|
||||
(Some(key_id), Some(source_id), Some(secret_file)) => Some(ChannelSoundingRuntimeOptions {
|
||||
key_id,
|
||||
source_id,
|
||||
secret_file: secret_file.clone(),
|
||||
}),
|
||||
_ => {
|
||||
return Err(
|
||||
"Channel Sounding key id, source id, and secret file must be supplied together"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if gateways.is_empty() {
|
||||
if companion.is_some() || args.radio_host_pseudonym_secret_file.is_some() {
|
||||
return Err(
|
||||
"Channel Sounding and host pseudonym configuration require at least one RVAE gateway"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let host_pseudonym_secret_file = args
|
||||
.radio_host_pseudonym_secret_file
|
||||
.clone()
|
||||
.ok_or_else(|| "radio gateways require a host pseudonym secret file".to_string())?;
|
||||
|
||||
RadioIngressRuntime::open(
|
||||
RadioIngressOptions {
|
||||
gateways,
|
||||
host_pseudonym_secret_file,
|
||||
replay_state_file: args.radio_replay_state.clone(),
|
||||
initialize_replay_state: args.radio_initialize_replay_state,
|
||||
channel_sounding: companion,
|
||||
},
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
)
|
||||
.map(Some)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Initialize tracing; with the `otel` feature and
|
||||
@@ -8315,6 +8555,66 @@ async fn main() {
|
||||
plan.bind_udp, plan.run_simulator, plan.run_wifi
|
||||
);
|
||||
|
||||
let radio_requested = args.radio_gateway_node_id.is_some()
|
||||
|| args.radio_gateway_key_id.is_some()
|
||||
|| args.radio_gateway_secret_file.is_some()
|
||||
|| !args.radio_additional_gateways.is_empty()
|
||||
|| args.radio_host_pseudonym_secret_file.is_some()
|
||||
|| args.radio_cs_key_id.is_some()
|
||||
|| args.radio_cs_source_id.is_some()
|
||||
|| args.radio_cs_secret_file.is_some();
|
||||
if radio_requested && !plan.bind_udp {
|
||||
error!("Authenticated radio ingress requires a source mode that binds UDP");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let radio_runtime = match open_radio_ingress(&args) {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
error!("Authenticated radio ingress configuration failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if args.radio_initialize_replay_state && radio_runtime.is_none() {
|
||||
error!("Replay initialization requires at least one complete gateway enrollment");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let radio_export_policy = wifi_densepose_sensing_server::radio_ingress::RadioExportPolicy {
|
||||
allow_biological_p4: args.radio_unsafe_export_p4_biological,
|
||||
allow_identity_p5: args.radio_unsafe_export_p5_identity,
|
||||
};
|
||||
if radio_export_policy.any() {
|
||||
let export_bind = args
|
||||
.bind_addr
|
||||
.parse::<std::net::IpAddr>()
|
||||
.unwrap_or_else(|_| {
|
||||
error!("Invalid --bind-addr '{}'", args.bind_addr);
|
||||
std::process::exit(1);
|
||||
});
|
||||
let auth_configured = ["RUVIEW_API_TOKEN", "RUVIEW_OAUTH_ISSUER"]
|
||||
.iter()
|
||||
.any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
|
||||
if radio_runtime.is_none()
|
||||
|| !export_bind.is_loopback()
|
||||
|| !auth_configured
|
||||
|| args.radio_export_audit_log.is_none()
|
||||
{
|
||||
error!(
|
||||
"Radio P4/P5 export overrides require authenticated radio ingress, a loopback bind, configured bearer or OAuth authentication, and a private audit log"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
if let Some(runtime) = radio_runtime.as_ref() {
|
||||
info!(
|
||||
"Authenticated RVAE ingress enabled; durable replay state: {}",
|
||||
runtime.replay_state_file().display()
|
||||
);
|
||||
info!(
|
||||
"Radio WebSocket export overrides: P4 biological={}, P5 identity={}",
|
||||
radio_export_policy.allow_biological_p4, radio_export_policy.allow_identity_p5
|
||||
);
|
||||
}
|
||||
|
||||
// Shared state
|
||||
// Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz)
|
||||
let vital_sample_rate = 1000.0 / args.tick_ms as f64;
|
||||
@@ -8445,6 +8745,23 @@ async fn main() {
|
||||
};
|
||||
|
||||
let (tx, _) = broadcast::channel::<String>(256);
|
||||
let radio_sender = match radio_runtime {
|
||||
Some(runtime) => {
|
||||
match wifi_densepose_sensing_server::radio_ingress::spawn_radio_ingress_worker(
|
||||
runtime,
|
||||
radio_export_policy,
|
||||
args.radio_export_audit_log.clone(),
|
||||
tx.clone(),
|
||||
) {
|
||||
Ok(sender) => Some(sender),
|
||||
Err(error) => {
|
||||
error!("Authenticated radio worker failed to start: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// ADR-099: parallel broadcast for the per-frame introspection snapshot stream
|
||||
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
|
||||
// clients drop oldest, identical backpressure shape.
|
||||
@@ -8691,6 +9008,7 @@ async fn main() {
|
||||
udp_bind_ip,
|
||||
args.udp_port,
|
||||
udp_allowlist,
|
||||
radio_sender,
|
||||
));
|
||||
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
|
||||
}
|
||||
|
||||
1724
v2/crates/wifi-densepose-sensing-server/src/radio_ingress.rs
Normal file
1724
v2/crates/wifi-densepose-sensing-server/src/radio_ingress.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user