From a937a5fd20a6b1010738d4dd21e2e2c26405153d Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 11:48:38 -0400 Subject: [PATCH] fix(firmware): correct device identity and provision OTA keys for 0.8.14 --- .github/workflows/firmware-ci.yml | 24 ++- docs/user-guide.md | 35 +++- firmware/esp32-csi-node/README.md | 96 ++++++++-- .../esp32-csi-node/main/onboarding_identity.h | 39 ++++ firmware/esp32-csi-node/main/ota_update.c | 6 +- .../esp32-csi-node/main/serial_onboarding.c | 29 +-- firmware/esp32-csi-node/provision.py | 94 ++++++++- .../esp32-csi-node/release_bins/README.md | 16 ++ .../release_bins/c6-onboarding/version.txt | 1 + firmware/esp32-csi-node/test/Makefile | 12 +- .../test/identity_stubs/esp_mac.h | 8 + .../test/identity_stubs/psa/crypto.h | 13 ++ .../test/test_delivery_contract.py | 57 ++++-- .../test/test_onboarding_identity.c | 74 +++++++ .../tests/test_provision_ota_psk.py | 181 ++++++++++++++++++ firmware/esp32-csi-node/version.txt | 2 +- 16 files changed, 618 insertions(+), 69 deletions(-) create mode 100644 firmware/esp32-csi-node/main/onboarding_identity.h create mode 100644 firmware/esp32-csi-node/release_bins/README.md create mode 100644 firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt create mode 100644 firmware/esp32-csi-node/test/identity_stubs/esp_mac.h create mode 100644 firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h create mode 100644 firmware/esp32-csi-node/test/test_onboarding_identity.c create mode 100644 firmware/esp32-csi-node/tests/test_provision_ota_psk.py diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index dae86129d..91e5ccf14 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -16,6 +16,17 @@ on: - '.github/workflows/firmware-ci.yml' jobs: + provisioning-tests: + name: Provisioning tooling tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.11' + - name: Verify provisioning and secret handling without hardware + run: python -m unittest discover -s firmware/esp32-csi-node/tests -p 'test_provision*.py' -v + version-guard: name: Verify version.txt matches release tag runs-on: ubuntu-latest @@ -119,6 +130,10 @@ jobs: fi echo "Binary size OK: $SIZE <= $MAX" + - name: Verify built application version before packaging + working-directory: firmware/esp32-csi-node + run: python test/test_delivery_contract.py --built-app build/esp32-csi-node.bin + - name: Verify flash image integrity working-directory: firmware/esp32-csi-node run: | @@ -208,7 +223,7 @@ jobs: publish-release-assets: name: Publish firmware release assets if: github.ref_type == 'tag' - needs: [version-guard, build, build-c6-idf6] + needs: [version-guard, provisioning-tests, build, build-c6-idf6] runs-on: ubuntu-latest permissions: contents: write @@ -232,7 +247,12 @@ jobs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 with: name: ESP32 firmware ${{ github.ref_name }} + prerelease: true + make_latest: false body: | - Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles. + Exact-run ESP32-S3 display 8 MB, ESP32-S3 headless 4 MB, and ESP32-C6 4 MB firmware bundles. Verify each archive against `SHA256SUMS.txt` before flashing. + CI builds and host tests verify software behavior, not target hardware. + This prerelease has no new physical-board qualification or sensing-accuracy claim. + Select the matching board configuration using firmware/esp32-csi-node/README.md. files: release-assets/* diff --git a/docs/user-guide.md b/docs/user-guide.md index 69324034e..f17429698 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1911,31 +1911,51 @@ A 3-6 node ESP32-S3 mesh provides full CSI at 20 Hz. Total cost: ~$54 for a 3-no **Flashing firmware:** -Pre-built binaries are available at [Releases](https://github.com/ruvnet/RuView/releases): +Use versioned bundles from [Releases](https://github.com/ruvnet/RuView/releases), +not the historical binaries in `firmware/esp32-csi-node/release_bins/`. +The 0.8.14 prerelease fixes USB device identity and adds OTA key provisioning. +It has CI build/host-test coverage; new physical-board qualification remains +pending. The previous stable firmware is 0.8.8. | Release | What It Includes | Tag | |---------|-----------------|-----| -| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32) | **Latest — ADR-110 firmware-side substrate closed.** Adds ESP-NOW mesh substrate with quantified ≤100 µs alignment (104.1 µs smoothed stdev, 3.95× suppression, 99.56 % cross-board match measured live), 32-byte sync-packet UDP emission with operator-tunable cadence, ADR-018 byte 19 bit 4 wire-fix sourced from working ESP-NOW path, Python SyncPacketParser stub for host wiring ([WITNESS-LOG-110 §A0.7-§A0.13](WITNESS-LOG-110.md)) | `v0.7.0-esp32` | +| [v0.8.14](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32) | **Prerelease:** corrected C6 base-MAC identity, OTA key provisioning without a host key cache; physical qualification pending | `v0.8.14-esp32` | +| [v0.8.8](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32) | Previous stable firmware: consistent occupancy and rate-aware C6 processing; see [release evidence](releases/v0.8.8-esp32.md) | `v0.8.8-esp32` | +| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32) | Historical ADR-110 firmware-side substrate: ESP-NOW synchronization and sync-packet UDP emission ([WITNESS-LOG-110 §A0.7-§A0.13](WITNESS-LOG-110.md)) | `v0.7.0-esp32` | | [v0.6.9](https://github.com/ruvnet/RuView/releases/tag/v0.6.9-esp32) | Sync-packet UDP emission, `CONFIG_C6_SYNC_EVERY_N_FRAMES` tunable cadence | `v0.6.9-esp32` | | [v0.6.8](https://github.com/ruvnet/RuView/releases/tag/v0.6.8-esp32) | ESP-NOW EMA-smoothed cross-board offset (3.95× suppression, 104 µs stdev) | `v0.6.8-esp32` | | [v0.6.7](https://github.com/ruvnet/RuView/releases/tag/v0.6.7-esp32) | Real LP-core motion-gate RISC-V program (B4 code path complete) + Wi-Fi 6 soft-AP with TWT Responder for two-board iTWT benches (B1/B2 unblock) | `v0.6.7-esp32` | -| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | **Stable (S3 mesh, recommended)** — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` | +| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | Historical S3 mesh release — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` | | [v0.4.3.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) | Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash ([#265](https://github.com/ruvnet/RuView/issues/265)), watchdog fix ([#266](https://github.com/ruvnet/RuView/issues/266)) | `v0.4.3.1-esp32` | | [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](../docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` | | [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-alpha-esp32) | Alpha — adds on-device edge intelligence (ADR-039) | `v0.3.0-alpha-esp32` | | [v0.2.0](https://github.com/ruvnet/RuView/releases/tag/v0.2.0-esp32) | Raw CSI streaming, TDM, channel hopping, QUIC mesh | `v0.2.0-esp32` | -> **Important:** Always use **v0.4.3.1 or later**. Earlier versions have false fall detection alerts (v0.4.2 and below) and CSI disabled in the build config (pre-v0.4.1). +For 0.8.14, select the correct archive, verify its release `SHA256SUMS.txt`, +extract into a separate directory, then verify the enclosed checksums: + +| Archive | Board configuration | +|---------|---------------------| +| `esp32-csi-node-firmware-8mb.tar.gz` | ESP32-S3 display build, 8 MB flash | +| `esp32-csi-node-firmware-4mb.tar.gz` | ESP32-S3 headless build, 4 MB layout | +| `esp32-csi-node-firmware-c6-4mb.tar.gz` | ESP32-C6 research target, 4 MB layout | + +Bare 8 MB DevKitC boards need the source `sdkconfig.defaults.devkitc` overlay; +do not use the display build. See the [firmware guide](../firmware/esp32-csi-node/README.md#0-download-a-versioned-firmware-bundle) +for board selection, C6 filenames, backup and OTA guidance. ```bash -# Flash an ESP32-S3 with 8MB flash (most boards) +# From the extracted ESP32-S3 display / 8 MB bundle: python -m esptool --chip esp32s3 --port COM7 --baud 460800 \ write-flash --flash-mode dio --flash-size 8MB --flash-freq 80m \ 0x0 bootloader.bin 0x8000 partition-table.bin \ 0xf000 ota_data_initial.bin 0x20000 esp32-csi-node.bin ``` -**4MB flash boards** (e.g. ESP32-S3 SuperMini 4MB): download `esp32-csi-node-s3-4mb.bin` + `partition-table-s3-4mb.bin` from the [v0.6.7 release](https://github.com/ruvnet/RuView/releases/tag/v0.6.7-esp32) (882 KB binary, 52 % partition slack) and use `--flash-size 4MB`: +**4 MB headless S3 boards:** use the matching application and partition table +from `esp32-csi-node-firmware-4mb.tar.gz` on the +[0.8.14 prerelease](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32) +and select `--flash-size 4MB`: ```bash python -m esptool --chip esp32s3 --port COM7 --baud 460800 \ @@ -1952,6 +1972,9 @@ python firmware/esp32-csi-node/provision.py --port COM7 \ ``` Replace `192.168.1.20` with the IP of the machine running the sensing server. +To enable authenticated OTA updates, add `--ota-psk-file` with a private +64-hex key file. The key must be resupplied on later NVS writes; it is never +cached in host state. See [OTA key provisioning](../firmware/esp32-csi-node/README.md#ota-key-provisioning). **Mesh key provisioning (secure mode):** diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index 52a19d9a1..f9e800fa0 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -19,7 +19,23 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr > | **Fall detection** | Phase acceleration threshold | Configurable sensitivity | > | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash | -## Firmware 0.8.12: guided Mac onboarding +## Firmware 0.8.14 prerelease: device identity and OTA provisioning + +Version 0.8.14 fixes the six-byte base-MAC input used for USB onboarding on +ESP32-C6, where the previous API wrote an eight-byte EUI-64 into that buffer. +The PSA SHA-256 algorithm and domain remain unchanged. Affected boards now +report a corrected `device_digest`; rediscover and reselect the physical board +after updating rather than accepting its old saved digest. + +The provisioner can also write the OTA authentication key to `security/ota_psk` +using `--ota-psk-file`. The key is never cached in host state. See +[OTA key provisioning](#ota-key-provisioning) before reprovisioning a node. + +This prerelease is built from the main source line. CI builds and host tests +do not establish physical-board qualification or sensing accuracy. The older +0.8.13 prerelease's unmerged watchdog experiments are not included. + +### Guided Mac onboarding (introduced in 0.8.12) Firmware 0.8.12 adds the protocol used by the RuView macOS Add Sensor workflow. A directly attached S3 or C6 can identify itself through a @@ -67,22 +83,34 @@ measured evidence and limitations. For users who want to get running fast. Detailed explanations follow in later sections. -### 0. Download the 0.8.8 release +### 0. Download a versioned firmware bundle Use the versioned source tag and binaries on the -[v0.8.8 ESP32 release page](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32). +[v0.8.14 ESP32 prerelease page](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32). +For the previous stable firmware and its hardware evidence, see +[v0.8.8](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32). Choose the package that names both your chip and flash size: | Package | Use it for | |---------|------------| -| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 8 MB flash | -| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 4 MB flash | -| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | Fresh ESP32-C6 installation using the supported 4 MB layout | +| `esp32-csi-node-firmware-8mb.tar.gz` | ESP32-S3 display configuration, 8 MB flash | +| `esp32-csi-node-firmware-4mb.tar.gz` | ESP32-S3 headless configuration, 4 MB layout | +| `esp32-csi-node-firmware-c6-4mb.tar.gz` | ESP32-C6 research target, 4 MB layout | Each bundle contains the matching bootloader, partition table, OTA metadata, -application, checksums, and a short flashing guide. Never flash an S3 bundle +application, version and checksums. Verify the archive using the release's +`SHA256SUMS.txt`, extract it into its own directory, then verify the enclosed +`SHA256SUMS.txt`. Never flash an S3 bundle onto a C6, or a C6 bundle onto an S3. +The 4 MB application/partition filenames are `esp32-csi-node-4mb.bin` and +`partition-table-4mb.bin`; C6 uses `esp32-csi-node-c6.bin` and +`partition-table-c6.bin`. Do not mix files from different archives. +Bare 8 MB DevKitC boards need a source build with `sdkconfig.defaults.devkitc` +as documented below; the 8 MB release bundle enables display support. +The tracked `release_bins/` files are historical snapshots and must not be +used as current firmware downloads (see issues #1899 and #2092). + Example for an 8 MB ESP32-S3 after extracting its bundle: ```bash @@ -181,9 +209,14 @@ password when run from a terminal. `--password` still works, but it shows up in ### 4. Start the sensing server ```bash -cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto +cd v2 +cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto \ + --udp-bind 0.0.0.0 --udp-allow 192.168.1.0/24 ``` +Use your nodes' subnet for `--udp-allow`. The default UDP listener is loopback +only, so LAN nodes need both the bind address and an explicit source allowlist. + ### 5. Open the UI Navigate to [http://localhost:3000](http://localhost:3000) in your browser. @@ -274,8 +307,8 @@ Adds real-time health and safety monitoring. drives the on-screen skeleton from signal-based heuristics (amplitude variance, motion-band power), not from learned keypoint inference. The repository does not ship pre-trained weights — see issues - [#509](../../issues/509) and [#506](../../issues/506) for context, and - [ADR-079](../../docs/adr/ADR-079-camera-supervised-pose-finetune.md) for + [#509](https://github.com/ruvnet/RuView/issues/509) and [#506](https://github.com/ruvnet/RuView/issues/506) for context, and + [ADR-079](../../docs/adr/ADR-079-camera-ground-truth-training.md) for the planned training path (phases P7-P9 are `Pending`). - The presence indicator is a calibrated variance threshold and **will false-positive** under strong RF interference from non-human sources @@ -462,6 +495,43 @@ dropped. Without a password flag the script prompts on a terminal; in scripts and CI it doesn't prompt and fails if no password was given now or saved earlier for that port. +### OTA key provisioning + +Create a random 256-bit key in a private file outside the repository. For +example, on POSIX: + +```bash +(umask 077; set -C; openssl rand -hex 32 > ~/.ruview-ota-psk) +python firmware/esp32-csi-node/provision.py --port COM7 \ + --ssid "MyWiFi" --password-file ~/.wifi-pass --target-ip 192.168.1.20 \ + --ota-psk-file ~/.ruview-ota-psk +``` + +The file must contain exactly 64 hexadecimal characters and optionally one +trailing newline. The tool rejects symlinks and non-regular files, bounds the +read, and checks owner-only POSIX permissions. On Windows, protect the file +with your user ACL; POSIX mode bits cannot enforce Windows ACLs. +`--ota-psk HEX` is also supported, but exposes the key in process arguments and +shell history. Key values are never printed by this tool, including `--state +--show-secrets`, and never saved in the per-board host JSON. + +Provisioning rewrites the **whole NVS partition**, not just the key supplied. +Pass the key again on every later provisioning run. Once this tool records +that OTA was configured, omission is refused unless `--clear-ota-psk` explicitly +disables OTA. An older board with no host record cannot be detected this way; +without a supplied key the tool warns that the rewrite disables OTA. Legacy +cached OTA keys are hidden from inspection and never reused. A successful +provisioning run removes them from saved state; read-only inspection leaves +the existing file unchanged. +Keep the key backed up securely. A dry-run binary or fallback CSV still +contains credentials and must remain private and be deleted after use. + +This writes `security/ota_psk` through the USB NVS workflow; it does not flash +application firmware. After reboot, verify the `OTA PSK loaded` log. The HTTP +server starting alone does not mean OTA authentication is armed. Uploads +remain fail-closed without a key; see the first-boot health check above after +an authenticated update. + ### NVS Key Reference #### Network Settings @@ -949,12 +1019,12 @@ This firmware implements or references the following ADRs: | ADR | Title | Status | |-----|-------|--------| -| [ADR-018](../../docs/adr/ADR-018-csi-binary-frame-format.md) | CSI binary frame format | Accepted | +| [ADR-018](../../docs/adr/ADR-018-esp32-dev-implementation.md) | CSI binary frame format | Accepted | | [ADR-029](../../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) | Channel hopping and TDM protocol | Accepted | | [ADR-039](../../docs/adr/ADR-039-esp32-edge-intelligence.md) | Edge intelligence tiers 0-2 | Accepted | | [ADR-040](../../docs/adr/) | WASM programmable sensing (Tier 3) with RVF container format | Alpha | -| [ADR-057](../../docs/adr/ADR-057-build-time-csi-guard.md) | Build-time CSI guard (`CONFIG_ESP_WIFI_CSI_ENABLED`) | Accepted | -| [ADR-060](../../docs/adr/ADR-060-channel-mac-filter.md) | Channel override and MAC address filter | Accepted | +| [ADR-057](../../docs/adr/ADR-057-firmware-csi-build-guard.md) | Build-time CSI guard (`CONFIG_ESP_WIFI_CSI_ENABLED`) | Accepted | +| [ADR-060](../../docs/adr/ADR-060-provision-channel-mac-filter.md) | Channel override and MAC address filter | Accepted | | [ADR-061](../../docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) | QEMU ESP32-S3 emulation for firmware testing | Proposed | --- diff --git a/firmware/esp32-csi-node/main/onboarding_identity.h b/firmware/esp32-csi-node/main/onboarding_identity.h new file mode 100644 index 000000000..675e80536 --- /dev/null +++ b/firmware/esp32-csi-node/main/onboarding_identity.h @@ -0,0 +1,39 @@ +#ifndef RUVIEW_ONBOARDING_IDENTITY_H +#define RUVIEW_ONBOARDING_IDENTITY_H + +#include +#include +#include + +#include "esp_mac.h" +#include "psa/crypto.h" + +/* Shared with the host regression test; hashing remains the IDF PSA API. + * esp_efuse_mac_get_default writes an eight-byte EUI-64 on C6/H2. The base + * MAC API always returns the six-byte identity required by ADR-363. */ +static inline void ruview_onboarding_device_digest(char output[17]) +{ + uint8_t base_mac[6] = {0}; + uint8_t digest[32] = {0}; + static const uint8_t domain[] = "ruview-device-v1"; + uint8_t digest_input[sizeof(domain) - 1 + sizeof(base_mac)]; + size_t digest_length = 0; + memcpy(digest_input, domain, sizeof(domain) - 1); + const esp_err_t mac_result = esp_base_mac_addr_get(base_mac); + if (mac_result == ESP_OK) { + memcpy(digest_input + sizeof(domain) - 1, base_mac, sizeof(base_mac)); + } + if (mac_result == ESP_OK && + psa_crypto_init() == PSA_SUCCESS && + psa_hash_compute(PSA_ALG_SHA_256, digest_input, sizeof(digest_input), + digest, sizeof(digest), &digest_length) == PSA_SUCCESS && + digest_length == sizeof(digest)) { + for (size_t index = 0; index < 8; index++) { + snprintf(output + index * 2, 3, "%02x", digest[index]); + } + } else { + memcpy(output, "0000000000000000", 17); + } +} + +#endif diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index 3ccfdff88..fe634057e 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -40,7 +40,7 @@ static char s_ota_psk[OTA_PSK_MAX_LEN] = {0}; * Returns true only when a PSK is provisioned AND the Bearer token * matches it. An unprovisioned node refuses all OTA requests * (fail-closed, see RuView#596 audit). The OTA server still starts so - * the operator can `provision.py --ota-psk ` over USB-CDC without + * the operator can `provision.py --ota-psk-file ` over USB without * a reflash, but the upload endpoint will reject every request until * the PSK is set. */ @@ -53,7 +53,7 @@ static bool ota_check_auth(httpd_req_t *req) * Plain HTTP transport + no Secure Boot V2 + no signed-image * verification meant a single LAN call could brick or back- * door a node. Reject until provisioned. */ - ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk )"); + ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk-file )"); return false; } @@ -319,7 +319,7 @@ static void ota_load_psk_from_nvs(void) ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1); } else { ESP_LOGW(TAG, "No OTA PSK in NVS — OTA upload endpoint will REJECT all requests until " - "provisioned (provision.py --ota-psk ). Fail-closed per RuView#596."); + "provisioned (provision.py --ota-psk-file ). Fail-closed per RuView#596."); } nvs_close(nvs); } else { diff --git a/firmware/esp32-csi-node/main/serial_onboarding.c b/firmware/esp32-csi-node/main/serial_onboarding.c index be706cbaa..8529f3f76 100644 --- a/firmware/esp32-csi-node/main/serial_onboarding.c +++ b/firmware/esp32-csi-node/main/serial_onboarding.c @@ -1,6 +1,7 @@ #include "serial_onboarding.h" #include "serial_onboarding_protocol.h" +#include "onboarding_identity.h" #include #include @@ -17,7 +18,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "nvs.h" -#include "psa/crypto.h" static const char *TAG = "serial_onboard"; static nvs_config_t s_current_config; @@ -38,36 +38,11 @@ static const char *chip_name(void) #endif } -static void device_digest(char output[17]) -{ - uint8_t base_mac[6] = {0}; - uint8_t digest[32] = {0}; - static const uint8_t domain[] = "ruview-device-v1"; - uint8_t digest_input[sizeof(domain) - 1 + sizeof(base_mac)]; - size_t digest_length = 0; - memcpy(digest_input, domain, sizeof(domain) - 1); - const esp_err_t mac_result = esp_efuse_mac_get_default(base_mac); - if (mac_result == ESP_OK) { - memcpy(digest_input + sizeof(domain) - 1, base_mac, sizeof(base_mac)); - } - if (mac_result == ESP_OK && - psa_crypto_init() == PSA_SUCCESS && - psa_hash_compute(PSA_ALG_SHA_256, digest_input, sizeof(digest_input), - digest, sizeof(digest), &digest_length) == PSA_SUCCESS && - digest_length == sizeof(digest)) { - for (size_t index = 0; index < 8; index++) { - snprintf(output + index * 2, 3, "%02x", digest[index]); - } - } else { - memcpy(output, "0000000000000000", 17); - } -} - static void emit_hello(const char *nonce) { const esp_app_desc_t *description = esp_app_get_description(); char digest[17] = {0}; - device_digest(digest); + ruview_onboarding_device_digest(digest); const bool configured = s_current_config.wifi_ssid[0] != '\0' && s_current_config.target_ip[0] != '\0'; printf("RUVIEW_HELLO_OK_V1 nonce=%s chip=%s version=%s node_id=%u " diff --git a/firmware/esp32-csi-node/provision.py b/firmware/esp32-csi-node/provision.py index 1c3730ba5..46a7aab31 100644 --- a/firmware/esp32-csi-node/provision.py +++ b/firmware/esp32-csi-node/provision.py @@ -68,6 +68,8 @@ NVS_PARTITION_SIZE = 0x6000 # 24 KiB CONFIG_VALUE_CHECKS = [ ("ssid", bool), ("password", lambda value: value is not None), + ("ota_psk", lambda value: value is not None), + ("clear_ota_psk", bool), ("target_ip", bool), ("target_port", lambda value: value is not None), ("node_id", lambda value: value is not None), @@ -118,6 +120,9 @@ MERGEABLE_ATTRS = [ "seed_url", "seed_token", "zone", "swarm_hb", "swarm_ingest", ] +# Cache only whether this tool armed OTA, never the firmware-upload credential. +OTA_PSK_STATE_KEY = "_ota_psk_configured" + def _default_state_dir() -> str: """Per-user config dir for provision-state JSON files.""" @@ -260,6 +265,43 @@ def read_password_file(path: str, allow_insecure: bool = False) -> str: return text +def validate_ota_psk(value: str) -> str: + """A 256-bit hex token matching the firmware's 65-byte NVS buffer.""" + if not isinstance(value, str) or re.fullmatch(r"[0-9a-fA-F]{64}", value) is None: + raise ValueError("OTA PSK must contain exactly 64 hexadecimal characters") + return value + + +def read_ota_psk_file(path: str) -> str: + """Read a bounded regular file; enforce POSIX privacy and refuse symlinks.""" + try: + before = os.lstat(path) + if not stat.S_ISREG(before.st_mode): + raise ValueError("--ota-psk-file must be a regular file, not a symlink") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + flags |= getattr(os, "O_BINARY", 0) + with os.fdopen(os.open(path, flags), "rb") as stream: + opened = os.fstat(stream.fileno()) + if (not stat.S_ISREG(opened.st_mode) + or (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino)): + raise ValueError("--ota-psk-file changed while opening") + if sys.platform != "win32" and stat.S_IMODE(opened.st_mode) & 0o077: + raise ValueError("--ota-psk-file must be owner-only; run chmod 600 on it") + # 64 hex bytes plus an optional LF/CRLF; one extra byte detects overflow. + data = stream.read(67) + except OSError: + raise ValueError("could not open --ota-psk-file as a regular file") from None + try: + value = data.decode("ascii") + except UnicodeDecodeError: + raise ValueError("OTA PSK file must contain ASCII hexadecimal text") from None + if value.endswith("\r\n"): + value = value[:-2] + elif value.endswith("\n"): + value = value[:-1] + return validate_ota_psk(value) + + def load_state(port: str, state_dir: str) -> dict: """Return the merged-state dict for `port`, or `{}` if absent / unreadable.""" path = _state_path_for(port, state_dir) @@ -269,6 +311,11 @@ def load_state(port: str, state_dir: str) -> dict: with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): + if "ota_psk" in data: + # Some earlier external provisioners cached this credential. + # Drop it from inspection/merge without mutating a read-only + # request. The next successful state write scrubs the file. + data[OTA_PSK_STATE_KEY] = bool(data.pop("ota_psk")) return data except (OSError, json.JSONDecodeError) as exc: print(f"WARNING: could not read state file {path}: {exc}", file=sys.stderr) @@ -280,6 +327,9 @@ def save_state(port: str, state_dir: str, state: dict) -> str: The file holds secrets, so it is written 0600 inside a 0700 dir (#1754). """ + state = dict(state) + if "ota_psk" in state: + state[OTA_PSK_STATE_KEY] = bool(state.pop("ota_psk")) os.makedirs(state_dir, mode=STATE_DIR_MODE, exist_ok=True) harden_state_dir(state_dir) path = _state_path_for(port, state_dir) @@ -421,6 +471,8 @@ def merge_state_into_args(args, prior: dict) -> dict: in place so downstream `build_nvs_csv` sees the merged values. """ merged = dict(prior) + if "ota_psk" in merged: + merged[OTA_PSK_STATE_KEY] = bool(merged.pop("ota_psk")) for name in MERGEABLE_ATTRS: cli_val = getattr(args, name, None) if cli_val is not None: @@ -431,7 +483,7 @@ def merge_state_into_args(args, prior: dict) -> dict: def build_nvs_csv(args): - """Build an NVS CSV string for the csi_cfg namespace.""" + """Build csi_cfg and the optional security/ota_psk namespace.""" buf = io.StringIO() writer = csv.writer(buf) writer.writerow(["key", "type", "encoding", "value"]) @@ -490,6 +542,9 @@ 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)]) + if getattr(args, "ota_psk", None) is not None: + writer.writerow(["security", "namespace", "", ""]) + writer.writerow(["ota_psk", "data", "string", validate_ota_psk(args.ota_psk)]) return buf.getvalue() @@ -526,7 +581,7 @@ def generate_nvs_binary(csv_content, size): subprocess.check_call([ # nosemgrep: dangerous-subprocess-use-tainted-env-args sys.executable, gen_script, "generate", csv_path, bin_path, hex(size) - ]) + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) with open(bin_path, "rb") as f: return f.read() @@ -595,6 +650,16 @@ def main(): parser.add_argument("--allow-insecure-password-file", action="store_true", help="Accept a --password-file that group or others can read, with a " "warning (e.g. a read-only 0444 secrets mount).") + ota_source = parser.add_mutually_exclusive_group() + ota_source.add_argument("--ota-psk", metavar="HEX", + help="OTA key: exactly 64 hex characters. Visible in process " + "arguments/history; prefer --ota-psk-file. Never cached in state.") + ota_source.add_argument("--ota-psk-file", metavar="PATH", + help="Read the 64-hex OTA key from a regular file. Requires " + "owner-only POSIX permissions; protect Windows files with ACLs.") + ota_source.add_argument("--clear-ota-psk", action="store_true", + help="Explicitly remove the OTA key during this NVS rewrite; " + "firmware updates will be refused until a key is provisioned.") parser.add_argument("--target-ip", help="Aggregator host IP (e.g. 192.168.1.20)") parser.add_argument("--target-port", type=int, help="Aggregator UDP port (default: 5005)") parser.add_argument("--node-id", type=int, help="Node ID 0-255 (default: 1)") @@ -653,6 +718,16 @@ def main(): # or early exit (#1754). harden_state_dir(args.state_dir) + try: + if args.ota_psk_file is not None: + args.ota_psk = read_ota_psk_file(args.ota_psk_file) + elif args.ota_psk is not None: + args.ota_psk = validate_ota_psk(args.ota_psk) + print("WARNING: --ota-psk is visible in process arguments and shell history; " + "use --ota-psk-file instead.", file=sys.stderr) + except ValueError as exc: + parser.error(str(exc)) + if args.password_file is not None: try: args.password = read_password_file( @@ -708,6 +783,10 @@ def main(): f"board last provisioned on {args.port}, rerun with --reset.", file=sys.stderr) merged = merge_state_into_args(args, prior) + if args.ota_psk is not None: + merged[OTA_PSK_STATE_KEY] = True + elif args.clear_ota_psk: + merged[OTA_PSK_STATE_KEY] = False # A new --ssid with no password given: ask for it on a terminal instead of # requiring it on the command line. The saved password is reused only when @@ -726,6 +805,14 @@ def main(): print("Secrets hidden; pass --show-secrets to print them.", file=sys.stderr) return + if args.ota_psk is None: + if merged.get(OTA_PSK_STATE_KEY) and not args.clear_ota_psk: + parser.error("OTA was previously configured. Resupply --ota-psk-file (keys " + "are not cached), or pass --clear-ota-psk to disable OTA.") + print("WARNING: this rewrites the whole NVS partition without an OTA key. " + "OTA uploads will be disabled; supply --ota-psk-file to keep them enabled.", + file=sys.stderr) + if not has_config_value(args): parser.error( "At least one config value must be specified (after merging prior state). " @@ -825,7 +912,8 @@ def main(): try: nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE) except Exception as e: - print(f"\nError generating NVS binary: {e}", file=sys.stderr) + detail = str(e).replace(args.ota_psk, "(redacted)") if args.ota_psk else str(e) + print(f"\nError generating NVS binary: {detail}", file=sys.stderr) print("\nFallback: save CSV and flash manually with ESP-IDF tools.", file=sys.stderr) fallback_path = "nvs_config.csv" _write_private(fallback_path, csv_content.encode("utf-8")) diff --git a/firmware/esp32-csi-node/release_bins/README.md b/firmware/esp32-csi-node/release_bins/README.md new file mode 100644 index 000000000..5aede3904 --- /dev/null +++ b/firmware/esp32-csi-node/release_bins/README.md @@ -0,0 +1,16 @@ +# Historical firmware snapshots + +These tracked files are historical artifacts, not the current release channel. +Do not select them for a new installation: their board configurations and +versions differ, and older builds can produce insufficient CSI traffic. + +Use the versioned, checksummed bundles on the +[ESP32 0.8.14 prerelease page](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32) +or the [previous stable 0.8.8 release](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32). +See the [firmware guide](../README.md#0-download-a-versioned-firmware-bundle) +for chip, flash-size and display selection. The 0.8.14 prerelease requires +physical-board qualification; CI build success is not hardware validation. + +`c6-onboarding/` remains the historical 0.8.12 snapshot. Its local `version.txt` +and `SHA256SUMS.txt` describe that snapshot, independently of current source. +No tracked binary is updated as part of a source-only firmware release. diff --git a/firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt b/firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt new file mode 100644 index 000000000..7eff8ab95 --- /dev/null +++ b/firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt @@ -0,0 +1 @@ +0.8.12 diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index 12a7098c5..382081235 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -56,7 +56,7 @@ FUZZ_JOBS ?= 1 test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \ test_thermal run_thermal test_csi_sanitize run_csi_sanitize \ test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \ - run_delivery_contract test_ota_health run_ota_health + run_delivery_contract test_ota_health run_ota_health run_onboarding_identity all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \ @@ -134,6 +134,12 @@ test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onb run_serial_onboarding: test_serial_onboarding ./test_serial_onboarding +test_onboarding_identity: test_onboarding_identity.c $(MAIN_DIR)/onboarding_identity.h identity_stubs/esp_mac.h identity_stubs/psa/crypto.h + cc -std=c11 -Wall -Wextra -Werror -pedantic -Iidentity_stubs -I$(MAIN_DIR) -o $@ $< + +run_onboarding_identity: test_onboarding_identity + ./test_onboarding_identity + # --- OTA first-boot health check (ADR-379) --- # Host-side. Exercises ota_health_step() from ../main/ota_health.h directly -- # the decision the device runs to confirm or roll back an OTA'd image. @@ -147,7 +153,7 @@ run_delivery_contract: python3 test_delivery_contract.py host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \ - run_c6_antenna run_serial_onboarding run_delivery_contract run_ota_health + run_c6_antenna run_serial_onboarding run_onboarding_identity run_delivery_contract run_ota_health @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract + OTA health)" # --- Serialize fuzzer --- @@ -186,5 +192,5 @@ run_all: run_serialize run_edge run_nvs clean: rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \ - test_ota_health + test_ota_health test_onboarding_identity rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/identity_stubs/esp_mac.h b/firmware/esp32-csi-node/test/identity_stubs/esp_mac.h new file mode 100644 index 000000000..9c1898be1 --- /dev/null +++ b/firmware/esp32-csi-node/test/identity_stubs/esp_mac.h @@ -0,0 +1,8 @@ +#ifndef RUVIEW_TEST_ESP_MAC_H +#define RUVIEW_TEST_ESP_MAC_H +#include +typedef int esp_err_t; +#define ESP_OK 0 +esp_err_t esp_base_mac_addr_get(uint8_t *mac); +esp_err_t esp_efuse_mac_get_default(uint8_t *mac); +#endif diff --git a/firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h b/firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h new file mode 100644 index 000000000..bffeaa3d8 --- /dev/null +++ b/firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h @@ -0,0 +1,13 @@ +#ifndef RUVIEW_TEST_PSA_CRYPTO_H +#define RUVIEW_TEST_PSA_CRYPTO_H +#include +#include +typedef int psa_status_t; +typedef uint32_t psa_algorithm_t; +#define PSA_SUCCESS 0 +#define PSA_ALG_SHA_256 ((psa_algorithm_t)0x02000009) +psa_status_t psa_crypto_init(void); +psa_status_t psa_hash_compute(psa_algorithm_t algorithm, + const uint8_t *input, size_t input_length, + uint8_t *hash, size_t hash_size, size_t *hash_length); +#endif diff --git a/firmware/esp32-csi-node/test/test_delivery_contract.py b/firmware/esp32-csi-node/test/test_delivery_contract.py index adcf6daa5..69c89161f 100644 --- a/firmware/esp32-csi-node/test/test_delivery_contract.py +++ b/firmware/esp32-csi-node/test/test_delivery_contract.py @@ -2,6 +2,10 @@ """Static delivery contracts that do not require an ESP-IDF toolchain.""" from pathlib import Path +import argparse +import hashlib +import sys +import tempfile import unittest @@ -9,29 +13,60 @@ FIRMWARE_ROOT = Path(__file__).resolve().parents[1] REPOSITORY_ROOT = FIRMWARE_ROOT.parents[1] +def app_version(path: Path) -> str: + """Read the first segment's ESP-IDF app descriptor, not arbitrary strings. + + IDF image header + segment header occupy 32 bytes; esp_app_desc_t has + magic 0xABCD5432 and a 32-byte version at descriptor offset 16. + """ + with path.open("rb") as stream: + header = stream.read(80) + if (len(header) != 80 or header[0] != 0xE9 + or header[32:36] != bytes.fromhex("3254cdab")): + raise ValueError("missing ESP-IDF application descriptor") + return header[48:80].split(b"\0", 1)[0].decode("ascii") + + class FirmwareDeliveryContractTests(unittest.TestCase): def test_c6_uses_native_usb_serial_jtag_as_primary_console(self) -> None: - config = (FIRMWARE_ROOT / "sdkconfig.defaults.esp32c6").read_text() + config = (FIRMWARE_ROOT / "sdkconfig.defaults.esp32c6").read_text(encoding="utf-8") self.assertIn("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y", config) self.assertNotIn("CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y", config) - onboarding = (FIRMWARE_ROOT / "main/serial_onboarding.c").read_text() + onboarding = (FIRMWARE_ROOT / "main/serial_onboarding.c").read_text(encoding="utf-8") self.assertNotIn('open("/dev/secondary"', onboarding) - def test_tracked_c6_onboarding_app_matches_version_txt(self) -> None: - expected = (FIRMWARE_ROOT / "version.txt").read_text().strip().encode() - app = (FIRMWARE_ROOT / "release_bins/c6-onboarding/esp32-csi-node.bin").read_bytes() - self.assertTrue( - expected + b"\0" in app, - f"tracked C6 app does not embed version {expected.decode()}", - ) + def test_historical_c6_snapshot_matches_its_own_version_and_checksums(self) -> None: + snapshot = FIRMWARE_ROOT / "release_bins/c6-onboarding" + expected = (snapshot / "version.txt").read_text(encoding="utf-8").strip() + self.assertEqual(app_version(snapshot / "esp32-csi-node.bin"), expected) + for line in (snapshot / "SHA256SUMS.txt").read_text(encoding="ascii").splitlines(): + digest, filename = line.split() + self.assertEqual(hashlib.sha256((snapshot / filename).read_bytes()).hexdigest(), digest) + + def test_app_version_rejects_truncated_or_wrong_descriptor(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "app.bin" + for data in [b"", b"0.8.14\0" * 20, b"\xe9" + b"\0" * 79]: + path.write_bytes(data) + with self.assertRaises(ValueError): + app_version(path) def test_firmware_tags_publish_downloadable_release_assets(self) -> None: - workflow = (REPOSITORY_ROOT / ".github/workflows/firmware-ci.yml").read_text() + workflow = (REPOSITORY_ROOT / ".github/workflows/firmware-ci.yml").read_text(encoding="utf-8") self.assertIn("publish-release-assets:", workflow) self.assertIn("esp32-csi-node-firmware-*", workflow) self.assertIn("softprops/action-gh-release@", workflow) if __name__ == "__main__": - unittest.main() + parser = argparse.ArgumentParser() + parser.add_argument("--built-app", type=Path) + args, remaining = parser.parse_known_args() + if args.built_app: + expected = (FIRMWARE_ROOT / "version.txt").read_text(encoding="utf-8").strip() + actual = app_version(args.built_app) + if actual != expected: + raise SystemExit(f"built application version {actual!r} does not match {expected!r}") + print(f"PASS: built application descriptor version {actual}") + unittest.main(argv=[sys.argv[0], *remaining]) diff --git a/firmware/esp32-csi-node/test/test_onboarding_identity.c b/firmware/esp32-csi-node/test/test_onboarding_identity.c new file mode 100644 index 000000000..5ed9757eb --- /dev/null +++ b/firmware/esp32-csi-node/test/test_onboarding_identity.c @@ -0,0 +1,74 @@ +/* Exercises the production identity boundary with synthetic IDF/PSA seams. + * This verifies the complete six-byte MAC reaches PSA, not PSA's SHA-256 + * implementation or physical hardware. No replacement hash ships on device. */ +#include +#include "onboarding_identity.h" + +static uint8_t board_mac[6] = {0x02, 0x10, 0x20, 0x30, 0x40, 0x50}; +static int mac_result, init_result, hash_result; +static size_t hash_length = 32; +static unsigned mac_calls, hash_calls; + +esp_err_t esp_base_mac_addr_get(uint8_t *mac) +{ + mac_calls++; + memcpy(mac, board_mac, sizeof(board_mac)); + return mac_result; +} + +esp_err_t esp_efuse_mac_get_default(uint8_t *mac) +{ + (void)mac; + assert(!"EUI-64 API must never receive the six-byte identity buffer"); + return -1; +} + +psa_status_t psa_crypto_init(void) { return init_result; } + +psa_status_t psa_hash_compute(psa_algorithm_t algorithm, + const uint8_t *input, size_t input_length, + uint8_t *hash, size_t hash_size, size_t *length) +{ + static const uint8_t domain[] = "ruview-device-v1"; + hash_calls++; + assert(algorithm == PSA_ALG_SHA_256); + assert(input_length == sizeof(domain) - 1 + sizeof(board_mac)); + assert(memcmp(input, domain, sizeof(domain) - 1) == 0); + assert(memcmp(input + sizeof(domain) - 1, board_mac, sizeof(board_mac)) == 0); + assert(hash_size == 32); + /* Synthetic digest exposes lost tail bytes and output hex formatting. */ + memset(hash, 0xab, hash_size); + memcpy(hash, board_mac, sizeof(board_mac)); + *length = hash_length; + return hash_result; +} + +int main(void) +{ + char first[17], second[17]; + ruview_onboarding_device_digest(first); + assert(strcmp(first, "021020304050abab") == 0); + board_mac[5]++; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "021020304051abab") == 0); + assert(strcmp(first, second) != 0); + assert(mac_calls == 2 && hash_calls == 2); + + mac_result = -1; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0 && hash_calls == 2); + mac_result = 0; + init_result = -1; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0 && hash_calls == 2); + init_result = 0; + hash_result = -1; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0); + hash_result = 0; + hash_length = 31; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0); + puts("PASS: six-byte onboarding identity input and fail-closed output"); + return 0; +} diff --git a/firmware/esp32-csi-node/tests/test_provision_ota_psk.py b/firmware/esp32-csi-node/tests/test_provision_ota_psk.py new file mode 100644 index 000000000..9574ed2a7 --- /dev/null +++ b/firmware/esp32-csi-node/tests/test_provision_ota_psk.py @@ -0,0 +1,181 @@ +"""OTA provisioning regressions; all hardware and NVS generation are mocked.""" + +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +SPEC = importlib.util.spec_from_file_location( + "provision_ota", Path(__file__).resolve().parents[1] / "provision.py") +provision = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(provision) +FAKE_PSK = "ab" * 32 + + +class OtaProvisionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.state = str(self.root / "state") + self.base = ["--port", "COM7", "--state-dir", self.state, + "--ssid", "test-network", "--password", "synthetic-password", + "--target-ip", "192.0.2.1"] + self.csv = [] + + def tearDown(self): + self.temp.cleanup() + + def key_file(self, value=FAKE_PSK): + path = self.root / "key.txt" + path.write_bytes(value.encode("ascii")) + path.chmod(0o600) + return str(path) + + def run_main(self, *options, generator_error=None): + def generate(csv, _size): + self.csv.append(csv) + if generator_error: + raise generator_error + return b"synthetic-nvs" + + out, err = io.StringIO(), io.StringIO() + old_cwd = os.getcwd() + try: + os.chdir(self.root) + with mock.patch.object(sys, "argv", ["provision.py", *self.base, *options]), \ + mock.patch.object(provision, "read_chip_mac", return_value=None) as read_mac, \ + mock.patch.object(provision, "flash_nvs") as flash, \ + mock.patch.object(provision, "generate_nvs_binary", side_effect=generate), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = 0 + try: + provision.main() + except SystemExit as exc: + code = exc.code + return code, out.getvalue(), err.getvalue(), read_mac, flash + finally: + os.chdir(old_cwd) + + def test_key_reaches_security_namespace_but_never_state_or_output(self): + code, out, err, _, flash = self.run_main("--ota-psk-file", self.key_file()) + self.assertEqual(code, 0) + flash.assert_called_once() + rows = list(provision.csv.reader(io.StringIO(self.csv[-1]))) + namespace = rows.index(["security", "namespace", "", ""]) + self.assertEqual(rows[namespace + 1], ["ota_psk", "data", "string", FAKE_PSK]) + saved = Path(provision._state_path_for("COM7", self.state)).read_text() + self.assertNotIn(FAKE_PSK, saved + out + err) + self.assertNotIn('"ota_psk"', saved) + self.assertTrue(json.loads(saved)[provision.OTA_PSK_STATE_KEY]) + code, out, err, read_mac, flash = self.run_main("--state", "--show-secrets") + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, out + err) + read_mac.assert_not_called() + flash.assert_not_called() + + def test_known_key_must_be_resupplied_or_explicitly_removed(self): + self.run_main("--ota-psk-file", self.key_file()) + self.csv.clear() + code, _, err, _, flash = self.run_main("--node-id", "2") + self.assertEqual(code, 2) + self.assertIn("keys are not cached", err) + self.assertEqual(self.csv, []) + flash.assert_not_called() + code, _, _, _, flash = self.run_main("--clear-ota-psk") + self.assertEqual(code, 0) + flash.assert_called_once() + self.assertNotIn("security,namespace", self.csv[-1]) + self.assertFalse(provision.load_state("COM7", self.state)[provision.OTA_PSK_STATE_KEY]) + + def test_legacy_key_is_hidden_on_read_and_scrubbed_only_on_successful_write(self): + os.makedirs(self.state) + path = Path(provision._state_path_for("COM7", self.state)) + path.write_text(json.dumps({"ota_psk": FAKE_PSK, "node_id": 7})) + before = path.read_bytes() + with mock.patch.object(provision, "save_state", side_effect=PermissionError("read-only")) as save: + code, out, err, _, _ = self.run_main("--state", "--show-secrets") + save.assert_not_called() + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, out + err) + self.assertEqual(path.read_bytes(), before) + self.assertTrue(json.loads(out)[provision.OTA_PSK_STATE_KEY]) + self.assertEqual(json.loads(out)["node_id"], 7) + code, _, _, _, flash = self.run_main() + self.assertEqual(code, 2) + flash.assert_not_called() + self.assertEqual(path.read_bytes(), before) + code, _, _, _, _ = self.run_main("--ota-psk-file", self.key_file()) + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, path.read_text()) + + def test_direct_key_validation_rejects_before_hardware_without_echo(self): + for value in ["", "a", "a" * 63, "a" * 65, "z" * 64, " " + FAKE_PSK, + FAKE_PSK + "\n", "é" * 64]: + with self.subTest(length=len(value)): + code, out, err, read_mac, flash = self.run_main("--ota-psk", value) + self.assertEqual(code, 2) + self.assertIn("exactly 64 hexadecimal", err) + if len(value) > 20: + self.assertNotIn(value, out + err) + read_mac.assert_not_called() + flash.assert_not_called() + + def test_direct_key_and_standalone_key_count_as_configuration(self): + code, out, err, _, _ = self.run_main("--ota-psk", FAKE_PSK) + self.assertEqual(code, 0) + self.assertIn("process arguments", err) + self.assertNotIn(FAKE_PSK, out + err) + args = type("Args", (), {"ota_psk": FAKE_PSK})() + self.assertTrue(provision.has_config_value(args)) + + def test_file_validation_allows_one_newline_and_rejects_oversize(self): + for suffix in ["", "\n", "\r\n"]: + self.assertEqual(provision.read_ota_psk_file(self.key_file(FAKE_PSK + suffix)), FAKE_PSK) + for value in [FAKE_PSK + "\n\n", "a" * 10000, "short"]: + with self.assertRaises(ValueError): + provision.read_ota_psk_file(self.key_file(value)) + with self.assertRaises(ValueError): + provision.read_ota_psk_file(str(self.root)) + with self.assertRaises(ValueError): + provision.read_ota_psk_file(str(self.root / "missing")) + + @unittest.skipIf(sys.platform == "win32", "POSIX permission/symlink model") + def test_file_rejects_insecure_permissions_and_symlinks(self): + key = Path(self.key_file()) + key.chmod(0o644) + with self.assertRaisesRegex(ValueError, "owner-only"): + provision.read_ota_psk_file(str(key)) + key.chmod(0o600) + link = self.root / "key-link" + link.symlink_to(key) + with self.assertRaisesRegex(ValueError, "not a symlink"): + provision.read_ota_psk_file(str(link)) + + def test_dry_run_has_no_hardware_or_state_side_effects(self): + code, out, err, read_mac, flash = self.run_main("--ota-psk-file", self.key_file(), "--dry-run") + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, out + err) + read_mac.assert_not_called() + flash.assert_not_called() + self.assertFalse(Path(provision._state_path_for("COM7", self.state)).exists()) + + def test_generator_failure_redacts_key_from_diagnostic(self): + code, out, err, _, flash = self.run_main( + "--ota-psk-file", self.key_file(), generator_error=RuntimeError(FAKE_PSK)) + self.assertEqual(code, 1) + self.assertNotIn(FAKE_PSK, out + err) + flash.assert_not_called() + + def test_state_writer_strips_key_as_backstop(self): + path = provision.save_state("COM7", self.state, {"ota_psk": FAKE_PSK}) + self.assertNotIn(FAKE_PSK, Path(path).read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index 7eff8ab95..832bad274 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.8.12 +0.8.14