From a4a0b97c3f5954f0a08535e8b0dbfbad78dafe59 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 21:41:47 -0700 Subject: [PATCH] Expand post-merge regressions and document qualification gaps --- MQTT_IMPLEMENTATION.md | 29 ++++-- docs/WiFi.md | 18 +++- docs/esp32_wifi_partition_migration.md | 65 ++++++++---- docs/heltec_v4_wifi_partition_migration.md | 20 ++-- docs/ota_protocol.md | 4 +- docs/ota_user_guide.md | 6 ++ docs/research/companion_contact_cache.md | 36 +++++++ docs/research/firmware_memory_budget.md | 20 ++++ docs/room_services.md | 30 +++++- .../hardware_validation_checklist.md | 80 +++++++++++++++ docs/test-results/index.md | 1 + scripts/package_esp32_partition_migration.py | 35 ++++++- test/fixtures/room_web_service/service.cpp | 19 ++++ test/fixtures/webconfig_login_handler.cpp | 21 ++++ test/fixtures/webconfig_login_throttle.cpp | 25 +++++ test/fixtures/webconfig_recovery_auth.cpp | 26 ++++- test/test_companion_contact_partial_tail.py | 98 +++++++++++++++---- test/test_esp32_partition_migration_recipe.py | 24 +++++ ...st_esp32_partition_migrator_ap_password.py | 41 +++++++- test/test_room_login_replay_capacity.py | 12 +++ 20 files changed, 536 insertions(+), 74 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index d593cc8c7..4eafe8fd7 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -753,14 +753,27 @@ Optionally set a WPA2 password for the setup AP at build time with `-D WEBCONFIG_AP_PASSWORD='"yourpassword"'`. #### Modes and authentication -- **Setup AP**: unauthenticated. Trust is based on physical proximity to the - open/PSK AP. Only the SoftAP interface serves the API -- on `start webconfig - ap` the STA is explicitly disassociated so the API is **not** exposed on the - LAN the node was attached to. -- **LAN**: requires the admin password (same one used for remote CLI admin). - Sessions use a cookie with a sliding idle expiry (default 20 minutes); five - failed logins from one address lock that address for 30 seconds. Failed - logins from all addresses together are capped at six, then one per 6 seconds. +- **Setup AP**: first-time provisioning with no saved WiFi SSID does not require + an admin login. With a saved SSID, both a recovery AP and `start webconfig ap` + require the existing admin password, when configured, before settings can be + changed. Joining the AP does not grant permission to replace that password. + Only the SoftAP interface serves the setup API, including while saved-network + recovery retries the station connection. `WEBCONFIG_AP_PASSWORD` protects + joining the WiFi network separately from the portal's admin login. +- **LAN**: requires the configured admin password (same one used for remote CLI + admin). Images deliberately configured without an admin password retain their + no-password policy. Sessions use a cookie with a sliding idle expiry (default + 20 minutes). + +When an admin login is required, `/api/login` applies the same limits on LAN and +the recovery AP. Five failed logins from one address lock that address for 30 +seconds after its fifth failure. A shared budget allows a burst of six failures +across all addresses and refills one allowance every 6 seconds. When it is +exhausted, even correct credentials receive HTTP 429 until an allowance returns. +A successful login clears only that address's failure count, not the shared +budget. The [room browser](docs/room_services.md#local-wi-fi-chat-on-esp32) has a +separate password budget; signing in through one endpoint does not clear the +other's failures. > **Security note:** the open setup AP transports WiFi/MQTT credentials over > plain HTTP. Provision on a trusted, non-public frequency/location, set diff --git a/docs/WiFi.md b/docs/WiFi.md index e70c49bc1..0accf3cac 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -154,10 +154,20 @@ fresh device with no saved SSID: 4. Select **Save & Reboot**. 5. After reboot, check `get wifi.status` and `get mqtt.status`. -The setup AP is unauthenticated unless the firmware was built with -`WEBCONFIG_AP_PASSWORD`. It uses plain HTTP, so provision it in a trusted -location. When WebConfig is running on the normal LAN, repeater and room-server -builds require the node's admin password. +First-time provisioning with no saved SSID does not require a portal admin +login. A setup AP opened with a saved SSID, including saved-network recovery, +requires the existing admin password before changing settings. Repeater and +room-server builds also require that password on the normal LAN; deliberately +passwordless images retain their no-password policy. `WEBCONFIG_AP_PASSWORD` +controls access to the WiFi network separately. The portal uses plain HTTP, so +use a trusted network. + +Admin login limits apply on both LAN and recovery AP: five failures lock the +failing address for 30 seconds after its fifth failure. All addresses also share +a budget of six failures, refilling one allowance every 6 seconds; while empty, +even correct credentials receive HTTP 429. A success clears only its own +address's count. The [room browser](room_services.md#local-wi-fi-chat-on-esp32) +uses a separate password budget; a WebConfig session does not bypass it. On an expanded FULL profile with no saved SSID, this automatic setup AP has an absolute 30-minute window. Browser activity or a phone left associated with the diff --git a/docs/esp32_wifi_partition_migration.md b/docs/esp32_wifi_partition_migration.md index 6e87b70aa..1d692d3d8 100644 --- a/docs/esp32_wifi_partition_migration.md +++ b/docs/esp32_wifi_partition_migration.md @@ -7,10 +7,12 @@ The partition-migration bridge lets a supported ESP32 node move from a small dual-OTA layout to a verified larger dual-OTA layout without cable flashing or a full-chip erase. It fits a legacy 1.25 MiB slot. The Wi-Fi version exposes an -updater for the Full image after migration. The LoRa version preserves a -verified old OTA receiver so it can fetch the Full image into the expanded -inactive slot. On 8/16 MiB flash the bridge restores identity before handing -off; the 4 MiB route keeps identity staged until the Full image's first boot. +updater for the Full image after migration. The older identity-only LoRa +handoff preserves a verified old OTA receiver so it can fetch the Full image +into the expanded inactive slot. On 8/16 MiB flash that bridge restores identity +before handing off; its 4 MiB route keeps identity staged until the Full image's +first boot. Configuration-preserving Partition Expanders instead receive the +Full image themselves after restoring identity and supported settings. The distinct USB version performs verified identity/configuration restoration before the host writes the final app. It keeps application USB CDC disabled @@ -42,6 +44,30 @@ These are the supported migration plans. The 8/16 MiB tables retain more filesystem storage and a coredump partition; their application slots are not the absolute maximum permitted by the physical flash capacity. +## Bridge Wi-Fi credentials + +Join `MeshCore-Migrate` using the admin password saved on the old node. This +applies to the Wi-Fi bridge and the recovery AP of LoRa bridges and Partition +Expanders. The firmware stores at most 15 password characters; the bridge can +use a saved password of **8 to 15 printable ASCII characters** for WPA2. + +If the saved password is shorter than 8 characters, contains other characters, +or cannot be read, the bridge uses the published fallback `meshcore-migrate`. +Missing saved preferences, including a node still using an unsaved built-in +default, also cause fallback. Anyone nearby who knows that published password +can then join and upload firmware when the updater is enabled. A saved default +or other public admin password is also usable, so choose a strong, nonpublic +password before migration. On the old node, use `password ` and +verify that it remains valid after a normal reboot before uploading the bridge. + +The Wi-Fi bridge and configuration-preserving Partition Expanders restore the +saved password before starting their AP in the expanded layout. The older +identity-only LoRa handoff does not restore saved settings, so its Wi-Fi +recovery AP after the table change normally uses the fallback. Follow the exact +route in the package README. If a device remembers `MeshCore-Migrate` from an +earlier bridge, forget that saved network or update its password before joining. +Bridge packages built before this credential change use `meshcore-migrate`. + ## Ordinary Full releases and installation Ordinary ESP32 node releases use Full for each exact board and firmware role. @@ -212,9 +238,11 @@ inclusion does **not** override either check. Every listed board/role has Wi-Fi and LoRa bridge recipes. Heltec V4 and XIAO S3 WIO repeaters retain their board-specific bridge names; the other entries -use matching original-ESP32, ESP32-S3, or ESP32-C3 chip-family bridges. LoRa -still requires a verified same-target old receiver, and 4 MiB boards require -the slot-B-only sequence below. A 4 MiB Full build offers 1,984 KiB per slot. +use matching original-ESP32, ESP32-S3, or ESP32-C3 chip-family bridges. The older +identity-only LoRa handoff requires a verified same-target old receiver, and +its 4 MiB route requires the slot-B-only sequence below. Configuration-preserving +Partition Expanders use the route described in their package README instead. +A 4 MiB Full build offers 1,984 KiB per slot. The packager verifies both the Full application's slot fit and its mOTA staging overhead. It supports at most 4096 blocks of 2 KiB for the Full transfer, and records the seeder proof-scratch requirement in the manifest. @@ -234,7 +262,8 @@ followed by its 64-byte private key), stages them in an NVS namespace that is unchanged by the source and target layouts, then changes the table. The Wi-Fi bridge and 8/16 MiB LoRa bridge restore the staged value into expanded SPIFFS, read it back byte-for-byte, and only then clear the NVS staging record. On the -4 MiB LoRa route, the Full application's first boot performs this restoration. +4 MiB identity-only LoRa handoff, the Full application's first boot performs +this restoration. The Wi-Fi bridge also stages and verifies its supported saved configuration: node name and preferences, radio profiles, ACL and login replay state, region @@ -284,27 +313,21 @@ Use `--board xiao-s3-wio` with the recipe above. Its Wi-Fi bridge is a legacy-slot application image; its Full image keeps the old repeater's exact LoRa OTA target ID. Upload `wifi-bridge.bin` from the ZIP through the legacy node's existing Wi-Fi browser updater. -Then join `MeshCore-Migrate` and wait for **Expanded layout ready**. Its Wi-Fi -password is the admin password saved on the old node, so only someone who can -already administer the node can join and upload firmware. Upload -`full-application.bin` at `/update`. +Then join `MeshCore-Migrate` using the node's saved admin password, following +the [shared credential and fallback rules](#bridge-wi-fi-credentials), and wait +for **Expanded layout ready**. Upload `full-application.bin` at `/update`. Do not upload an `-merged.bin`: merged images include bootloader and table offsets for cable flashing, not browser OTA. -WPA2 needs a password of at least 8 printable ASCII characters. If the saved -admin password is shorter, uses other characters, or was never saved (the node -still runs on its built-in default), the bridge falls back to -`meshcore-migrate`. That password is published here, so anyone nearby could -then join and upload firmware. Before uploading the bridge, set an admin -password of 8 to 15 characters with `password `. A LoRa -first-stage bridge does not carry saved settings into the expanded layout, so -its Wi-Fi recovery page after the table change normally uses the fallback. - `xiao_s3_partition_legacy_seed` is a disposable-hardware test image only. It models the legacy table and an identity file; it is not a deployable repeater. ## LoRa-only migration of a listed role +This section describes the older identity-only LoRa handoff. A package whose +LoRa first stage is a configuration-preserving Partition Expander, including +the Waveshare observer packages, uses its package README's self-hosted route. + The exact old board/role must already support MeshCore LoRa mOTA and have a valid EndF image identity. The LoRa bridge checks the old app's target ID and body hash before any partition-table write; an unsupported or damaged receiver is diff --git a/docs/heltec_v4_wifi_partition_migration.md b/docs/heltec_v4_wifi_partition_migration.md index 93bbf13a9..385fcf6f1 100644 --- a/docs/heltec_v4_wifi_partition_migration.md +++ b/docs/heltec_v4_wifi_partition_migration.md @@ -20,16 +20,22 @@ python3 -B scripts/build_esp32_partition_migration.py \ The ZIP contains the Wi-Fi bridge, LoRa bridge, and exact-target Full image. Upload its `wifi-bridge.bin` through the legacy browser Wi-Fi updater. Rejoin `MeshCore-Migrate` using the node's saved -admin password as the Wi-Fi password (see the shared guide for the published -fallback); when it reports **Expanded layout ready**, upload the ZIP's +admin password as the Wi-Fi password (see the shared +[credential and fallback rules](esp32_wifi_partition_migration.md#bridge-wi-fi-credentials)); +when it reports **Expanded layout ready**, upload the ZIP's `full-application.bin` at `/update`. Do not use an `-merged.bin` in either browser uploader. Merged images contain bootloader and table offsets for cable flashing. The bridge preserves the -private identity; other SPIFFS-backed settings may be recreated after the -Full image starts. +private identity and supported saved configuration, including node name, +radio profiles, ACL and login replay state. Files outside the supported +preservation set may need to be recreated after the Full image starts. For an existing `heltec_v4_repeater` that already supports LoRa mOTA, use the -separate LoRa migration package described in the shared guide. It preserves -the old repeater across the partition change so the full image can also be -installed over LoRa without reaching the board's Wi-Fi network. +ZIP's `lora-bridge.mota` with the identity-only LoRa handoff described in the +shared guide. It preserves the old repeater across the partition change so the +Full image can also be installed over LoRa without reaching the board's Wi-Fi +network. +That route does not restore saved settings; its post-expansion Wi-Fi recovery +AP normally uses the published fallback. The optional Partition Expander +route instead restores supported settings and retains the saved admin password. diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 8f324d1c4..0bf099930 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -736,7 +736,7 @@ for `ENABLE_OTA` builds using the shared raw-radio scheduler, except primary ESP-NOW builds; `-DMESH_ENABLE_OTA_KISS_DIRECT=0` explicitly disables it. Enabling the build feature alone does not bypass the runtime negotiation/session gates. The separate host seeder's safe probe/serve examples and lab TX lock are in -[`tools/lora_ota/README.md`](../tools/lora_ota/README.md). +[`tools/lora_ota/README.md`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/README.md). ### 8.6 Temporary-radio and transfer boundary @@ -954,7 +954,7 @@ deployment runner temporarily uses `0.3` on managed relays. `ota folder on` command; that command confirms it can host and advertise the folder. A **KISS modem will not work with `motatool serve --serial`**: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI and `mota-seeder` request/response transport. The separate -[`mota_kiss_direct.py` source](../tools/lora_ota/README.md) implements that full KISS interface. +[`mota_kiss_direct.py` source](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/mota_kiss_direct.py) implements that full KISS interface. An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative source connection: use its dedicated seeder port with `motatool serve --tcp :5001`. An nRF52 Full Companion can instead pair with a phone or Linux host, subscribe diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 8029b7499..ead2a2fad 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -12,6 +12,12 @@ matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB. +Before a Companion upgrade, export its contacts when possible. ESP32, RP2040 +and STM32 can retain complete legacy records ahead of an interrupted final +record when no backup is reported present; nRF52 has separate migration rules. +See [legacy interrupted contact saves](research/companion_contact_cache.md#legacy-interrupted-contact-saves) +for the recovery limits and next-save behavior. + This guide is for **node operators**: how to update your MeshCore device's firmware over the radio, in plain language. No cables, no programmer - your node can download a new firmware from a neighbour and diff --git a/docs/research/companion_contact_cache.md b/docs/research/companion_contact_cache.md index 0fd7ace85..117b233ce 100644 --- a/docs/research/companion_contact_cache.md +++ b/docs/research/companion_contact_cache.md @@ -67,6 +67,36 @@ The path checksum also rejects a cold path changed underneath a live handle; it does not add a new checksum to the legacy ESP32 on-disk format. +### Legacy interrupted contact saves + +ESP32, RP2040 and STM32 Companions can load the complete records before a +partial final record in a legacy `/contacts3` file. This includes cached and +uncached ESP32 builds. The file must contain at least one whole 152-byte +record, and every whole record must be readable. +The partial record is ignored; loading does not truncate the source. The next +successful ordinary contact save writes the complete loaded contacts through +the platform's existing transaction writer. + +On ESP32 and RP2040, this salvage applies only when the presence check reports +`/contacts3.bak` absent. A readable complete backup takes precedence over the +incomplete primary. An unreadable backup keeps the existing bounded retry and +recovery behavior; the partial primary is not used to declare that backup +expendable. ESP32 also defers salvage when backup presence is uncertain. +RP2040's filesystem API does not distinguish a metadata I/O error from a +missing file. STM32 does not select a contact backup. + +This recovers a readable prefix from an interrupted older in-place save, not +arbitrary corruption. Legacy records have no file checksum, and the existing +route validation and repair still apply. A file with no complete record, or +one that remains unreadable after recovery retries, can still be discarded by +contact-only recovery. Failure to remove damaged data leaves contacts volatile +and explicit saves unavailable, without erasing identity or unrelated settings. + +nRF52 retains its separate legacy-size validation and paged-store migration; +this partial-tail salvage does not apply to nRF52. The existing +[ExtraFS recovery guide](../test-results/nrf52_companion_storage_recovery.md) +describes its separate mount and metadata recovery boundaries. + ## Shared secrets and power Both platforms reuse the 16 most recent secrets from RAM. Cache misses use a @@ -188,6 +218,8 @@ change that layout or enable WiFi OTA. ```sh python3 -B test/test_contact_cache.py +python3 -B test/test_companion_uncached_storage.py +python3 -B test/test_companion_contact_partial_tail.py python3 -B test/test_companion_contact_persistence_contract.py python3 -B test/test_companion_contact_stream_contract.py python3 -B test/test_nrf52_extrafs_contract.py @@ -202,6 +234,10 @@ adapters. They exercise 350 routes, eviction, snapshot rollback, page migration, identity changes, read/write/rename faults, handle exhaustion, packet release on a failed path read, and recovery after a simulated reset between file renames. They do not simulate a physical flash power cut. +The partial-tail tests also check readable-prefix preservation, backup +precedence, deferred normalization on save and reload on ESP32, RP2040 and +STM32 using filesystem adapters. Real flash power-loss and full-volume +qualification remains separate from these host checks. `tools/hil/contact_cache_serial_stress.py` checks all 350 paths over USB, replaces them with a different generation, and optionally exercises secret diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index db3356d4f..942fde3f9 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -548,6 +548,26 @@ G2/Wio L1 selections in this matrix and all four STM32 Companions were rebuilt at `29787c0b2`. The final 95 related host tests and artifact-hash checks pass. +### PR21 contact-recovery size recheck, 2026-10-18 UTC + +The combined PR17–21 merge tree `a40b1f662` was linked with +`v1.18.0.1-halo-keymind-cascade-dev`, `usa-cascade-fixed`, and the Cascade +profile. All four unchanged 229,376-byte application limits and runtime RAM +gates passed. These are build results, not on-device qualification. + +| USB Companion | Application bytes | Free application bytes | Static RAM bytes | +| --- | ---: | ---: | ---: | +| RAK3x72 | 228,744 | 632 | 40,220 | +| Wio-E5 Mini | 228,760 | 616 | 40,252 | +| Wio-E5 Dev | 228,884 | 492 | 40,212 | +| Tiny Relay | 228,940 | 436 | 40,220 | + +The earlier 508/452-byte margins above are historical checkpoints. Continue +linking every small STM32 Companion at full release-version length: these +margins do not justify relaxing the application, filesystem or runtime-RAM +gates. Physical storage and boot checks remain in the +[hardware checklist](../test-results/hardware_validation_checklist.md#pr1721-follow-up-qualification-2026-10-18-utc). + Margins remain narrow: RAK4631 Full Companion has 932 flash bytes free; canonical/unified RAK4631 Full Repeater have 548/540 bytes above their RAM budgets. W12/G2/G3 Full MQTT have 2,208/2,176/1,816 bytes above their budgets. diff --git a/docs/room_services.md b/docs/room_services.md index c05eab21f..0b2f5f2be 100644 --- a/docs/room_services.md +++ b/docs/room_services.md @@ -225,11 +225,13 @@ read/write sessions remain transient; password-created administrators remain retained. The existing permission byte is unchanged. Guest-password logins do not allocate new durable login replay records, so -guest churn cannot fill the bounded replay store. Any existing record from a -previous privileged role still applies and is advanced. After reboot or -transient client eviction, an identity without such a record can have captured -guest login and message traffic accepted again. Assign an explicit read/write -role with `setperm 2` when durable login replay protection is required. +guest churn cannot fill the bounded replay store. Existing records remain +authoritative and are advanced, including guest-password records created by +older firmware. This change does not reclaim space in an already full +128-identity replay store. After reboot or transient client eviction, an +identity without such a record can have captured guest login and message +traffic accepted again. Assign an explicit read/write role with `setperm 2` +when durable login replay protection is required. Room-post deduplication and per-sender post replay timestamps remain in RAM: captured old posts can also be accepted after a legitimate newer login restores a lost session. @@ -408,6 +410,24 @@ notices; only administrators can publish or delete them. Each operation rechecks credentials and bans. Use private Wi-Fi: the local page uses HTTP, and network access alone does not grant administrator permission. +Room-browser password checks share one budget across all browsers and tokens. +Five newly counted wrong passwords exhaust a 30-second window measured from its +first counted failure. Until that window ends, every request with a nonempty +password is refused, including correct guest/admin passwords and polls from an +already connected admin tab. Correct passwords do not refund failures, and +refused requests do not extend the window. The last four wrong passwords are +remembered; repeats among those four do not count again. A wrong nonempty +password still counts when read-only access admits it as a guest. An empty +password never consumes this budget and remains usable +for explicitly enabled public read-only access. + +This limit belongs to `/api/room`. The WebConfig admin login at `/api/login` +has its own address limits and shared budget for the same admin password; +neither endpoint clears or bypasses the other's limit. The room page does not +require a WebConfig session, and this browser limit does not change radio +logins. If password requests are blocked, wait for the window to end or use +the local console to manage the node. + The browser generates a secret token and its own moderation identity. It does not import a radio private key or inherit a claimed radio key's ACL role. The Access and history panel shows the complete browser identity for banning. diff --git a/docs/test-results/hardware_validation_checklist.md b/docs/test-results/hardware_validation_checklist.md index 6bbafa2ab..a34e5ec18 100644 --- a/docs/test-results/hardware_validation_checklist.md +++ b/docs/test-results/hardware_validation_checklist.md @@ -14,6 +14,86 @@ when its log identifies the physical device, firmware artifact, artifact hash, command result, and cold/warm boot outcome. Do not infer success from a tool's exit code when the tool has a documented false-success mode. +## PR17–21 follow-up qualification, 2026-10-18 UTC + +The merged source checkpoint is `0a24279a3`. This audit adds host regressions, +operator documentation, and a C6 GPIO capability fix; it is not a hardware +qualification. No device was flashed or contacted during the audit. + +Completed host coverage includes guest replay-store capacity and live-client +eviction; per-address/shared WebConfig limits and room-password windows across +`millis()` rollover; existing WebConfig sessions during login throttling; +migration credential precedence and failed-identity upload denial; generated +package instructions; and cached/uncached partial-contact recovery. The real +Chromium room and WebConfig fixtures use mocked HTTP responses, so they do not +prove the actual network/firmware interaction below. + +The merge was compiled for `Heltec_v3_room_server`, +`heltec_v4_companion_radio_usb`, `esp32_s3_4mb_partition_migrator`, and all four +small STM32 USB Companions at release-version length. Follow-up builds cover +`Xiao_rp2040_companion_radio_usb` and Arduino 3's +`LilyGo_Tlora_C6_room_server_`. The C6 build initially exposed unavailable +global deep-sleep GPIO hold APIs; the helper now follows the SDK's per-pad +capability, with a fixture that omits the unavailable declarations. The CI +`cross-platform-smoke` job retains both RP2040 and C6 build checks. + +Additional migration builds passed for `esp32_8mb_partition_migrator`, +`esp32_c3_4mb_partition_migrator`, +`heltec_v4_partition_migrator_lora_repeater`, `heltec_v4_partition_expander`, +and `esp32_s3_4mb_partition_migrator_usb`, including their application-size and +runtime-RAM gates. This covers representative chip families and Wi-Fi, +identity-only LoRa, Expander and USB code paths, rather than every board alias. + +### Remaining physical acceptance checks + +Record the exact clean source revision, firmware SHA-256, device identity, +observed result, and cold/warm boot outcome for each check. Record password +classes and success/failure, never the passwords themselves. + +- [ ] **Migration credentials:** on cable-recoverable test hardware, join with + a distinct saved 8–15-character printable ASCII admin password. Reject the + wrong password and published fallback. Repeat with unusable/missing saved + credentials and verify the documented fallback. Forget or update the host's + saved `MeshCore-Migrate` network between different credentials. +- [ ] **Migration handoff:** test both source OTA slots and the distinct + identity-only LoRa and configuration-preserving Expander routes. Verify + recovery-AP credentials after expansion, actual browser upload, unchanged + identity/supported settings, final version, and cold-boot persistence. + Exercise supported interruption/retry points on sacrificial hardware; the + partition-table sector write is not atomic. +- [ ] **WebConfig:** use two distinct source addresses on LAN and on a + saved-network recovery AP. One address's five failures must not lock the + other out; exhausting the shared budget must reject even a correct new + login. An existing authorized session must remain usable. Verify recovery + at the six-second refill and 30-second address-lock boundaries. Clients + sharing one address also share its lock. +- [ ] **Room browser:** use separate real browser tabs/tokens for five + different wrong passwords. Nonempty credentials, including an already-open + admin tab, must wait for the shared window to expire; public empty-password + read-only access must remain available when enabled. Repeated stale-tab + passwords must not consume a new failure on every poll. Check visible error, + retry and unsaved-edit behavior against the real board. +- [ ] **Guest sessions:** churn transient guest identities, then admit a new + admin and an explicitly assigned writer. Power-cycle and verify privileged + login replay floors remain authoritative. Document the expected captured + guest-traffic replay boundary after transient state is lost; no durable + guest record is added and an already-full older store is not reclaimed. +- [ ] **Contact storage:** on ESP32 cached/uncached, RP2040 and STM32, exercise + legacy interrupted saves, full-volume/read failures and complete-backup + precedence. Require complete records to survive, the partial source to stay + unchanged until ordinary save, and identity/settings to remain untouched. + RP2040's `exists()` cannot distinguish absence from every metadata error; + include actual storage faults. nRF52 uses separate legacy migration rules + and is not covered by the partial-tail salvage change. +- [ ] **C6 terminal sleep:** measure the selected radio/FEM output levels + through terminal deep sleep and wake/reset, and verify that runtime/light + sleep does not latch unrelated live pins. A successful build and simulated + pad retention do not establish physical latch or current behavior. + +Remaining automated follow-ups are browser throttle-error/retry/draft cases +and an executed configuration-restore-to-AP credential handoff. The host AP +fixture supplies pre/post-restore files; it does not run an entire migration. + ## ESP32 web console and browser OTA release gates The [2026-10-05 V4/XIAO checkpoint](hardware_validation_esp32_web_console_ota_2026-10-05.md) diff --git a/docs/test-results/index.md b/docs/test-results/index.md index 8412b57ce..d9de0087a 100644 --- a/docs/test-results/index.md +++ b/docs/test-results/index.md @@ -7,6 +7,7 @@ Completed validation reports, experiments, and test guides are collected here. Older page addresses redirect to these copies. Results are tied to the hardware and firmware versions named in each report; they do not automatically qualify a newer build. - [1.17.1.8 Preview 1 qualification and raw reports](preview-1.17.1.8/index.md) +- [PR17–21 remaining hardware acceptance checks](hardware_validation_checklist.md#pr1721-follow-up-qualification-2026-10-18-utc) ## Radio and RF diff --git a/scripts/package_esp32_partition_migration.py b/scripts/package_esp32_partition_migration.py index f230150b0..d1d6e50b3 100644 --- a/scripts/package_esp32_partition_migration.py +++ b/scripts/package_esp32_partition_migration.py @@ -487,6 +487,8 @@ ready**, it listens on the saved primary radio profile and automatically fetches only this package's exact-target Full application. Serve `full-application.mota` on that profile; do not upload it to the browser. The bridge's status page reports the receiver profile and block progress. +Its recovery AP uses the restored saved admin password when usable; the +shared credential rules above explain the fallback. Keep only the intended Full build for this target on the seeder during the migration; the bridge pins the hardware/role target, not an individual MID. If installed on an already-expanded board, it returns to a verified Full image @@ -508,10 +510,8 @@ installing the bridge; the bridge does not itself repeat normal traffic. `Started:` URL returned by the command (the port can vary by old build). 2. Keep power stable. Join `MeshCore-Migrate` using the node's saved admin password as the Wi-Fi password, open `http://192.168.4.1/`, and wait for - **Expanded layout ready**. If that password is shorter than 8 characters - or was never saved, the bridge falls back to the published - `meshcore-migrate`, which anyone nearby can use; set a longer admin - password before step 1. + **Expanded layout ready**. Follow the shared credential and fallback rules + above if the saved password is unusable. 3. At that page's `/update`, upload `full-application.bin`. """ usb_instructions = f"""## Host-managed USB route @@ -555,6 +555,8 @@ it does not reboot into an old receiver with reset settings. 3. Serve this package's `full-application.mota` on that saved profile. The expander automatically fetches only this exact successor target and installs it after complete verification. Wi-Fi recovery remains available. + Its AP uses the restored saved admin password when usable, with the fallback + described above. The final image has {full_mota_blocks} blocks; the seeder needs at least {full_mota_blocks * 4} bytes of proof scratch. Confirm that a capable seeder @@ -608,6 +610,11 @@ the old receiver's compiled default radio profile and arrange a seeder on that profile for stage two; a remote node that cannot hear the seeder cannot finish its LoRa migration. +This identity-only LoRa handoff does not restore saved settings. Its +post-expansion Wi-Fi recovery AP normally uses `meshcore-migrate`, the published +fallback, even when the old node had a usable saved admin password. This differs +from the configuration-preserving Partition Expander route. + 1. Make `lora-bridge.mota` available from a compatible mOTA seeder. On the old node use `ota ls`, `ota pull flash`, then `ota install` after the complete download. A manual install handles an equal-version bridge. @@ -685,6 +692,26 @@ the table-sector erase/write can still require cable recovery. Do not use this on an inaccessible node without accepting that risk and testing a sacrificial example of the same board first. +## Bridge Wi-Fi credentials + +Join `MeshCore-Migrate` using the old node's saved admin password when it is +usable. The firmware stores at most 15 characters; use **8 to 15 printable ASCII +characters**. Before installing the bridge, set a strong, nonpublic password +with `password ` and verify it survives a normal reboot. + +The bridge uses the published fallback `meshcore-migrate` if the saved password +is shorter than 8 characters, contains nonprintable or non-ASCII characters, +or cannot be read. Missing saved preferences, including an unsaved built-in +default, also cause fallback. Anyone nearby who knows the fallback can join +and upload firmware when the updater is enabled. A saved default or other +public admin password remains usable; it does not protect the bridge from +someone who already knows it. + +The Wi-Fi bridge restores the saved password after expansion. The LoRa and +Partition Expander sections below explain their recovery AP credentials. +Forget or update any previously saved `MeshCore-Migrate` network before joining +with a different password. Older bridge packages use the published fallback. + {wifi_instructions} {usb_instructions} {expander_instructions} diff --git a/test/fixtures/room_web_service/service.cpp b/test/fixtures/room_web_service/service.cpp index 2d52c5f98..d88985bf9 100644 --- a/test/fixtures/room_web_service/service.cpp +++ b/test/fixtures/room_web_service/service.cpp @@ -333,6 +333,25 @@ static void passwordLimitCases() { ticks = 31000; assert(result(m, request("status", "admin-secret"), sequence++, 25)["role"] == PERM_ACL_ADMIN); ticks = later; } + { + // A window beginning before UINT32_MAX stays locked through rollover, + // expires at its original boundary, and permits a full new failure window. + const uint32_t later = ticks, start = UINT32_MAX - 1000u; ticks = start; + MyMesh m; enable(m); m._prefs.allow_read_only = false; + const unsigned checks = password_checks + 5; + for (uint8_t owner = 20; owner < 25; ++owner) error(m, request("status", guess("wrap-", owner).c_str()), sequence++, "incorrect", owner); + assert(password_checks == checks); + ticks = UINT32_MAX; error(m, request("status", "admin-secret"), sequence++, "too many", 25); + ticks = 0; error(m, request("status", "admin-secret"), sequence++, "too many", 25); + ticks = start + 29999u; error(m, request("status", "admin-secret"), sequence++, "too many", 25); + assert(password_checks == checks); + ticks = start + 30000u; + assert(result(m, request("status", "admin-secret"), sequence++, 25)["role"] == PERM_ACL_ADMIN); + for (uint8_t owner = 30; owner < 35; ++owner) error(m, request("status", guess("after-wrap-", owner).c_str()), sequence++, "incorrect", owner); + assert(password_checks == checks + 6); + error(m, request("status", "admin-secret"), sequence++, "too many", 25); + ticks = later; + } { MyMesh m; enable(m); m._prefs.allow_read_only = false; // Neither the guest nor the admin password refunds failed guesses. diff --git a/test/fixtures/webconfig_login_handler.cpp b/test/fixtures/webconfig_login_handler.cpp index 97e1ba8e4..cbd363a99 100644 --- a/test/fixtures/webconfig_login_handler.cpp +++ b/test/fixtures/webconfig_login_handler.cpp @@ -96,6 +96,27 @@ int main() { assert(login(portal, owner, "existing-secret")==200); assert(login(portal, attacker, "existing-secret")==200); } + // Real handler responses retain their exact boundaries when millis wraps. + { + WebConfigServer portal; const uint32_t start=UINT32_MAX-1000u; now_ms=start; + for (int i = 0; i < 5; i++) assert(login(portal, attacker, "guess")==401); + now_ms=0; + assert(login(portal, attacker, "existing-secret")==429 && last_reply==kLocked); + now_ms=start+29999u; + assert(login(portal, attacker, "existing-secret")==429 && last_reply==kLocked); + now_ms=start+30000u; + assert(login(portal, attacker, "existing-secret")==200); + } + { + WebConfigServer portal; const uint32_t start=UINT32_MAX-3000u; now_ms=start; + for (uint32_t address = 10; address < 16; address++) assert(login(portal, address, "guess")==401); + now_ms=0; + assert(login(portal, owner, "existing-secret")==429 && last_reply==kShared); + now_ms=start+5999u; + assert(login(portal, owner, "existing-secret")==429 && last_reply==kShared); + now_ms=start+6000u; + assert(login(portal, owner, "existing-secret")==200); + } // LAN requests without a client object share one key and do not crash. { WebConfigServer portal; portal._mode=WebConfigServer::MODE_LAN; diff --git a/test/fixtures/webconfig_login_throttle.cpp b/test/fixtures/webconfig_login_throttle.cpp index b6d094f48..a9fae9f9c 100644 --- a/test/fixtures/webconfig_login_throttle.cpp +++ b/test/fixtures/webconfig_login_throttle.cpp @@ -84,6 +84,31 @@ int main() { assert(t.check(A, now) == ALLOW); assert(t.check(B, now) == ALLOW); } + // A lock started just before UINT32_MAX remains active across rollover and + // expires at exactly thirty seconds, then gives the client a fresh count. + { + Tracker t; const uint32_t start = UINT32_MAX - 1000u; + fail(t, A, start, kClientFails); + assert(t.check(A, UINT32_MAX) == CLIENT_LOCKED); + assert(t.check(A, 0) == CLIENT_LOCKED); + assert(t.check(B, 0) == ALLOW); + assert(t.check(A, start + kClientLockMs - 1) == CLIENT_LOCKED); + fail(t, A, start + kClientLockMs, kClientFails); + assert(t.check(A, start + kClientLockMs) == CLIENT_LOCKED); + } + // The shared allowance also refills across rollover, once per six seconds. + { + Tracker t; const uint32_t start = UINT32_MAX - 3000u; + uint32_t ip = 1; + for (int i = 0; i < kSharedBudget; i++) fail(t, ip++, start); + assert(t.check(ip, UINT32_MAX) == SHARED_LOCKED); + assert(t.check(ip, 0) == SHARED_LOCKED); + assert(t.check(ip, start + kSharedRefillMs - 1) == SHARED_LOCKED); + fail(t, ip++, start + kSharedRefillMs); + assert(t.check(ip, start + 2 * kSharedRefillMs - 1) == SHARED_LOCKED); + fail(t, ip++, start + 2 * kSharedRefillMs); + assert(t.check(ip, start + 2 * kSharedRefillMs) == SHARED_LOCKED); + } // Failures recorded without a check saturate instead of wrapping. { Tracker t; uint32_t now = 100000; diff --git a/test/fixtures/webconfig_recovery_auth.cpp b/test/fixtures/webconfig_recovery_auth.cpp index e62ff3f28..17b4a110a 100644 --- a/test/fixtures/webconfig_recovery_auth.cpp +++ b/test/fixtures/webconfig_recovery_auth.cpp @@ -22,7 +22,11 @@ struct Callback { }; struct WCLock { explicit WCLock(int) {} }; struct WiFiMock { int softAPIP() const { return 4; } } WiFi; -struct Client { int local_ip=4; int localIP() const { return local_ip; } uint32_t getRemoteAddress() const { return 0; } }; +struct Client { + int local_ip=4; uint32_t remote=0; + int localIP() const { return local_ip; } + uint32_t getRemoteAddress() const { return remote; } +}; struct Header { String cookies; const String& value() const { return cookies; } }; struct AsyncWebServerResponse { int status; std::map headers; @@ -106,5 +110,25 @@ int main() { // password management. Keep that explicit no-password deployment policy. portal._initial_setup=false; portal.callback.node.admin_password[0]=0; assert(portal.checkAuth(&attacker)); portal.handleLogin(&attacker); assert(attacker.status==200); + // A spent login budget blocks new logins without revoking an existing + // authenticated session, on either the saved-network AP or the LAN. + for (int mode : {WebConfigServer::MODE_SETUP, WebConfigServer::MODE_LAN}) { + WebConfigServer active; active._mode=mode; + AsyncWebServerRequest owner; owner.socket.remote=100; + owner.password("existing-secret"); active.handleLogin(&owner); + assert(owner.status==200); + const std::string token=active._session_token; + owner.header.cookies=std::string("wcs=")+token; + for (uint32_t address = 1; address <= 6; address++) { + AsyncWebServerRequest wrong; wrong.socket.remote=address; + wrong.password("wrong"); active.handleLogin(&wrong); assert(wrong.status==401); + } + AsyncWebServerRequest login; login.socket.remote=200; + login.password("existing-secret"); active.handleLogin(&login); + assert(login.status==429 && token==active._session_token); + assert(active.checkAuth(&owner)); + active.checkConfigPost(&owner); assert(active.config_accepted); + assert(token==active._session_token); + } puts("WebConfig recovery authentication regression checks passed"); } diff --git a/test/test_companion_contact_partial_tail.py b/test/test_companion_contact_partial_tail.py index 6a9385d80..e510c4547 100644 --- a/test/test_companion_contact_partial_tail.py +++ b/test/test_companion_contact_partial_tail.py @@ -125,6 +125,9 @@ static void whole_record_read_errors_keep_bounded_recovery() { static void a_complete_backup_still_wins() { const auto image = tailImage(); for (unsigned fault = 0; fault < 3; ++fault) { +#if defined(RP2040_PLATFORM) + if (fault == 2) continue; // Arduino-Pico exists() hides metadata errors. +#endif tailFs() = FakeFilesystem(); tailFs().files["/contacts3"] = partial(image, 1); tailFs().files["/contacts3.bak"] = records(image, 2); @@ -143,32 +146,77 @@ static void a_complete_backup_still_wins() { assert(!tailFs().files.count("/contacts3.bak")); } } + +#if defined(ESP32_PLATFORM) +static void unknown_backup_presence_preserves_sources() { + const auto image = tailImage(); + tailFs() = FakeFilesystem(); + tailFs().files["/contacts3"] = partial(image, 1); + tailFs().files["/contacts3.bak"] = records(image, 2); + tailFs().files["/channels2"] = {3, 4, 5}; + tailFs().files["/identity/_main.id"] = {7, 8, 9}; + const auto durable = tailFs().files; + { + failMetadata(true); + TailBoot b; + b.store.loadContacts(&b.host); + assert(b.store.contactRecoveryPending() && b.host.contacts.empty()); + assert(!tailSave(b) && tailFs().files == durable); + b.store.recoverContacts(&b.host); // Presence remains unknown on the retry. + assert(!b.store.contactRecoveryPending() && b.store.contactsAreVolatile()); + assert(b.host.contacts.empty() && tailFs().files == durable); + failMetadata(false); + assert(!tailSave(b) && tailFs().files == durable); // Volatile for this boot. + } + TailBoot rebooted; + boot(rebooted); + assert(!rebooted.store.hasIncompleteContactLoad() && !rebooted.store.contactsAreVolatile()); + checkLoaded(rebooted.host, image, 2); // The complete backup still wins. + assert(tailFs().files.at("/contacts3") == records(image, 2)); + assert(!tailFs().files.count("/contacts3.bak")); + assert(tailFs().files.at("/channels2") == durable.at("/channels2")); + assert(tailFs().files.at("/identity/_main.id") == durable.at("/identity/_main.id")); +} +#endif #endif -#if !MESH_CONTACT_CACHE static void salvaged_primary_open_failure_is_not_absence() { const auto image = tailImage(); - filesystem = FakeFilesystem(); - filesystem.files["/contacts3"] = partial(image, 1); + tailFs() = FakeFilesystem(); + tailFs().files["/contacts3"] = partial(image, 1); { TailBoot dry; // The last read open of a healthy load opens the image to load. dry.store.loadContacts(&dry.host); assert(dry.host.contacts.size() == 3); } - const unsigned load_open = filesystem.read_opens; - filesystem = FakeFilesystem(); - filesystem.files["/contacts3"] = partial(image, 1); - filesystem.fail_read_open = load_open; - TailBoot b; - b.store.loadContacts(&b.host); - assert(b.store.hasIncompleteContactLoad() && b.store.contactRecoveryPending()); - const auto unchanged = filesystem.files; - assert(!tailSave(b) && filesystem.files == unchanged); - b.store.recoverContacts(&b.host); - assert(!b.store.hasIncompleteContactLoad() && !b.store.contactRecoveryPending()); - checkLoaded(b.host, image, 3); -} + const unsigned load_open = tailFs().read_opens; + for (bool add_backup : {false, true}) { +#if defined(STM32_PLATFORM) + if (add_backup) continue; // STM32 does not select a contact backup. #endif + tailFs() = FakeFilesystem(); + tailFs().files["/contacts3"] = partial(image, 1); + tailFs().fail_read_open = load_open; + TailBoot b; + b.store.loadContacts(&b.host); + assert(tailFs().read_opens == load_open); + assert(b.store.hasIncompleteContactLoad() && b.store.contactRecoveryPending()); + assert(b.host.contacts.empty()); + const auto unchanged = tailFs().files; + const auto writes = tailFs().writes; + assert(!tailSave(b) && tailFs().files == unchanged && tailFs().writes == writes); + // A backup available at the recovery boundary takes precedence over the + // salvaged prefix, even though the first selection had admitted it. + if (add_backup) tailFs().files["/contacts3.bak"] = records(image, 2); + b.store.recoverContacts(&b.host); + assert(!b.store.hasIncompleteContactLoad() && !b.store.contactRecoveryPending()); + checkLoaded(b.host, image, add_backup ? 2 : 3); + if (add_backup) { + assert(tailFs().files.at("/contacts3") == records(image, 2)); + assert(!tailFs().files.count("/contacts3.bak")); + } else assert(tailFs().files == unchanged); + } +} int main() { partial_tail_keeps_whole_records(); @@ -176,10 +224,11 @@ int main() { whole_record_read_errors_keep_bounded_recovery(); #if !defined(STM32_PLATFORM) a_complete_backup_still_wins(); +#if defined(ESP32_PLATFORM) + unknown_backup_presence_preserves_sources(); +#endif #endif -#if !MESH_CONTACT_CACHE salvaged_primary_open_failure_is_not_absence(); -#endif printf("''' + MARKER + r'''\n"); return 0; } @@ -206,7 +255,18 @@ def uncached_fixture(work): def cached_fixture(work): fixtures = ROOT / "test/fixtures/contact_cache" - (work / "FakeFilesystem.h").write_text((fixtures / "FakeFilesystem.h").read_text()) + filesystem = (fixtures / "FakeFilesystem.h").read_text() + # Match the uncached fixture's one-shot final-load-open failure without + # changing the shared filesystem adapter used by unrelated cache tests. + for old, new in ((" unsigned false_success_rename = 0, late_rename_error = 0;\n", + " unsigned false_success_rename = 0, late_rename_error = 0;\n" + " unsigned read_opens = 0, fail_read_open = 0;\n"), + (" auto failure = read_open_failures.find(path);\n", + " if (++read_opens == fail_read_open) return File();\n" + " auto failure = read_open_failures.find(path);\n")): + assert filesystem.count(old) == 1, old + filesystem = filesystem.replace(old, new) + (work / "FakeFilesystem.h").write_text(filesystem) source = (fixtures / "test_contact_cache.cpp").read_text() generated = work / "partial_tail.cpp" generated.write_text("#include \n" + source[:source.index("int main() {")] + CHECKS) diff --git a/test/test_esp32_partition_migration_recipe.py b/test/test_esp32_partition_migration_recipe.py index 5bd23049d..6dd32c10f 100644 --- a/test/test_esp32_partition_migration_recipe.py +++ b/test/test_esp32_partition_migration_recipe.py @@ -147,6 +147,30 @@ class Esp32MigrationRecipeTest(unittest.TestCase): self.assertEqual("esp32_s3_8mb_partition_migrator_usb", steps[3][0][3]) self.assertEqual("esp32_s3_8mb_partition_migrator_lora", steps[4][0][3]) + def test_generated_guides_explain_credentials_for_each_recovery_route(self): + for board in ("heltec-v2-room-server", "heltec-v4", + "waveshare-s3-lr1121-observer-repeater"): + with self.subTest(board=board): + guide = readme(board, BOARDS[board], "v1.18.0-test", "01234567") + credentials = " ".join(guide.split("## Bridge Wi-Fi credentials\n", 1)[1] + .split("## Wi-Fi route\n", 1)[0].split()) + for rule in ("saved admin password", "8 to 15 printable ASCII characters", + "shorter than 8", "nonprintable or non-ASCII", "cannot be read", + "Missing saved preferences", "meshcore-migrate", + "verify it survives a normal reboot"): + self.assertIn(rule, credentials) + lora = " ".join(guide.split("## LoRa route", 1)[1].split()) + if BOARDS[board].get("expander_bridge") == BOARDS[board]["lora_bridge"]: + self.assertIn("uses the restored saved admin password", lora) + self.assertNotIn("post-expansion Wi-Fi recovery AP normally uses", lora) + else: + self.assertIn("post-expansion Wi-Fi recovery AP normally uses `meshcore-migrate`", + lora) + if BOARDS[board].get("expander_bridge"): + expander = " ".join(guide.split("## Partition Expander route", 1)[1] + .split("## LoRa route", 1)[0].split()) + self.assertIn("uses the restored saved admin password", expander) + def test_all_six_usb_chip_capacity_profiles_are_built_once_and_wifi_retained(self): steps = build_steps(list(BOARDS), "v1.17.1.7-test", "usa-cascadia", "cascade", 4) utilities = [command[3] for command, full in steps if not full] diff --git a/test/test_esp32_partition_migrator_ap_password.py b/test/test_esp32_partition_migrator_ap_password.py index d70cef5ea..f3ecc1a86 100644 --- a/test/test_esp32_partition_migrator_ap_password.py +++ b/test/test_esp32_partition_migrator_ap_password.py @@ -111,8 +111,9 @@ esp_err_t esp_wifi_get_max_tx_power(int8_t* power) { *power = 78; return ESP_OK; void delay(unsigned) {} struct AsyncWebServerRequest { - void send(int, const char*, const String&) {} - void send(int, const char*, const char*) {} + std::string body; + void send(int, const char*, const String& text) { body = text.s; } + void send(int, const char*, const char* text) { body = text; } }; struct AsyncWebServer { std::vector routes; @@ -136,6 +137,7 @@ struct File { struct Filesystem { std::map> files; bool mountable = true, mounted = false, seek_fails = false, short_read = false; + std::string fail_open; unsigned begins = 0, ends = 0, opens = 0, closes = 0; // Before migration this is the legacy filesystem: it must never be formatted. bool begin(bool format) { assert(!format); ++begins; mounted = mountable; return mounted; } @@ -145,7 +147,9 @@ struct Filesystem { assert(!strcmp(mode, "r")); File file; auto entry = files.find(path); - if (mounted && entry != files.end()) { file.data = &entry->second; ++opens; } + if (mounted && fail_open != path && entry != files.end()) { + file.data = &entry->second; ++opens; + } return file; } } SPIFFS; @@ -235,6 +239,18 @@ void expectFallback() { balancedMount(); } +void expectUploadDisabled(const char* expected_password) { + startServer(); + assert(WiFi.ap_calls == 1 && !WiFi.open && WiFi.password == expected_password); + assert(AsyncElegantOTA.begins == 0); + AsyncWebServerRequest request; + sendHome(&request); + assert(request.body.find("Private identity recovery did not complete") != std::string::npos); + assert(request.body.find("Firmware upload is disabled") != std::string::npos); + assert(request.body.find("href='/update'") == std::string::npos); + balancedMount(); +} + int main(int argc, char** argv) { assert(argc == 2); const int scenario = atoi(argv[1]); @@ -288,6 +304,19 @@ int main(int argc, char** argv) { reset(); SPIFFS.short_read = true; SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9"); expectFallback(); + } else if (scenario == 7) { + reset(true); identity_ready = false; + SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9"); + expectUploadDisabled("Correct-Horse9"); + reset(true); identity_ready = false; + expectUploadDisabled(kFallback); + } else if (scenario == 8) { + // Existing /com_prefs wins even when it cannot be opened. Never use a + // potentially stale /node_prefs password to bypass that file precedence. + reset(); SPIFFS.fail_open = "/com_prefs"; + SPIFFS.files["/com_prefs"] = prefs(290, "Com-prefs-pass"); + SPIFFS.files["/node_prefs"] = prefs(290, "Node-prefs-pass"); + expectFallback(); } else { return 2; } @@ -361,6 +390,12 @@ class MigrationBridgeApPasswordTests(unittest.TestCase): def test_failed_seek_or_partial_read_falls_back(self): self.run_scenario(6) + def test_failed_identity_restore_disables_upload_with_either_ap_credential(self): + self.run_scenario(7) + + def test_unopenable_common_prefs_does_not_use_legacy_password(self): + self.run_scenario(8) + def test_operator_instructions_name_the_admin_password(self): # Extra guard only: the instructions must not present the published # fallback as the bridge's normal Wi-Fi password. diff --git a/test/test_room_login_replay_capacity.py b/test/test_room_login_replay_capacity.py index 19c470454..b55f19d85 100644 --- a/test/test_room_login_replay_capacity.py +++ b/test/test_room_login_replay_capacity.py @@ -120,6 +120,18 @@ static void guestChurnCannotLockOutNewIdentities() { CHECK(!login(room, guest, "published", 1000 + i)); CHECK(login(room, guest, "published", 2000 + i)); } + // A guest without a durable record loses its exact live floor on eviction. + // Re-admission starts another transient writer session, whose immediate + // replay is rejected without allocating a permanent replay-store slot. + const auto evicted_guest = member(0, 0x6E); + CHECK(!room.acl.getClient(evicted_guest.pub_key, PUB_KEY_SIZE)); + CHECK(storedIdentities(fs) == 0); + CHECK(login(room, evicted_guest, "published", 1000)); + const auto* readmitted = room.acl.getClient(evicted_guest.pub_key, PUB_KEY_SIZE); + CHECK(readmitted && readmitted->permissions == PERM_ACL_READ_WRITE + && !readmitted->permissions_are_explicit); + CHECK(!login(room, evicted_guest, "published", 1000)); + CHECK(storedIdentities(fs) == 0); // A new Admin device and an assigned writer's first login are still admitted. CHECK(login(room, admin, "admin-secret", 3000)); CHECK(login(room, writer, "", 3000));