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:
rUv authored and GitHub committed 2026-10-12 09:17:31 -07:00
commit 4413491370
16 files changed
+618 -69

No files matched your search

+22 -2
View File
@@ -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
View File
@@ -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):**
+83 -13
View File
@@ -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
+3 -3
View File
@@ -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 "
+91 -3
View File
@@ -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
+9 -3
View File
@@ -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
View File
@@ -1 +1 @@
0.8.12
0.8.14