release: pin RAK3401 b40 mOTA chain

This commit is contained in:
mikecarper committed 2026-08-29 09:10:24 -07:00
1 parent b40d2e6cb2
commit f46262dadc
3 files changed
+348 -127

No files matched your search

+138 -76
View File
@@ -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
+129 -48
View File
@@ -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 "
+81 -3
View File
@@ -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: