mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-22 06:32:35 -05:00
Merge pull request #2247 from ruvnet/codex/firmware-release-20261012
fix(firmware): correct identity and secure OTA provisioning for 0.8.14
This commit is contained in:
16 files changed
+618
-69
No files matched your search
@@ -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/*
|
||||
+29
-6
@@ -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):**
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#ifndef RUVIEW_ONBOARDING_IDENTITY_H
|
||||
#define RUVIEW_ONBOARDING_IDENTITY_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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
|
||||
@@ -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 <hex>` over USB-CDC without
|
||||
* the operator can `provision.py --ota-psk-file <path>` 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 <hex>)");
|
||||
ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk-file <path>)");
|
||||
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 <hex>). Fail-closed per RuView#596.");
|
||||
"provisioned (provision.py --ota-psk-file <path>). Fail-closed per RuView#596.");
|
||||
}
|
||||
nvs_close(nvs);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "serial_onboarding.h"
|
||||
|
||||
#include "serial_onboarding_protocol.h"
|
||||
#include "onboarding_identity.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
@@ -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 "
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
0.8.12
|
||||
@@ -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/
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef RUVIEW_TEST_ESP_MAC_H
|
||||
#define RUVIEW_TEST_ESP_MAC_H
|
||||
#include <stdint.h>
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
#ifndef RUVIEW_TEST_PSA_CRYPTO_H
|
||||
#define RUVIEW_TEST_PSA_CRYPTO_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
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
|
||||
@@ -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])
|
||||
@@ -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 <assert.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -1 +1 @@
|
||||
0.8.12
|
||||
0.8.14
|
||||
Reference in new issue
Block a user