diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index ffc3e55ae..defda2b01 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -92,14 +92,11 @@ jobs: idf.py set-target ${{ matrix.target }} idf.py build - - name: Build and run host-side ADR-110 unit tests + - name: Build and run host-side firmware unit tests if: matrix.variant == 'c6-4mb' working-directory: firmware/esp32-csi-node/test run: | - make test_adr110 - ./test_adr110 - make test_c6_antenna - ./test_c6_antenna + make host_tests - name: Verify binary size budget working-directory: firmware/esp32-csi-node diff --git a/docs/adr/ADR-348-native-macos-usb-node-onboarding.md b/docs/adr/ADR-348-native-macos-usb-node-onboarding.md new file mode 100644 index 000000000..400a4cccf --- /dev/null +++ b/docs/adr/ADR-348-native-macos-usb-node-onboarding.md @@ -0,0 +1,79 @@ +# ADR 348: Native macOS USB node onboarding + +## Status + +Accepted and physically qualified with one attached ESP32 S3 and an unsigned +Mac Catalyst development build. ESP32 C6 qualification and signed distribution +remain release gates. + +## Context + +RuView operators currently provision ESP32 nodes with command line tooling. +The macOS app can list server accepted node identities, but it cannot establish +that an attached serial device is RuView firmware, assign a collision free node +identity, preserve its WiFi configuration, or prove that it joined the sensing +server. A UI that merely stores a friendly name would create false success. + +Generic shell access from the app would also expand authority beyond the task. +Serial logs can contain operational data, and WiFi credentials must not enter +application diagnostics or provisioning receipts. + +## Decision + +1. Firmware 0.8.9 adds a bounded line protocol over the physical USB serial + channel. A discovery request carries a random 128 bit nonce. The device + response binds that nonce to chip family, firmware version, current node + identity, private server target, configuration state, and a pseudonymous + 64 bit device digest derived from the base MAC under a RuView domain. + +2. A configuration request is accepted only for the most recently issued + nonce within 60 seconds. The nonce is single use. Node identity is limited + to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port + is limited to 1 through 65535. + +3. Existing WiFi configuration can be preserved. New credentials use strict + base64 framing, bounded lengths, and control character rejection. Firmware + commits NVS before acknowledging and rebooting. Credentials are never + returned, logged, or included in the receipt. + +4. The macOS client uses a native serial bridge. It allowlists exact character + device prefixes, configures the port directly, and does not invoke a shell, + Python, or an external process. It rechecks the device digest immediately + before configuration to reduce port substitution risk. + +5. The app suggests the lowest unused server node identity and the Mac private + LAN address. Success is shown only after the configured node appears in the + server inventory as active with evidence no older than three seconds. + +6. Old firmware is reported as requiring an update. The app must not claim it + provisioned a device based only on generic RuView serial logs. + +7. Automatic firmware flashing is outside this decision. Flashing requires an + exact chip and port check, a private backup, a matching image, and captured + boot evidence. Release publication remains a separate maintainer action. + +## Security and privacy consequences + +The app gains Mac App Sandbox USB and serial device access, but not general file +or shell authority. The protocol does not authenticate a device with a hardware +root of trust; the digest is stable identification, not a cryptographic device +certificate. Physical USB possession is therefore the trust boundary for this +version. An authenticated fleet identity remains ADR 305 scope. + +The largest failure mode is a successful NVS write followed by failure to join +the LAN. The user receives an explicit bounded verification failure instead of +a success screen, while the board retains its committed settings and can be +rescanned over USB. + +## Acceptance test + +Connect an ESP32 with firmware 0.8.9 to a Mac that is already receiving RuView +nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the +lowest unused identity, and set the Mac private address. Pass when the UI +reports the expected chip and firmware, the reassigned node remains fresh for +five minutes, the stale collided identity expires, app restart preserves the +profile, no server health, send, watchdog, panic, or reboot evidence appears, +and no credential appears in app logs or the provisioning receipt. + +The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in +`docs/validation/2026-08-31-macos-usb-node-onboarding.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md index f1446db2b..f3eeef3a3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -115,6 +115,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) | | [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) | | [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) | +| [ADR-348](ADR-348-native-macos-usb-node-onboarding.md) | Native macOS USB node onboarding with nonce bound discovery and fresh server verification | Accepted and physically qualified on one ESP32 S3; C6 and signed distribution pending | | [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) | | [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed | | [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed | diff --git a/docs/validation/2026-08-31-macos-usb-node-onboarding.md b/docs/validation/2026-08-31-macos-usb-node-onboarding.md new file mode 100644 index 000000000..f513b5a94 --- /dev/null +++ b/docs/validation/2026-08-31-macos-usb-node-onboarding.md @@ -0,0 +1,50 @@ +# Native macOS USB onboarding firmware witness + +## Evidence class + +MEASURED on 2026-08-31 with one physical ESP32 S3. This is not an ESP32 C6 or +release distribution qualification. + +## Hardware and firmware + +The board was identified by the flashing tool as ESP32 S3 QFN56 revision 0.2 +with 2 MB embedded PSRAM and 8 MB flash. Firmware 0.8.9 was built for ESP32 S3 +with the displayless DevKitC defaults. The private configuration region was +backed up before flashing. The flash operation omitted NVS, preserving the +existing WiFi configuration. + +No base MAC, WiFi credential, device digest, or raw CSI is retained in this +witness. + +## Results + +1. The initial native USB scan failed because the ESP-IDF secondary USB + Serial/JTAG console mirrors output but does not forward input to STDIN. +2. Firmware was corrected to read the bounded onboarding protocol from the + secondary VFS while retaining STDIN for UART console boards. +3. A direct nonce challenge then returned ESP32 S3, firmware 0.8.9, configured + state, the current node identity, and the private server route. +4. The Mac app changed the collided identity from 1 to unused identity 5 while + preserving WiFi. It showed success only after receiving fresh server + evidence at minus 56 dBm. +5. A five minute monitor collected 299 samples with zero node 5 missing events, + zero node 5 stale events, zero server health failures, minus 57 to minus 50 + dBm RSSI, and 216 ms maximum packet age. +6. Firmware uptime exceeded 376 seconds. Captured logs advanced from CSI + callback 10,600 through 11,000, emitted a node 5 synchronization packet, and + showed ESP NOW transmit failures equal to zero without reboot, watchdog, or + panic evidence. +7. The final channel isolation hardening image was flashed and challenged + again. A following 60 sample check had zero missing, stale, or health failure + events, a maximum packet age of 100 ms, and minus 57 to minus 50 dBm RSSI. + +## Software gates + +The nonce and configuration parser host test passed, the complete ESP32 S3 +firmware image built under ESP-IDF 5.4, and the application image occupied +941,056 bytes with 55 percent of the smallest application partition free. + +## Remaining gates + +Physical ESP32 C6, new WiFi credential entry, signed Mac distribution, and +cross platform serial bridges remain unmeasured. diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index 382b841ad..28c0833bf 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -19,6 +19,21 @@ 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 | +## Development firmware 0.8.9: guided Mac onboarding + +The unreleased 0.8.9 source adds the protocol used by the RuView macOS Add +Sensor workflow. A directly attached S3 or C6 can identify itself through a +nonce bound USB serial receipt, preserve its current WiFi settings, accept an +unused node ID and private LAN server address, then reboot. The Mac app does +not report success until the sensing server observes that node as fresh. + +This is not a firmware flashing API. A board on 0.8.8 or earlier must receive a +matching chip build through the documented backup and flash process once. The +USB protocol rejects public target addresses and invalid identifiers. New WiFi +credentials are bounded, committed only in NVS, and never included in the +receipt or diagnostic output. See +[ADR 348](../../docs/adr/ADR-348-native-macos-usb-node-onboarding.md). + ## Firmware 0.8.8 in plain language Release 0.8.8 makes the sensing stream more internally consistent and easier diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index e70112167..8ca4ba16b 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -1,6 +1,7 @@ set(SRCS "main.c" "csi_collector.c" "thermal.c" "stream_sender.c" "nvs_config.c" + "serial_onboarding.c" "serial_onboarding_protocol.c" "edge_processing.c" "ota_update.c" "power_mgmt.c" "wasm_runtime.c" "wasm_upload.c" "rvf_parser.c" "mmwave_sensor.c" @@ -40,6 +41,7 @@ set(REQUIRES driver lwip mbedtls + efuse ) # ADR-110: C6-only components — pulled in when building for esp32c6. diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index 9431e86f7..4b6515151 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -24,6 +24,7 @@ #include "thermal.h" #include "stream_sender.h" #include "nvs_config.h" +#include "serial_onboarding.h" #include "edge_processing.h" #include "ota_update.h" #include "power_mgmt.h" @@ -336,6 +337,14 @@ void app_main(void) * ownership of GPIO3 and GPIO14 because the default implementation is a * no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */ ESP_ERROR_CHECK(c6_xiao_antenna_apply()); + /* The native Mac onboarding bridge must remain available even when WiFi + * credentials or the aggregator address are wrong. Start it before any + * blocking network initialization. The protocol is physical-USB-only, + * nonce-bound, bounded, and never prints credentials. */ + esp_err_t onboarding_ret = serial_onboarding_start(&g_nvs_config); + if (onboarding_ret != ESP_OK) { + ESP_LOGW(TAG, "USB onboarding unavailable: %s", esp_err_to_name(onboarding_ret)); + } /* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0) * or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the diff --git a/firmware/esp32-csi-node/main/nvs_config.c b/firmware/esp32-csi-node/main/nvs_config.c index dd4517ef2..5febe73cc 100644 --- a/firmware/esp32-csi-node/main/nvs_config.c +++ b/firmware/esp32-csi-node/main/nvs_config.c @@ -109,7 +109,7 @@ void nvs_config_load(nvs_config_t *cfg) len = sizeof(buf); if (nvs_get_str(handle, "ssid", buf, &len) == ESP_OK && len > 1) { strlcpy(cfg->wifi_ssid, buf, sizeof(cfg->wifi_ssid)); - ESP_LOGI(TAG, "NVS override: ssid=%s", cfg->wifi_ssid); + ESP_LOGI(TAG, "NVS override: ssid="); } /* WiFi password */ diff --git a/firmware/esp32-csi-node/main/serial_onboarding.c b/firmware/esp32-csi-node/main/serial_onboarding.c new file mode 100644 index 000000000..af560ddc4 --- /dev/null +++ b/firmware/esp32-csi-node/main/serial_onboarding.c @@ -0,0 +1,200 @@ +#include "serial_onboarding.h" + +#include "serial_onboarding_protocol.h" + +#include +#include +#include +#include +#include + +#include "esp_app_desc.h" +#include "esp_efuse.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "mbedtls/sha256.h" +#include "nvs.h" + +static const char *TAG = "serial_onboard"; +static nvs_config_t s_current_config; +static char s_issued_nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1]; +static int64_t s_nonce_issued_at_us; + +#define ONBOARDING_LINE_MAX 384 +#define ONBOARDING_NONCE_TTL_US (60LL * 1000LL * 1000LL) + +static const char *chip_name(void) +{ +#if defined(CONFIG_IDF_TARGET_ESP32C6) + return "esp32c6"; +#elif defined(CONFIG_IDF_TARGET_ESP32S3) + return "esp32s3"; +#else + return "esp32"; +#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"; + mbedtls_sha256_context context; + mbedtls_sha256_init(&context); + if (esp_efuse_mac_get_default(base_mac) == ESP_OK && + mbedtls_sha256_starts(&context, 0) == 0 && + mbedtls_sha256_update(&context, domain, sizeof(domain) - 1) == 0 && + mbedtls_sha256_update(&context, base_mac, sizeof(base_mac)) == 0 && + mbedtls_sha256_finish(&context, digest) == 0) { + for (size_t index = 0; index < 8; index++) { + snprintf(output + index * 2, 3, "%02x", digest[index]); + } + } else { + memcpy(output, "0000000000000000", 17); + } + mbedtls_sha256_free(&context); +} + +static void emit_hello(const char *nonce) +{ + const esp_app_desc_t *description = esp_app_get_description(); + char digest[17] = {0}; + 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 " + "target_ip=%s target_port=%u configured=%u device_digest=%s\n", + nonce, chip_name(), description->version, + (unsigned)s_current_config.node_id, s_current_config.target_ip, + (unsigned)s_current_config.target_port, configured ? 1U : 0U, digest); + fflush(stdout); +} + +static esp_err_t commit_config(const ruview_onboarding_config_t *config) +{ + nvs_handle_t handle; + esp_err_t result = nvs_open("csi_cfg", NVS_READWRITE, &handle); + if (result != ESP_OK) return result; + if (!config->preserve_wifi) { + result = nvs_set_str(handle, "ssid", config->ssid); + if (result == ESP_OK) result = nvs_set_str(handle, "password", config->password); + } + if (result == ESP_OK) result = nvs_set_str(handle, "target_ip", config->target_ip); + if (result == ESP_OK) result = nvs_set_u16(handle, "target_port", config->target_port); + if (result == ESP_OK) result = nvs_set_u8(handle, "node_id", config->node_id); + if (result == ESP_OK) result = nvs_commit(handle); + nvs_close(handle); + return result; +} + +static void process_line(char *line) +{ + char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1] = {0}; + if (ruview_onboarding_parse_hello(line, nonce)) { + memcpy(s_issued_nonce, nonce, sizeof(s_issued_nonce)); + s_nonce_issued_at_us = esp_timer_get_time(); + emit_hello(nonce); + return; + } + if (strncmp(line, "RUVIEW_CONFIG_V1 ", 17) != 0) return; + + ruview_onboarding_config_t request; + const ruview_onboarding_result_t parsed = ruview_onboarding_parse_config(line, &request); + if (parsed != RUVIEW_ONBOARDING_OK) { + printf("RUVIEW_CONFIG_ERR_V1 reason=%s\n", ruview_onboarding_result_name(parsed)); + fflush(stdout); + return; + } + const int64_t age_us = esp_timer_get_time() - s_nonce_issued_at_us; + if (s_issued_nonce[0] == '\0' || strcmp(request.nonce, s_issued_nonce) != 0 || + age_us < 0 || age_us > ONBOARDING_NONCE_TTL_US) { + printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=claim_expired\n", request.nonce); + fflush(stdout); + return; + } + s_issued_nonce[0] = '\0'; + const esp_err_t result = commit_config(&request); + if (result != ESP_OK) { + printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=nvs_commit code=%s\n", + request.nonce, esp_err_to_name(result)); + fflush(stdout); + return; + } + + printf("RUVIEW_CONFIG_OK_V1 nonce=%s node_id=%u rebooting=1\n", + request.nonce, (unsigned)request.node_id); + fflush(stdout); + vTaskDelay(pdMS_TO_TICKS(400)); + esp_restart(); +} + +static void consume_bytes(const uint8_t *input, ssize_t count, char *line, + size_t *length, bool *overflow) +{ + for (ssize_t index = 0; index < count; index++) { + const uint8_t byte = input[index]; + if (byte == '\r') continue; + if (byte == '\n') { + if (!*overflow && *length > 0) { + line[*length] = '\0'; + process_line(line); + } + *length = 0; + *overflow = false; + continue; + } + if (byte < 0x20 || byte == 0x7f) continue; + if (*length + 1 < ONBOARDING_LINE_MAX) line[(*length)++] = (char)byte; + else *overflow = true; + } +} + +static void serial_onboarding_task(void *argument) +{ + (void)argument; + const int original_flags = fcntl(STDIN_FILENO, F_GETFL, 0); + if (original_flags >= 0) (void)fcntl(STDIN_FILENO, F_SETFL, original_flags | O_NONBLOCK); + /* ESP-IDF secondary consoles mirror output but deliberately do not forward + * input to STDIN. Native S3/C6 USB Serial/JTAG therefore needs a bounded + * direct VFS reader while UART-console boards continue to use STDIN. */ + const int usb_fd = open("/dev/secondary", O_RDONLY | O_NONBLOCK); + char primary_line[ONBOARDING_LINE_MAX]; + char usb_line[ONBOARDING_LINE_MAX]; + size_t primary_length = 0; + size_t usb_length = 0; + bool primary_overflow = false; + bool usb_overflow = false; + for (;;) { + uint8_t input[64]; + const ssize_t count = read(STDIN_FILENO, input, sizeof(input)); + if (count > 0) { + consume_bytes(input, count, primary_line, &primary_length, &primary_overflow); + } + else if (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "serial input unavailable: errno=%d", errno); + vTaskDelay(pdMS_TO_TICKS(500)); + } + if (usb_fd >= 0) { + const ssize_t usb_count = read(usb_fd, input, sizeof(input)); + if (usb_count > 0) { + consume_bytes(input, usb_count, usb_line, &usb_length, &usb_overflow); + } + } + vTaskDelay(pdMS_TO_TICKS(20)); + } +} + +esp_err_t serial_onboarding_start(const nvs_config_t *config) +{ + if (config == NULL) return ESP_ERR_INVALID_ARG; + memcpy(&s_current_config, config, sizeof(s_current_config)); + const BaseType_t created = xTaskCreate(serial_onboarding_task, "serial_onboard", + 4096, NULL, 4, NULL); + if (created != pdPASS) return ESP_ERR_NO_MEM; + ESP_LOGI(TAG, "bounded serial onboarding ready"); + return ESP_OK; +} diff --git a/firmware/esp32-csi-node/main/serial_onboarding.h b/firmware/esp32-csi-node/main/serial_onboarding.h new file mode 100644 index 000000000..9a9e9febf --- /dev/null +++ b/firmware/esp32-csi-node/main/serial_onboarding.h @@ -0,0 +1,15 @@ +#ifndef SERIAL_ONBOARDING_H +#define SERIAL_ONBOARDING_H + +#include "esp_err.h" +#include "nvs_config.h" + +/** Start the bounded USB serial onboarding listener. + * + * The listener exposes device metadata and accepts one nonce-bound + * configuration transaction. It never prints WiFi credentials and reboots + * only after a successful atomic NVS commit. + */ +esp_err_t serial_onboarding_start(const nvs_config_t *config); + +#endif diff --git a/firmware/esp32-csi-node/main/serial_onboarding_protocol.c b/firmware/esp32-csi-node/main/serial_onboarding_protocol.c new file mode 100644 index 000000000..3393819cc --- /dev/null +++ b/firmware/esp32-csi-node/main/serial_onboarding_protocol.c @@ -0,0 +1,214 @@ +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L /* strtok_r under -std=c11 -pedantic on glibc */ +#endif +#include "serial_onboarding_protocol.h" + +#include +#include +#include +#include + +#define HELLO_COMMAND "RUVIEW_HELLO_V1" +#define CONFIG_COMMAND "RUVIEW_CONFIG_V1" + +static bool valid_nonce(const char *value) +{ + if (value == NULL || strlen(value) != RUVIEW_ONBOARDING_NONCE_HEX_LEN) return false; + for (size_t index = 0; index < RUVIEW_ONBOARDING_NONCE_HEX_LEN; index++) { + const char c = value[index]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) return false; + } + return true; +} + +static bool parse_u32(const char *value, uint32_t maximum, uint32_t *output) +{ + if (value == NULL || value[0] == '\0' || output == NULL) return false; + uint32_t result = 0; + for (const char *cursor = value; *cursor != '\0'; cursor++) { + if (*cursor < '0' || *cursor > '9') return false; + const uint32_t digit = (uint32_t)(*cursor - '0'); + if (result > (maximum - digit) / 10U) return false; + result = result * 10U + digit; + } + *output = result; + return true; +} + +static bool valid_ipv4(const char *value) +{ + if (value == NULL || value[0] == '\0' || strlen(value) > 15) return false; + unsigned octets = 0; + const char *cursor = value; + while (*cursor != '\0') { + if (octets == 4) return false; + if (*cursor == '.') return false; + uint32_t octet = 0; + unsigned digits = 0; + while (*cursor != '\0' && *cursor != '.') { + if (*cursor < '0' || *cursor > '9' || digits == 3) return false; + octet = octet * 10U + (uint32_t)(*cursor - '0'); + if (octet > 255U) return false; + digits++; + cursor++; + } + if (digits == 0) return false; + octets++; + if (*cursor == '.') cursor++; + } + return octets == 4; +} + +static bool valid_private_ipv4(const char *value) +{ + if (!valid_ipv4(value)) return false; + unsigned first = 0; + unsigned second = 0; + if (sscanf(value, "%u.%u", &first, &second) != 2) return false; + return first == 10U || (first == 172U && second >= 16U && second <= 31U) || + (first == 192U && second == 168U); +} + +static int base64_value(char c) +{ + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +static bool decode_base64(const char *encoded, uint8_t *output, + size_t output_capacity, size_t *output_length) +{ + if (encoded == NULL || output == NULL || output_length == NULL) return false; + const size_t length = strlen(encoded); + if (length == 0 || length % 4 != 0) return false; + size_t written = 0; + for (size_t index = 0; index < length; index += 4) { + int values[4]; + unsigned padding = 0; + for (unsigned offset = 0; offset < 4; offset++) { + const char c = encoded[index + offset]; + if (c == '=') { + if (offset < 2 || index + 4 != length) return false; + values[offset] = 0; + padding++; + } else { + if (padding != 0) return false; + values[offset] = base64_value(c); + if (values[offset] < 0) return false; + } + } + if (padding > 2) return false; + const uint32_t block = ((uint32_t)values[0] << 18) | + ((uint32_t)values[1] << 12) | + ((uint32_t)values[2] << 6) | + (uint32_t)values[3]; + const unsigned bytes = 3U - padding; + if (written + bytes > output_capacity) return false; + output[written++] = (uint8_t)(block >> 16); + if (bytes > 1) output[written++] = (uint8_t)(block >> 8); + if (bytes > 2) output[written++] = (uint8_t)block; + } + *output_length = written; + return true; +} + +static bool printable_secret(const uint8_t *bytes, size_t length) +{ + if (bytes == NULL) return false; + for (size_t index = 0; index < length; index++) { + if (bytes[index] < 0x20 || bytes[index] == 0x7f) return false; + } + return true; +} + +int ruview_onboarding_parse_hello( + const char *line, + char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1]) +{ + if (line == NULL || nonce == NULL) return 0; + const size_t prefix_length = strlen(HELLO_COMMAND); + if (strncmp(line, HELLO_COMMAND, prefix_length) != 0 || line[prefix_length] != ' ') + return 0; + const char *candidate = line + prefix_length + 1; + if (!valid_nonce(candidate)) return 0; + memcpy(nonce, candidate, RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1); + return 1; +} + +ruview_onboarding_result_t ruview_onboarding_parse_config( + const char *line, + ruview_onboarding_config_t *config) +{ + if (line == NULL || config == NULL || strlen(line) >= 384) return RUVIEW_ONBOARDING_ERR_FORMAT; + char copy[384]; + memcpy(copy, line, strlen(line) + 1); + + char *tokens[7] = {0}; + size_t token_count = 0; + char *save = NULL; + for (char *token = strtok_r(copy, " ", &save); token != NULL; + token = strtok_r(NULL, " ", &save)) { + if (token_count == 7) return RUVIEW_ONBOARDING_ERR_FORMAT; + tokens[token_count++] = token; + } + if (token_count != 7 || strcmp(tokens[0], CONFIG_COMMAND) != 0) + return RUVIEW_ONBOARDING_ERR_FORMAT; + if (!valid_nonce(tokens[1])) return RUVIEW_ONBOARDING_ERR_NONCE; + + uint32_t node_id = 0; + if (!parse_u32(tokens[2], 255, &node_id) || node_id == 0) + return RUVIEW_ONBOARDING_ERR_NODE_ID; + if (!valid_private_ipv4(tokens[3])) return RUVIEW_ONBOARDING_ERR_TARGET_IP; + uint32_t target_port = 0; + if (!parse_u32(tokens[4], 65535, &target_port) || target_port == 0) + return RUVIEW_ONBOARDING_ERR_TARGET_PORT; + + const bool preserve_wifi = strcmp(tokens[5], "KEEP") == 0 && + strcmp(tokens[6], "KEEP") == 0; + if ((strcmp(tokens[5], "KEEP") == 0) != (strcmp(tokens[6], "KEEP") == 0)) + return RUVIEW_ONBOARDING_ERR_FORMAT; + uint8_t ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES]; + size_t ssid_length = 0; + if (!preserve_wifi && (!decode_base64(tokens[5], ssid, sizeof(ssid), &ssid_length) || + ssid_length == 0 || !printable_secret(ssid, ssid_length))) + return RUVIEW_ONBOARDING_ERR_SSID; + uint8_t password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES]; + size_t password_length = 0; + if (!preserve_wifi && (!decode_base64(tokens[6], password, sizeof(password), &password_length) || + password_length < 8 || !printable_secret(password, password_length))) + return RUVIEW_ONBOARDING_ERR_PASSWORD; + + memset(config, 0, sizeof(*config)); + memcpy(config->nonce, tokens[1], RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1); + config->node_id = (uint8_t)node_id; + memcpy(config->target_ip, tokens[3], strlen(tokens[3]) + 1); + config->target_port = (uint16_t)target_port; + config->preserve_wifi = preserve_wifi; + if (!preserve_wifi) { + memcpy(config->ssid, ssid, ssid_length); + config->ssid[ssid_length] = '\0'; + memcpy(config->password, password, password_length); + config->password[password_length] = '\0'; + } + return RUVIEW_ONBOARDING_OK; +} + +const char *ruview_onboarding_result_name(ruview_onboarding_result_t result) +{ + switch (result) { + case RUVIEW_ONBOARDING_OK: return "ok"; + case RUVIEW_ONBOARDING_ERR_FORMAT: return "format"; + case RUVIEW_ONBOARDING_ERR_NONCE: return "nonce"; + case RUVIEW_ONBOARDING_ERR_NODE_ID: return "node_id"; + case RUVIEW_ONBOARDING_ERR_TARGET_IP: return "target_ip"; + case RUVIEW_ONBOARDING_ERR_TARGET_PORT: return "target_port"; + case RUVIEW_ONBOARDING_ERR_SSID: return "ssid"; + case RUVIEW_ONBOARDING_ERR_PASSWORD: return "password"; + default: return "unknown"; + } +} diff --git a/firmware/esp32-csi-node/main/serial_onboarding_protocol.h b/firmware/esp32-csi-node/main/serial_onboarding_protocol.h new file mode 100644 index 000000000..c860db575 --- /dev/null +++ b/firmware/esp32-csi-node/main/serial_onboarding_protocol.h @@ -0,0 +1,42 @@ +#ifndef SERIAL_ONBOARDING_PROTOCOL_H +#define SERIAL_ONBOARDING_PROTOCOL_H + +#include +#include +#include + +#define RUVIEW_ONBOARDING_NONCE_HEX_LEN 32 +#define RUVIEW_ONBOARDING_SSID_MAX_BYTES 32 +#define RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES 63 + +typedef struct { + char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1]; + uint8_t node_id; + char target_ip[16]; + uint16_t target_port; + bool preserve_wifi; + char ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES + 1]; + char password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES + 1]; +} ruview_onboarding_config_t; + +typedef enum { + RUVIEW_ONBOARDING_OK = 0, + RUVIEW_ONBOARDING_ERR_FORMAT, + RUVIEW_ONBOARDING_ERR_NONCE, + RUVIEW_ONBOARDING_ERR_NODE_ID, + RUVIEW_ONBOARDING_ERR_TARGET_IP, + RUVIEW_ONBOARDING_ERR_TARGET_PORT, + RUVIEW_ONBOARDING_ERR_SSID, + RUVIEW_ONBOARDING_ERR_PASSWORD, +} ruview_onboarding_result_t; + +int ruview_onboarding_parse_hello(const char *line, + char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1]); + +ruview_onboarding_result_t ruview_onboarding_parse_config( + const char *line, + ruview_onboarding_config_t *config); + +const char *ruview_onboarding_result_name(ruview_onboarding_result_t result); + +#endif diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index 950eb539a..38321d508 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -55,10 +55,10 @@ FUZZ_JOBS ?= 1 .PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \ 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_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ - test_thermal test_csi_sanitize test_c6_antenna + test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding # --- XIAO ESP32-C6 RF switch selection --- # Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven @@ -122,9 +122,19 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h run_thermal: test_thermal ./test_thermal +# --- Native Mac USB onboarding protocol --- +# Tests the production parser and secret validation without ESP-IDF. This pins +# nonce shape, IPv4 and node bounds, base64 handling, and control-byte denial. +test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.h + cc -std=c11 -Wall -Wextra -Werror -pedantic -I$(MAIN_DIR) -o $@ \ + test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c + +run_serial_onboarding: test_serial_onboarding + ./test_serial_onboarding + host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \ - run_c6_antenna - @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna)" + run_c6_antenna run_serial_onboarding + @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding)" # --- Serialize fuzzer --- # Tests csi_serialize_frame() with random wifi_csi_info_t inputs. @@ -161,5 +171,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_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/test_serial_onboarding_protocol.c b/firmware/esp32-csi-node/test/test_serial_onboarding_protocol.c new file mode 100644 index 000000000..314a6d1d1 --- /dev/null +++ b/firmware/esp32-csi-node/test/test_serial_onboarding_protocol.c @@ -0,0 +1,68 @@ +#include +#include +#include + +#include "../main/serial_onboarding_protocol.h" + +static void test_hello(void) +{ + char nonce[33] = {0}; + assert(ruview_onboarding_parse_hello( + "RUVIEW_HELLO_V1 00112233445566778899aabbccddeeff", nonce)); + assert(strcmp(nonce, "00112233445566778899aabbccddeeff") == 0); + assert(!ruview_onboarding_parse_hello("RUVIEW_HELLO_V1 bad", nonce)); + assert(!ruview_onboarding_parse_hello( + "RUVIEW_HELLO_V1 00112233445566778899aabbccddeeff extra", nonce)); +} + +static void test_config(void) +{ + ruview_onboarding_config_t config; + assert(ruview_onboarding_parse_config( + "RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff 5 192.168.1.166 5005 " + "UnVWaWV3IExhYg== U3Ryb25nUGFzc3dvcmQ=", &config) == RUVIEW_ONBOARDING_OK); + assert(config.node_id == 5); + assert(config.target_port == 5005); + assert(strcmp(config.target_ip, "192.168.1.166") == 0); + assert(strcmp(config.ssid, "RuView Lab") == 0); + assert(strcmp(config.password, "StrongPassword") == 0); + assert(!config.preserve_wifi); + assert(ruview_onboarding_parse_config( + "RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff 6 192.168.1.166 5005 KEEP KEEP", + &config) == RUVIEW_ONBOARDING_OK); + assert(config.preserve_wifi); +} + +static void test_rejections(void) +{ + ruview_onboarding_config_t config; + const char *prefix = "RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff"; + char line[512]; + snprintf(line, sizeof(line), "%s 0 192.168.1.2 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_NODE_ID); + snprintf(line, sizeof(line), "%s 5 999.1.1.1 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP); + snprintf(line, sizeof(line), "%s 5 8.8.8.8 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP); + snprintf(line, sizeof(line), "%s 5 172.32.0.1 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP); + snprintf(line, sizeof(line), "%s 5 192.168.1.2 0 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_PORT); + snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 !!!! U3Ryb25nUGFzc3dvcmQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_SSID); + snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 UnVWaWV3 c2hvcnQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_PASSWORD); + snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 UnVWaWV3 UGFzc3dvcmQxCg==", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_PASSWORD); + snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 KEEP U3Ryb25nUGFzc3dvcmQ=", prefix); + assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_FORMAT); +} + +int main(void) +{ + test_hello(); + test_config(); + test_rejections(); + puts("serial onboarding protocol tests passed"); + return 0; +} diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index ef5056161..83ce05d72 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.8.10 +0.8.11