From f46262dadcccb6e60aeaa34b74c8b80b9563ba74 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 29 Aug 2026 09:10:24 -0700 Subject: [PATCH] release: pin RAK3401 b40 mOTA chain --- docs/rak3401_mota_chain.md | 214 +++++++++++++++++---------- tools/lora_ota/rak3401_mota_chain.py | 177 ++++++++++++++++------ tools/lora_ota/test_lora_ota.py | 84 ++++++++++- 3 files changed, 348 insertions(+), 127 deletions(-) diff --git a/docs/rak3401_mota_chain.md b/docs/rak3401_mota_chain.md index 65642c87..37c2d121 100644 --- a/docs/rak3401_mota_chain.md +++ b/docs/rak3401_mota_chain.md @@ -1,32 +1,32 @@ # RAK3401 1W repeater compact LoRa update chain -> Status: **unreleased candidate with physically passed package transitions.** -> On 29-Aug-2026 all ten exact package transitions completed on the target -> RAK3401 with its deployed `0.9.2-OTAFIX2.4` bootloader and a Heltec V4 source at -> 909.950 MHz / 500 kHz / SF5 / CR5. Every intermediate EndF hash matched. -> Independent SWD readback then matched the endpoint application, original -> bootloader, SoftDevice/MBR, and UICR byte-for-byte. The host runner received -> cleanup/recovery fixes after that test and has not had a new clean end-to-end -> physical rerun. Multi-hop and alternate-bandwidth estimates remain planning -> data, not physical qualification. +> Status: **published prerelease for controlled, recoverable lab use.** On +> 29-Aug-2026 steps 1-9 completed on the target RAK3401 and are byte-for-byte +> identical to the corresponding transitions in the physically passed +> `fd98bc90` chain. The replacement step 10 installs the current `b40d2e6c` +> endpoint. That replacement package and the hardened current runner have +> passed offline reconstruction plus both required bootloader simulators, but +> have not had a new physical end-to-end run. Use the lab gate and keep local +> DFU/SWD recovery available. Multi-hop and alternate-bandwidth estimates +> remain planning data, not physical qualification. -The candidate is a local qualification artifact, not a GitHub release: +The prerelease is +[`rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.1.5-b40d2e6c`](https://github.com/mikecarper/MeshCore/releases/tag/rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.1.5-b40d2e6c). +Use this asset: ```text -RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip +RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip ``` -- candidate ZIP SHA-256: `c0b33f4568985e8b2b8dc99411295907212cf2bad21764b6333d5e0ba298fd61` -- inner `SHA256SUMS.txt` SHA-256: `3f8c4af8096b96a4aa6506825c387cc8a06f74d5213a29c9387bd11689546881` -- endpoint image SHA-256: `31c182c888ceb1135e5afb2376610d93cee2e807b556c838e07fd4486c79d095` -- endpoint EndF body hash: `9179B98432895924` +- prerelease ZIP SHA-256: `8e7f0c2565f37d4372fa51dc3ba72b591e7f38b74be09303e48641623f99bce4` +- inner `SHA256SUMS.txt` SHA-256: `29a02a6c4c83eafa80168a80562b0ded5d8432e6cc02cfaca289cb2ed426681a` +- endpoint image SHA-256: `a7761da8af1f2447d7f01d72e2b1456b99c0e874a165d6483f48ab1f58e8d905` +- endpoint EndF body hash: `1FA6AAA3C28D8BD8` -Always pass the exact local path with `--bundle`. The runner has no download -URL for this candidate. `--verify-only` remains the safe default. The hidden -`--accept-test-candidate` override is intentionally absent from `--help` and -remains required until this exact artifact is published with its qualification -record; direct physical success does not silently turn a local file into a -released deployment asset. +`--verify-only` remains the safe default. Live preflight and installation of +this prerelease require `--accept-test-candidate`. That switch bypasses only +the prerelease-status gate; it does not bypass the archive, identity, +bootloader, route, watchdog, reachability, or post-boot checks. ## Exact destination @@ -37,8 +37,8 @@ target: - hardware: `RAK_3401` - role: `RAK_3401_repeater_lora_ota_no_external_sensors` - start: `v1.16.7.0-c1caa5ad`, EndF `71F4026CBE4B8B74` -- endpoint source: MeshCore `fd98bc90f7fb25d5956facd550e227b217abbe95` -- endpoint label: `v1.17.1.5-halo-keymind-cascade-dev-fd98bc90` +- endpoint source: MeshCore `b40d2e6cb2a185db740175e8b161ea71736d19d9` +- endpoint label: `v1.17.1.5-halo-keymind-cascade-dev-b40d2e6c` - endpoint packed version: `0x01110105` - deployment target key: `63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d` @@ -86,14 +86,14 @@ Every package in this candidate is an ordinary application container using format ABI 2 and in-place codec 2. The historical bridge applications scan the bootloader capability marker byte-by-byte, so they recognize the released Preview 5 RAK4631 marker at absolute address `0xFCDEA` even though it is two -bytes off a word boundary. The `fd98bc90` endpoint deliberately retains that -exact Preview 5 application-update fallback. Its separately compiled internal +bytes off a word boundary. The `b40d2e6c` endpoint retains that exact Preview +5 application-update fallback. Its separately compiled internal bootloader-update feature remains unavailable on Preview 5 because privileged self-update requires a current ABI 3 marker with the exact storage profile. Installing this chain therefore does not replace the bootloader, and the final application can still accept a future valid ABI-2/codec-2 application update. -The schema-2 exhaustive search checked 17,402 candidate geometries and found +The schema-2 exhaustive search checked 17,371 candidate geometries and found 121 feasible forward edges in the declared 33-image inventory. Fourteen ten-package routes tied for the minimum package count; the selected route has the smallest total container size among them. That minimum applies to the @@ -111,12 +111,21 @@ is: | 7 | 1.16.9.113 | 1.16.9.117 | `0x8B000` | 139,637 | 0 | | 8 | 1.16.9.117 | 1.16.10.0 | `0x7E000` | 174,202 | 20,480 | | 9 | 1.16.10.0 | 1.17.1.3 | `0x6B000` | 271,569 | 0 | -| 10 | 1.17.1.3 | 1.17.1.5 | `0x74000` | 173,334 | 61,440 | +| 10 | 1.17.1.3 | 1.17.1.5 | `0x74000` | 174,744 | 61,440 | -Total mOTA transfer data is 1,382,842 bytes. `ROUTE.json`, `CHAIN.csv`, and +Total mOTA transfer data is 1,384,252 bytes. `ROUTE.json`, `CHAIN.csv`, and `validation-results.json` in the bundle pin the exact geometry and image hash for every transition. +Steps 1-9 are byte-for-byte identical to the transitions that passed on the +physical RAK3401. The replacement step 10, from the physically reached +`FE65A6135A1E7B3F` v1.17.1.3 body to `1FA6AAA3C28D8BD8`, has offline +qualification only. All ten packages passed independent container checks, +zero-filled and erased-workspace reconstruction, the OTAFIX 2.4.1 Preview 5 +simulator, and the current OTAFIX simulator. Those results prove the package +bytes and apply geometry; they are not a physical claim for the replacement +step 10 or the hardened runner. + ## Host requirements Install Python 3.9 or newer, `meshcli` 1.6.0 or newer, the current `motatool`, @@ -128,7 +137,7 @@ No password or device is needed: ```bash python3 tools/lora_ota/rak3401_mota_chain.py \ - --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip \ --work-dir ./rak3401-mota-chain-work \ --motatool /path/to/motatool \ --verify-only @@ -141,14 +150,14 @@ inner coverage, all ten manifests, continuity, image anchors, both recovery images, and every container with `motatool`. Passing this command means offline and simulator qualification only; it does not unlock live use. -## Current guarded reproduction command +## Guarded prerelease lab command -These are the same direct-link arguments used for the physical transition run, -but the current host runner also contains later cleanup and recovery fixes. -That revised runner has not been cleanly rerun through all ten transitions. -Keep local USB/SWD recovery available while the artifact remains unreleased. -The hidden override bypasses only the candidate-status block; all identity, -checksum, bootloader, route, watchdog, and post-boot gates still run. +These direct-link arguments match the physical transition setup, but the +current package replaces step 10 and the current host runner contains later +cleanup and recovery fixes. Neither has been cleanly rerun through the whole +chain. Keep local USB/SWD recovery available. The candidate switch bypasses +only the prerelease-status block; all identity, checksum, bootloader, route, +watchdog, reachability, and post-boot gates still run. Restore the test start locally with `recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2`, then run: @@ -157,7 +166,7 @@ Restore the test start locally with export MESHCORE_ADMIN_PASSWORD='password' python3 tools/lora_ota/rak3401_mota_chain.py \ - --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip \ --work-dir ./rak3401-mota-chain-work \ --controller-serial /dev/ttyACM0 \ --source-tcp 192.168.1.51:5001 \ @@ -179,7 +188,30 @@ zero and restores it at the endpoint. Use that option only where the selected frequency and local duty-cycle rules permit a full transmit budget; omit it otherwise. -### Measured direct-link timing +## Legacy `fd98bc90` physical and SWD qualification + +The earlier local candidate remains the exact physical evidence source for +this route: + +```text +RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip +``` + +- candidate ZIP SHA-256: `c0b33f4568985e8b2b8dc99411295907212cf2bad21764b6333d5e0ba298fd61` +- inner `SHA256SUMS.txt` SHA-256: `3f8c4af8096b96a4aa6506825c387cc8a06f74d5213a29c9387bd11689546881` +- endpoint image SHA-256: `31c182c888ceb1135e5afb2376610d93cee2e807b556c838e07fd4486c79d095` +- endpoint EndF body hash: `9179B98432895924` + +All ten exact `fd98bc90` transitions completed on the RAK3401 with its +deployed `0.9.2-OTAFIX2.4` bootloader and a Heltec V4 source at 909.950 MHz / +500 kHz / SF5 / CR5. Every intermediate EndF hash matched. Independent SWD +readback then matched the endpoint application, original bootloader, +SoftDevice/MBR, and UICR byte-for-byte. Steps 1-9 are also the exact first nine +packages in the `b40d2e6c` prerelease. The two candidates have different step +10 packages and endpoints, so the physical result and step-10 timing must not +be attributed to `b40d2e6c`. + +### Measured direct-link timing for `fd98bc90` The first `ready to install` transfer ages were: @@ -196,23 +228,44 @@ The first `ready to install` transfer ages were: | 9 | 264 | 187 s | | 10 | 169 | 193 s | -The physically observed bulk-transfer total for those exact packages is 1,605 -seconds (26m45s). The log came from the pre-cleanup-fix runner revision. Step 1 -was measured before the host began forcing source RXPS off and is therefore a -conservative outlier. A new complete run with that fix is projected at roughly -18–19 minutes of bulk transfer, but that projection is not a substitute for -the measured total. Allow about 70–75 minutes direct at BW500 for validation, -ten transfers, installs/reboots, retained-store checks, and final restoration. +The physically observed bulk-transfer total for those exact legacy packages is +1,605 seconds (26m45s). The log came from the pre-cleanup-fix runner revision. +Step 1 was measured before the host began forcing source RXPS off and is +therefore a conservative outlier. A new complete run with that fix is projected +at roughly 18–19 minutes of bulk transfer, but that projection is not a +substitute for the measured total. Allow about 70–75 minutes direct at BW500 +for validation, ten transfers, installs/reboots, retained-store checks, and +final restoration. -Step 10's longer per-block time was not a V4 TempRadio delay: the source RXPS -readback was off. Its v1.17.1.3 requester uses non-overlapping adaptive flights -and a five-second recovery floor and logged more block reloads than step 8. -The V4 itself intentionally schedules its local radio handoff after 1.5 seconds -so the normal-channel command reply can drain; the host waits three seconds and -then checks the live TempRadio state. +The legacy step 10's longer per-block time was not a V4 TempRadio delay: the +source RXPS readback was off. Its v1.17.1.3 requester uses non-overlapping +adaptive flights and a five-second recovery floor and logged more block reloads +than step 8. The V4 itself intentionally schedules its local radio handoff +after 1.5 seconds so the normal-channel command reply can drain; the host waits +three seconds and then checks the live TempRadio state. -For planning only, scale the conservative measured 26m45s bulk baseline by the -qualified adaptive-preamble airtime and by each additional relay transmission: +### Live progress and speed sanity checks + +The runner watches `motatool serve -v` locally, so its quarter-progress reports +do not consume LoRa airtime. At BW500 direct, physically measured steps 2–9 +averaged about 1.1–1.4 newly requested payload blocks per second; the legacy +step 10 averaged about 0.88 blocks/s, and step 1's 0.15 blocks/s was the known +pre-RXPS-fix outlier. Use those only as bench sanity ranges, not guarantees. +Relays, narrower bandwidths, interference, and recovery reads lower the rate. + +Healthy output keeps advancing from `[download] passive source progress x/y` +to `source read all y payload blocks`, followed by destination status `ready to +install y/y (100%)` for the same manifest ID. Aggregate reads above the unique +block count indicate recovery/retry traffic. A radio-scaled no-progress window +causes one destination status check; the transfer timeout leaves the partial +download staged for an exact-package resume. Stop and diagnose instead of +installing if the manifest changes, progress remains flat through those checks, +the final receiver count is not `y/y`, or the measured rate is far below the +matching bandwidth/hop estimate without an understood RF cause. + +For planning only, scale the conservative measured `fd98bc90` 26m45s bulk +baseline by the qualified adaptive-preamble airtime and by each additional +relay transmission: | Bandwidth, SF5/CR5 | Direct / 0 relays | 1 relay | 2 relays | |---:|---:|---:|---:| @@ -238,17 +291,17 @@ never skip a step even when a later package appears in `ota ls`. ### 1. Verify and extract the asset ```bash -sha256sum RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip -unzip RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip -cd RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90 +sha256sum RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip +unzip RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip +cd RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c sha256sum -c SHA256SUMS.txt for package in motas/*.mota; do motatool verify "$package" || exit 1; done ``` The outer hash must be -`c0b33f4568985e8b2b8dc99411295907212cf2bad21764b6333d5e0ba298fd61`, +`8e7f0c2565f37d4372fa51dc3ba72b591e7f38b74be09303e48641623f99bce4`, and the SHA-256 of the extracted `SHA256SUMS.txt` must be -`3f8c4af8096b96a4aa6506825c387cc8a06f74d5213a29c9387bd11689546881`. +`29a02a6c4c83eafa80168a80562b0ded5d8432e6cc02cfaca289cb2ed426681a`. Do not continue after any checksum or verification error. ### 2. Record and prepare the destination @@ -342,7 +395,7 @@ to install`. | 7 | `80F40DB6` | `65328FC8A1FBED2D` | `1.16.9.117` | `AF7532E13337FADD` | | 8 | `DBF6310D` | `AF7532E13337FADD` | `1.16.10.0` | `22002359BBDFA76E` | | 9 | `C2979E08` | `22002359BBDFA76E` | `1.17.1.3` | `FE65A6135A1E7B3F` | -| 10 | `319DEC92` | `FE65A6135A1E7B3F` | `1.17.1.5` | `9179B98432895924` | +| 10 | `B5011420` | `FE65A6135A1E7B3F` | `1.17.1.5` | `1FA6AAA3C28D8BD8` | For each row, first prove that `ota self` exactly matches its **Before hash**, then inspect the manager before changing it: @@ -406,10 +459,9 @@ If the board boots but `ota self` says the EndF is invalid, stop. Only when `ota status` still identifies that row's complete staged package may the guarded `ota rescue install BEFORE_HASH` command be used. It is not a force option and must use that row's exact Before hash. If the board does not boot, -recover the documented start/recovery UF2 locally over USB. The fd98 endpoint -deliberately disables rescue on its shared internal bootloader-update build; -if its EndF is invalid, use local DFU/SWD rather than attempting another LoRa -package. +recover the documented start/recovery UF2 locally over USB. Step 10 is the +endpoint and has no successor package in this chain. If its EndF is invalid, +use local DFU/SWD rather than attempting an unqualified LoRa package. ### 4. Intermediate relays and restoration @@ -436,7 +488,7 @@ the saved tuple after replying on the temporary channel: normalradio ``` -The fd98 endpoint supports that command. If a recovery build unexpectedly +The `b40d2e6c` endpoint supports that command. If a recovery build unexpectedly replies `Unknown command`, shorten its lease to one minute with the same tuple: ```text @@ -456,25 +508,26 @@ A Full Companion source is the exception: its local TCP console supports Binary API radio tuple. Final lab success requires version -`v1.17.1.5-halo-keymind-cascade-dev-fd98bc90`, body hash -`9179B98432895924`, target `2FA509C1`, hardware `RAK_3401`, and +`v1.17.1.5-halo-keymind-cascade-dev-b40d2e6c`, body hash +`1FA6AAA3C28D8BD8`, target `2FA509C1`, hardware `RAK_3401`, and `get system.watchdog` reporting `> on`. If a relay cannot be restored before its TempRadio lease ends, wait for it to return to the normal channel and restore its saved `rxdelay` and `txdelay` there. ## Two-relay deployment -The exact direct package transitions above were physically observed; this -post-run host-runner revision and multi-hop operation are not cleanly -end-to-end qualified. A controlled, recoverable multi-hop lab can exercise two -intermediate relays by +The first nine direct package transitions above were physically observed as +the byte-identical first nine transitions of the legacy chain. Replacement +step 10, this hardened host-runner revision, and multi-hop operation are not +cleanly end-to-end physically qualified. A controlled, recoverable multi-hop +lab can exercise two intermediate relays by listing them farthest-to-nearest and using three OTA hops. Run a non-mutating -preflight first; even preflight requires the hidden candidate override because -the artifact remains unreleased and the command connects to live devices: +preflight first; even preflight requires the prerelease candidate override +because the command connects to live devices: ```bash python3 tools/lora_ota/rak3401_mota_chain.py \ - --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip \ --work-dir ./rak3401-mota-chain-work \ --controller-serial /dev/ttyACM0 \ --source-tcp SOURCE_IP:5001 \ @@ -501,8 +554,8 @@ step 1 and remains available through the step-9 bridge. It is not a force command: it refuses a valid normal EndF, a base mismatch, wrong target/hardware, or invalid payload. A failure before step 1 completes still requires local USB recovery because the deployed start image predates the -rescue command. The final fd98 endpoint uses the stricter shared-slot profile; -its invalid-EndF recovery is local DFU/SWD, not the rescue command. +rescue command. The final `b40d2e6c` endpoint uses the stricter shared-slot +profile; its invalid-EndF recovery is local DFU/SWD, not the rescue command. Before the first mutation, the runner saves the destination's RXPS periods, CPU power-saving state, RX flood delay, airtime factor, and OTA hop reach in @@ -537,7 +590,7 @@ the relay leaves TempRadio. The runner also keeps the watchdog off across the chain, rechecks identity and OTA reach before every package, requires the exact post-boot EndF hash, and only re-enables the watchdog after step 10. Lab success requires endpoint body -hash `9179B98432895924`, target `2FA509C1`, hardware `RAK_3401`, normal radio +hash `1FA6AAA3C28D8BD8`, target `2FA509C1`, hardware `RAK_3401`, normal radio `910.525 MHz / 62.5 kHz / SF7 / CR5`, and the watchdog verified on. ## External-radio startup and manual USB recovery @@ -573,6 +626,15 @@ the battery as well as USB and reseat the WisBlock module. Remote firmware will continue retrying without requiring that physical intervention or churning the USB device. +For an existing deployed tower using Preview 5, do not change the bootloader +as part of this application chain. Its exact local recovery package is the +[Preview 5 RAK4631 ZIP](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/0.9.2-OTAFIX2.4.1-preview.5/wiscore_rak4631_board_bootloader-0.9.2-OTAFIX2.4.1-preview.5_s140_6.1.1.zip). +For a new RAK3401 installation or local recovery, use the +[OTAFIX 2.4.3 RAK3401 ZIP](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/0.11.0-OTAFIX2.4.3/wiscore_rak3401_bootloader-0.11.0-OTAFIX2.4.3_s140_6.1.1.zip) +from the +[OTAFIX 2.4.3 release](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.11.0-OTAFIX2.4.3). +These are local DFU/SWD recovery assets, not mOTA chain steps. + For a local Serial DFU recovery with the repository helper: ```bash diff --git a/tools/lora_ota/rak3401_mota_chain.py b/tools/lora_ota/rak3401_mota_chain.py index db190874..40f1e25a 100755 --- a/tools/lora_ota/rak3401_mota_chain.py +++ b/tools/lora_ota/rak3401_mota_chain.py @@ -29,16 +29,36 @@ except ImportError: import lora_ota as ota -# These exact ten package transitions passed directly on the physical target, -# and the resulting endpoint passed independent SWD readback. The host runner -# has received cleanup/recovery fixes since that run and has not itself had a -# new clean end-to-end qualification run. Keep the artifact unpublished and -# normally gated from live use; never invent a release URL. -RELEASE_URL = "unreleased local RAK3401 10-step candidate" -ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip" -ASSET_SHA256 = "c0b33f4568985e8b2b8dc99411295907212cf2bad21764b6333d5e0ba298fd61" -CHECKSUM_LIST_SHA256 = "3f8c4af8096b96a4aa6506825c387cc8a06f74d5213a29c9387bd11689546881" -BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90" +# The published b40d2e6c prerelease keeps the first nine package bytes from the +# physically passed fd98bc90 chain and replaces its endpoint package. The new +# step 10 and this hardened host runner have exhaustive offline/simulator +# evidence, but not a clean physical transition run, so live use remains gated. +RELEASE_URL = ( + "https://github.com/mikecarper/MeshCore/releases/tag/" + "rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.1.5-b40d2e6c" +) +ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip" +ASSET_SHA256 = "8e7f0c2565f37d4372fa51dc3ba72b591e7f38b74be09303e48641623f99bce4" +CHECKSUM_LIST_SHA256 = "29a02a6c4c83eafa80168a80562b0ded5d8432e6cc02cfaca289cb2ed426681a" +BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c" + +# Preserve the exact fd98bc90 candidate whose ten package transitions passed +# directly on the physical RAK3401 and whose endpoint passed independent SWD +# readback. It remains a separately identified, hidden-lab-ack-gated legacy +# artifact; sharing its version and first nine targets with b40d2e6c must never +# make the two archives interchangeable. +PHYSICALLY_PASSED_FD98_ASSET_NAME = ( + "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip" +) +PHYSICALLY_PASSED_FD98_ASSET_SHA256 = ( + "c0b33f4568985e8b2b8dc99411295907212cf2bad21764b6333d5e0ba298fd61" +) +PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256 = ( + "3f8c4af8096b96a4aa6506825c387cc8a06f74d5213a29c9387bd11689546881" +) +PHYSICALLY_PASSED_FD98_ROOT_NAME = ( + "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90" +) # Preserve the physically qualified nine-step chain for exact offline # recognition and existing live deployments. @@ -140,6 +160,9 @@ MAX_CHECKSUM_LIST_BYTES = 1024 * 1024 MAX_PROGRESS_BYTES = 1024 * 1024 PINNED_ARCHIVE_CHECKSUMS = { ASSET_SHA256: CHECKSUM_LIST_SHA256, + PHYSICALLY_PASSED_FD98_ASSET_SHA256: ( + PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256 + ), PHYSICALLY_QUALIFIED_9_ASSET_SHA256: ( PHYSICALLY_QUALIFIED_9_CHECKSUM_LIST_SHA256 ), @@ -241,11 +264,10 @@ COMPACT_RELEASE_ANCHORS = ( (9, "2784e4b645bc3dc198de0b8b18d3d7369cd02eca61cd71c46a51b61854da5345"), ) -# Every target in the exact unreleased ten-package fd98bc90 sequence that -# passed direct physical transitions. These pins bind both offline verification -# and the hidden explicit live-lab override to those exact package bytes; they -# do not qualify later host-runner changes. -CURRENT_10_CANDIDATE_ANCHORS = ( +# Every target in the exact legacy fd98bc90 sequence whose package transitions +# passed directly on the physical RAK3401. The b40d2e6c prerelease reuses the +# first nine package bytes exactly, but has its own endpoint pin below. +PHYSICALLY_PASSED_FD98_ANCHORS = ( (1, "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530"), (2, "ac5f50e5028378ccfe6ea08bbf32f227f50fdcf5285a7deb866e309fbdd0a88f"), (3, "884b5e9355b4585b7a4e079dbb44d2858ec46e2fa7bb0dba47e810db2a82e349"), @@ -258,6 +280,11 @@ CURRENT_10_CANDIDATE_ANCHORS = ( (10, "31c182c888ceb1135e5afb2376610d93cee2e807b556c838e07fd4486c79d095"), ) +CURRENT_10_CANDIDATE_ANCHORS = ( + *PHYSICALLY_PASSED_FD98_ANCHORS[:9], + (10, "a7761da8af1f2447d7f01d72e2b1456b99c0e874a165d6483f48ab1f58e8d905"), +) + # Exact anchors for every image in the accelerated 30-step release. The outer # asset and inner checksum-list pins cover every byte as well; the complete # anchor table keeps structural validation fail-closed if those layers are @@ -336,31 +363,86 @@ SUPERSEDED_30_MESSAGE = ( "the deployed bootloader. Use --verify-only for the older bundle." ) CURRENT_10_CANDIDATE_MESSAGE = ( - "live installation of the exact fd98bc90 ten-step candidate is disabled: " - "the ten pinned package transitions completed directly on the physical " - "RAK3401 and the endpoint passed independent SWD readback, but the current " - "host runner includes later cleanup/recovery fixes that have not had a new " - "clean end-to-end physical run. The artifact is also local and unpublished. " - "Use --verify-only with the explicit local bundle path, or the hidden " - "controlled-lab override, until the exact artifact and qualification record " - "are published." + "live installation of the exact published b40d2e6c ten-step prerelease is " + "disabled: steps 1-9 are byte-identical to package transitions that " + "completed directly on the physical RAK3401, but the new b40d2e6c step 10 " + "and this hardened host runner have passed offline reconstruction, motatool " + "verification, and both bootloader simulators only. The exact final " + "transition has not had a clean physical run. Use --verify-only, or the " + "hidden controlled-lab override, until that qualification is complete." ) +PHYSICALLY_PASSED_FD98_MESSAGE = ( + "live installation of the exact legacy fd98bc90 ten-step candidate is " + "disabled: all ten pinned package transitions completed directly on the " + "physical RAK3401 and the endpoint passed independent SWD readback, but this " + "later hardened host runner has not had a clean end-to-end physical run. " + "Use --verify-only with the explicit legacy bundle path, or the hidden " + "controlled-lab override." +) + + class KnownUnsafeReleaseError(ota.OtaError): """The pinned artifacts are intact but their live transition is unsafe.""" +def matches_exact_anchors( + steps: list[ChainStep], + anchors: tuple[tuple[int, str], ...], +) -> bool: + return len(steps) == len(anchors) and all( + number == position + and steps[position - 1].target_sha256 == expected_sha256 + for position, (number, expected_sha256) in enumerate(anchors, 1) + ) + + +def ten_step_candidate_kind(steps: list[ChainStep]) -> str | None: + if matches_exact_anchors(steps, CURRENT_10_CANDIDATE_ANCHORS): + return "current-b40d2e6c" + if matches_exact_anchors(steps, PHYSICALLY_PASSED_FD98_ANCHORS): + return "legacy-fd98bc90" + return None + + +def ten_step_verification_message(steps: list[ChainStep]) -> str: + candidate_kind = ten_step_candidate_kind(steps) + if candidate_kind == "current-b40d2e6c": + return ( + "Current b40d2e6c ten-step prerelease verified offline: exact " + "archive/checksum pins, all target anchors, zero-filled and " + "erased-workspace reconstruction, independent motatool verification, " + "and both bootloader simulations passed. Steps 1-9 are byte-identical " + "to packages that passed directly on the RAK3401. The new step 10 and " + "this hardened host runner have not had a clean physical run; live " + "use remains gated." + ) + if candidate_kind == "legacy-fd98bc90": + return ( + "Legacy fd98bc90 ten-step candidate verified offline: exact " + "archive/checksum pins, all target anchors, zero-filled and " + "erased-workspace reconstruction, independent motatool verification, " + "and bootloader simulation passed. These exact ten package " + "transitions also passed directly on the RAK3401 and the endpoint " + "passed independent SWD readback. This later hardened host runner has " + "not had a clean physical rerun; live use remains gated." + ) + raise ota.OtaError("unrecognized ten-step candidate reached verification report") + + def require_live_release_safe( args: argparse.Namespace, steps: list[ChainStep], ) -> None: if len(steps) == EXPECTED_STEP_COUNT: - if all( - steps[number - 1].target_sha256 == expected_sha256 - for number, expected_sha256 in CURRENT_10_CANDIDATE_ANCHORS - ): + candidate_kind = ten_step_candidate_kind(steps) + if candidate_kind == "current-b40d2e6c": if args.accept_test_candidate: return raise KnownUnsafeReleaseError(CURRENT_10_CANDIDATE_MESSAGE) + if candidate_kind == "legacy-fd98bc90": + if args.accept_test_candidate: + return + raise KnownUnsafeReleaseError(PHYSICALLY_PASSED_FD98_MESSAGE) raise KnownUnsafeReleaseError( "live installation is disabled: this is an unrecognized variant " "of the pinned ten-step candidate" @@ -615,8 +697,8 @@ def download_release_asset(destination: Path) -> None: print(f"[bundle] using verified cached asset {destination}") return raise ota.OtaError( - "the exact ten-step candidate is not released; pass its explicit local " - "ZIP or extracted root with --bundle" + f"automatic release download is disabled; download {ASSET_NAME} from " + f"{RELEASE_URL} and pass its ZIP or extracted root with --bundle" ) @@ -926,6 +1008,7 @@ def locate_bundle(args: argparse.Namespace, work_dir: Path) -> Path: ) if actual not in { ASSET_SHA256, + PHYSICALLY_PASSED_FD98_ASSET_SHA256, PHYSICALLY_QUALIFIED_9_ASSET_SHA256, SUPERSEDED_30_ASSET_SHA256, SUPERSEDED_29_ASSET_SHA256, @@ -950,6 +1033,7 @@ def read_checksum_entries(bundle_root: Path) -> dict[PurePosixPath, str]: checksum_digest = hashlib.sha256(checksum_bytes).hexdigest() expected_lists = { CHECKSUM_LIST_SHA256, + PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256, PHYSICALLY_QUALIFIED_9_CHECKSUM_LIST_SHA256, SUPERSEDED_30_CHECKSUM_LIST_SHA256, SUPERSEDED_29_CHECKSUM_LIST_SHA256, @@ -1246,11 +1330,12 @@ def parse_chain(bundle_root: Path) -> tuple[list[ChainStep], bytes]: if steps[0].from_version != EXPECTED_START_VERSION: raise ota.OtaError("chain has an unexpected starting version") if len(steps) == EXPECTED_STEP_COUNT: - for number, expected_sha256 in CURRENT_10_CANDIDATE_ANCHORS: - if steps[number - 1].target_sha256 != expected_sha256: - raise ota.OtaError( - f"ten-step candidate step {number} does not match its audited image pin" - ) + candidate_kind = ten_step_candidate_kind(steps) + if candidate_kind is None: + raise ota.OtaError( + "ten-step candidate does not match the exact b40d2e6c or " + "legacy fd98bc90 audited image pins" + ) expected_final_version = EXPECTED_FINAL_VERSION elif len(steps) == PHYSICALLY_QUALIFIED_9_STEP_COUNT: for number, expected_sha256 in COMPACT_RELEASE_ANCHORS: @@ -2241,16 +2326,20 @@ def run_step( def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( - "Verify the exact local ten-step RAK3401 candidate from c1caa5ad " - "to fd98bc90. Its exact package transitions passed directly on " - "hardware, but this later host-runner revision has not had a clean " - "end-to-end physical rerun and normal live use remains disabled." + "Verify the exact published ten-step RAK3401 prerelease from " + "c1caa5ad to b40d2e6c. Its first nine package bytes passed directly " + "on hardware; the new final transition and this hardened host runner " + "have offline/simulator qualification only, so live use remains " + "disabled without the controlled-lab override." ) ) parser.add_argument( "--bundle", type=Path, - help="pinned local ZIP or extracted bundle root (required until release)", + help=( + "pinned prerelease ZIP or extracted bundle root " + "(required; automatic download is disabled)" + ), ) parser.add_argument( "--work-dir", @@ -2510,15 +2599,7 @@ def main(argv: list[str] | None = None) -> int: if args.verify_only: print(f"Verified pinned bundle: {supplied_bundle_root}") if len(steps) == EXPECTED_STEP_COUNT: - print( - "Ten-step candidate verified offline: exact archive/checksum " - "pins, all target anchors, zero-filled and erased-workspace " - "reconstruction, independent motatool verification, and " - "bootloader simulation passed. These exact package transitions " - "also passed directly on the RAK3401 and the endpoint passed " - "independent SWD readback. This later host-runner revision has " - "not had a new clean physical rerun; live use remains gated." - ) + print(ten_step_verification_message(steps)) elif len(steps) == PHYSICALLY_QUALIFIED_9_STEP_COUNT: print( "Compact release verified: all 9 transitions passed its exact " diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 64b780f8..98287cd9 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -3983,7 +3983,7 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase): steps[number - 1].target_sha256 = image_sha256 with self.assertRaisesRegex( rak_chain.KnownUnsafeReleaseError, - "package transitions completed directly on the physical RAK3401", + "new b40d2e6c step 10.*bootloader simulators only", ): rak_chain.require_live_release_safe( argparse.Namespace(accept_test_candidate=False), steps @@ -3992,6 +3992,52 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase): argparse.Namespace(accept_test_candidate=True), steps ) + def test_exact_fd98_ten_step_candidate_remains_gated_legacy(self) -> None: + steps = [mock.Mock(target_sha256="") for _ in range(10)] + for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS: + steps[number - 1].target_sha256 = image_sha256 + with self.assertRaisesRegex( + rak_chain.KnownUnsafeReleaseError, + "legacy fd98bc90.*all ten pinned package transitions completed", + ): + rak_chain.require_live_release_safe( + argparse.Namespace(accept_test_candidate=False), steps + ) + rak_chain.require_live_release_safe( + argparse.Namespace(accept_test_candidate=True), steps + ) + + def test_current_and_legacy_ten_step_reports_do_not_mix_evidence(self) -> None: + current = [mock.Mock(target_sha256="") for _ in range(10)] + legacy = [mock.Mock(target_sha256="") for _ in range(10)] + for number, image_sha256 in rak_chain.CURRENT_10_CANDIDATE_ANCHORS: + current[number - 1].target_sha256 = image_sha256 + for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS: + legacy[number - 1].target_sha256 = image_sha256 + + current_message = rak_chain.ten_step_verification_message(current) + legacy_message = rak_chain.ten_step_verification_message(legacy) + self.assertIn("new step 10", current_message) + self.assertIn("not had a clean physical run", current_message) + self.assertNotIn("endpoint passed independent SWD readback", current_message) + self.assertIn("exact ten package transitions", legacy_message) + self.assertIn("endpoint passed independent SWD readback", legacy_message) + + def test_ten_step_candidates_share_only_the_pinned_physical_prefix(self) -> None: + self.assertEqual( + rak_chain.CURRENT_10_CANDIDATE_ANCHORS[:9], + rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS[:9], + ) + self.assertNotEqual( + rak_chain.CURRENT_10_CANDIDATE_ANCHORS[-1], + rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS[-1], + ) + steps = [mock.Mock(target_sha256="") for _ in range(10)] + for number, image_sha256 in rak_chain.CURRENT_10_CANDIDATE_ANCHORS: + steps[number - 1].target_sha256 = image_sha256 + steps[8].target_sha256 = "00" * 32 + self.assertIsNone(rak_chain.ten_step_candidate_kind(steps)) + def test_changed_current_ten_step_anchor_is_not_recognized(self) -> None: steps = [mock.Mock(target_sha256="") for _ in range(10)] for number, image_sha256 in rak_chain.CURRENT_10_CANDIDATE_ANCHORS: @@ -4005,14 +4051,46 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase): argparse.Namespace(accept_test_candidate=True), steps ) - def test_unreleased_candidate_has_no_implicit_download(self) -> None: + def test_published_candidate_has_no_implicit_download(self) -> None: with tempfile.TemporaryDirectory() as directory: destination = Path(directory) / rak_chain.ASSET_NAME with self.assertRaisesRegex( - ota.OtaError, "not released.*--bundle" + ota.OtaError, "automatic release download is disabled.*--bundle" ): rak_chain.download_release_asset(destination) + def test_fd98_archive_and_inner_checksum_remain_pinned(self) -> None: + self.assertEqual( + rak_chain.PINNED_ARCHIVE_CHECKSUMS[ + rak_chain.PHYSICALLY_PASSED_FD98_ASSET_SHA256 + ], + rak_chain.PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256, + ) + + def test_locate_bundle_accepts_exact_fd98_legacy_archive(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / rak_chain.PHYSICALLY_PASSED_FD98_ASSET_NAME + archive.write_bytes(b"fixture is identified by the mocked digest") + extracted = root / rak_chain.PHYSICALLY_PASSED_FD98_ROOT_NAME + args = argparse.Namespace(bundle=archive) + with ( + mock.patch.object( + rak_chain, + "sha256_file_limited", + return_value=rak_chain.PHYSICALLY_PASSED_FD98_ASSET_SHA256, + ), + mock.patch.object( + rak_chain, "extract_bundle", return_value=extracted + ) as extract_bundle, + ): + self.assertEqual(rak_chain.locate_bundle(args, root / "work"), extracted) + extract_bundle.assert_called_once_with( + archive.resolve(), + root / "work" / "bundle", + rak_chain.PHYSICALLY_PASSED_FD98_ASSET_SHA256, + ) + def test_compact_9_step_release_rejects_changed_anchor(self) -> None: steps = [mock.Mock(target_sha256="") for _ in range(9)] for number, image_sha256 in rak_chain.COMPACT_RELEASE_ANCHORS: