Finalize tested RAK3401 v1.17.01 mOTA release

This commit is contained in:
mikecarper
2026-08-13 23:48:14 -07:00
parent 20bbfc33e4
commit b4096901eb
4 changed files with 181 additions and 321 deletions
+148 -250
View File
@@ -1,318 +1,216 @@
# RAK3401 1W repeater LoRa update chain
> Status: the second corrected v1.17.01 candidate is withdrawn. A physical
> RAK3401 run passed steps 1 through 15, then step 16 booted but could not
> validate its EndF. The runner blocks this exact bundle before device access.
> Status: physically passed. The exact 29-step chain completed from the
> c1caa5ad v1.16.7.0 test image through v1.17.01 on a RAK3401 1W. Every step
> passed its post-boot version, EndF, rescue-command, and watchdog checks.
This procedure records the exact 26-step update chain from the release
The release is
[`rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e`](https://github.com/mikecarper/MeshCore/releases/tag/rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e).
It is intentionally specific to the following destination firmware:
Use this asset:
```text
RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip
```
- ZIP SHA-256: `eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34`
- inner `SHA256SUMS.txt` SHA-256: `8097d75c5d11b9e32e3ebd4971068bf743eaa108054ab39eb0049016f89d185d`
- endpoint image SHA-256: `5c8d94bb23e87c23b0374ffb5a46e0c1205d6eebcb9d8bae3be6fda2613f9f79`
- endpoint EndF body hash: `DD45E7A23438B051`
The older v1.17.01 candidates and the v1.17.02 chain are retained only for
diagnosis. The runner recognizes their hashes and refuses live installation
before opening a device or reading a password.
## Exact destination
This chain is intentionally specific to one firmware lineage and hardware
target:
- target ID: `2FA509C1`
- hardware: `RAK_3401`
- role: `RAK_3401_repeater_lora_ota_no_external_sensors`
- starting version: `1.16.7.0`, build `c1caa5ad`
- starting EndF body hash: `71F4026CBE4B8B74`
- destination key used for this deployment:
`63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d`
- start: `v1.16.7.0-c1caa5ad`, EndF `71F4026CBE4B8B74`
- endpoint: `v1.17.01-halo-keymind-cascade-dev-cd824765`, EndF `DD45E7A23438B051`
- deployment target key: `63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d`
Do not use this chain on another node, and do not start it on another RAK3401.
The physical test has withdrawn this exact chain. Use it only for offline
diagnosis; the runner refuses live deployment even with
`--accept-test-candidate`.
Do not use the chain on another target ID, hardware family, starting image, or
firmware body hash. The runner checks all four.
## Withdrawn second v1.17.01 candidate
## What was tested
The replacement rebuilt steps 6 through 15 around the first observed SHA
failure. Step 15 used software SHA-256 and passed its physical EndF check.
Step 16 re-enabled CC310 SHA with checked return codes. It downloaded and
applied successfully, booted `v1.16.9.112-ea3843e0`, then returned `ERR no
EndF`. This proves that CC310 can report success yet produce a wrong digest
for the memory-mapped application image; return-code fallback is insufficient.
The direct physical run used:
```text
/home/mesh/git/MeshCore/out-rak3401-mota-v1.17.01-corrected/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-c96bdd6e.zip
```
- a Heltec V4.3 OLED 2 MB Full Companion on `/dev/ttyACM0`;
- the same Full Companion at `192.168.1.51`, with mOTA seeding on TCP `5001`
and its local OTA/TempRadio console on TCP `5002`;
- Full Companion key
`8ce031bc322f3cf77376eeacfe8800b30d8c5dfe927f3636d801f3455a2dd4cf`;
- one direct RF link to `RAK3401 1W Repeater`;
- temporary radio `909.950 MHz`, `500 kHz`, `SF5`, `CR5`;
- all 29 freshly rebuilt 1 KiB-block mOTA packages.
ZIP SHA-256:
`46c7480ed6bdc2aa01fb23a0f70e34c4012ffdd42b616d07bde66cf66d594630`.
The largest package is 88,280 bytes, leaving 1,832 bytes below the staging
limit. All 26 deltas reconstruct with both zero-filled and 0xFF-filled
workspaces. The endpoint has whole-image SHA-256
`e923f6209e0f071a5862ffbc7690e0b355c1e74829067e0bfdacd0de6c316065`
and EndF body hash `AC267B02E055F42E`.
The first verified transition completed at `2026-08-14T01:50:24Z`; the final
endpoint completed at `2026-08-14T06:41:36Z`. The ZIP contains the complete
29-row physical record as `PHYSICAL-TEST.jsonl`.
The withdrawn bundle includes the exact v1.16.7 test-start UF2/Nordic DFU ZIP, a
diagnostic corrected step-6 image, and the final recovery UF2/ZIP. Restore the
lab node locally to the included test-start image before rerunning the chain.
The bundle's `RUNBOOK.md` is retained for diagnosis only.
The largest package is 89,844 bytes and leaves 268 bytes of staging margin.
Every delta was also reconstructed offline with both zero-filled and
`0xFF`-filled workspaces and verified independently with `motatool`. Step 25 is
an intentional extra bridge that keeps the compiler-layout transition small.
## Requirements for the next chain
Step 1 installs both the guarded rescue command and the accelerated four-block
primary requester. The old requester used for the first transfer is serial, so
step 1 is expected to be slower. Steps 2 through 29 use the accelerated
requester. Some historical bridge text still says `low priority`; those
images were built with `OTA_TX_PRIORITY=0`, so their transfer traffic is
actually primary.
The next chain must use software SHA-256 for every bridge. CC310 may remain
enabled for AES, HMAC, and entropy, but not for either `Utils::sha256`
overload. The guarded `ota rescue install <base_hash16>` command must be in
the first installed bridge (step 1) and every target after it. Offline
validation must reconstruct each target and prove that command is present;
the live runner also probes `ota help` after every successful transition and
refuses to expose a bridge without the rescue path to another update.
## Withdrawn v1.17.02-chain smoke-test result
The lab topology used a Heltec V4.3 OLED ESP32 Full Companion on
`/dev/ttyACM0` and a direct LoRa path to `RAK3401 1W Repeater`. The remote
admin password is the lowercase default `password`, not `Password`.
Steps 1 through 5 completed and passed an exact post-boot version and EndF
body-hash check. Step 6 then did all of the following successfully:
- downloaded all 79 blocks of mOTA ID `7C8BC884`;
- verified the watchdog was off immediately before install;
- returned `verified (unsigned); applying`;
- booted `v1.16.08.7-halo-keymind-cascade-b-0c4ed3ac`;
- retained a soft-reset boot record consistent with OTAFIX apply success.
The resulting bridge returns `ERR no EndF (firmware lacks the trailer?)` from
`ota self`. The release package is not corrupt: independently applying its
delta reconstructs the expected 524,204-byte image with whole-image SHA-256
`61ced8b63953c614748c2fa1b04c2c01e8eb6626a604f6ef95fd2594d6d8ce71`
and valid EndF body hash `A0130F9BD6760D47`.
The regression is in the intermediate application. This is the first bridge
in the chain built with the old CC310 hardware-crypto SHA path. That path
passes the memory-mapped application body to CC310 and ignores a failed return
code instead of falling back to software SHA. The preceding v1.16.08 image
uses software SHA, while later code contains the required checked fallback.
The normal nRF52 install gate correctly refuses step 7 when it cannot validate
the running body. Forcing the bootloader trigger would bypass an application
safety gate and is not an acceptable recovery.
The lab RAK remains alive on v1.16.08.7. Its normal radio is restored to
`910.525,62.5,7,5`, and `system.watchdog` is restored and verified `on`.
## Recover the lab RAK
The latest physical run stopped on `v1.16.9.112-ea3843e0`: normal radio is
restored to `910.525,62.5,7,5` and `system.watchdog` is verified `on`, but the
application reports `ERR no EndF`. That bridge predates the guarded rescue
command, so recovery requires a local data path to the RAK3401 itself. The
Heltec controller cable cannot recover the remote RAK.
1. Connect the RAK3401 itself to this host by USB.
2. Enter its exact-board UF2 bootloader, using a local `uf2reset` command or
the board's double-reset gesture.
3. For another chain test, flash the exact c1caa5ad test-start UF2 from the
withdrawn bundle's `recovery/test-start/` directory. Do not use its final
recovery image as a production recovery target; it predates the new
software-only SHA rule.
4. After it boots, require the c1caa5ad start identity, target `2FA509C1`,
hardware `RAK_3401`, a valid `ota self` reply, normal radio settings, and
watchdog `on`.
Do not force an apply or issue a remote recovery reboot. Once the
RAK USB cable is attached, the port and UF2 mount can be detected and the
recovery completed without guessing device paths.
## Intended runner safety behavior
Stable OTAFIX 2.4 cannot feed a system watchdog inherited by the bootloader.
The dormant live path implements the release's watchdog sequence:
1. Persist `system.watchdog off` without issuing a normal reboot.
2. Wait 90 seconds for the already-running watchdog to reset the node.
3. Require the node to report `> off`.
4. Prove another 90 seconds of stable uptime.
5. Enforce the requested `ota config hops` reach before every chain step.
6. Recheck `> off` immediately before every `ota install` command.
7. Re-enable the watchdog only after the exact step 26 image boots and passes
both version and EndF body-hash verification.
These controls remain requirements for the next candidate. They cannot repair
a running bridge that lacks both valid app-side EndF validation and the rescue
command. A replacement must pass a complete physical-board run before its
lab-only gate is removed.
An interrupted download is resumable. Avoid interrupting power or radio
coverage while `ota install` is applying a package. Physical USB recovery is
the fallback if an apply is interrupted at the wrong instant.
## Host and radio requirements
## Host requirements
Install:
- Python 3.9 or newer
- `meshcli` 1.6.0 or newer
- a current `motatool`
- a Full Companion or OTA-enabled repeater to seed the files
- Python 3.9 or newer;
- `meshcli` 1.6.0 or newer;
- the current `motatool`;
- a Full Companion or OTA-enabled repeater that can seed mOTA files.
The ESP32 Full Companion serves mOTA data through its dedicated TCP port
`5001`; its USB port remains the Binary API controller. Its local OTA and
TempRadio console is TCP port `5002`. For a Heltec Full Companion at
`192.168.1.51`, use these three connections together:
The runner maintains one persistent controller connection. It logs into the
destination once, keeps that authenticated radio-node session across
application reboots, and does not treat a silent packet as lost
authentication. The source seeder reconnects for each isolated package but
does not need remote-admin login.
```text
controller: /dev/ttyACM0
mOTA source: 192.168.1.51:5001
source CLI: 192.168.1.51:5002
```
## Verify the release offline
Before a deployment, verify that USB and TCP port `5000` report the same
Companion public key:
```bash
meshcli -s /dev/ttyACM0 -j -c off infos
meshcli -t 192.168.1.51 -p 5000 -j -c off infos
```
The tested Heltec key is
`8ce031bc322f3cf77376eeacfe8800b30d8c5dfe927f3636d801f3455a2dd4cf`.
Change the IP address for a different deployment.
The source, controller, destination, and every intermediate relay must support
the temporary tuple `909.950,250,5,5`. Confirm that this frequency and
bandwidth are legal at the deployment location. All nodes return to their
ordinary radio after each step.
## Verify without touching a radio
The chain runner pins the release ZIP SHA-256 to
`46c7480ed6bdc2aa01fb23a0f70e34c4012ffdd42b616d07bde66cf66d594630`.
It also pins and verifies the inner checksum list, checks complete checksum
coverage, parses every manifest, verifies the chain continuity, and runs
`motatool verify` on all 26 containers.
It downloads the release automatically when `--bundle` is omitted:
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.01-cd824765.zip \
--work-dir ./rak3401-mota-chain-work \
--motatool /path/to/motatool \
--verify-only
```
An already-downloaded asset can be supplied with `--bundle`. Only the pinned
ZIP or its extracted root is accepted.
When `--bundle` is omitted, the runner downloads the pinned release asset. It
checks the outer ZIP hash, complete inner checksum coverage, all 29 manifests,
chain continuity, audited image anchors, final recovery image, and every mOTA
container with `motatool`.
## Withdrawn-release verification
## Direct recoverable bench run: BW500/SF5
No password or device connection is needed for offline verification:
Restore the test RAK locally with the ZIP's
`recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2`. Before starting,
require start version `1.16.7.0`, body hash `71F4026CBE4B8B74`, target
`2FA509C1`, hardware `RAK_3401`, and OTAFIX mOTA ABI 2 with codec 2.
```bash
python3 tools/lora_ota/rak3401_mota_chain.py \
--work-dir ./rak3401-mota-chain-work \
--motatool /path/to/motatool \
--verify-only
```
Use `--verify-only` for this withdrawn bundle. Every live invocation exits
before connecting to a device, including one with `--accept-test-candidate`.
The older failed v1.17.01 and v1.17.02 chains are also recognized and blocked.
## Future direct lab template
This template becomes usable only after a new ZIP and its hashes are pinned in
the runner. Use it only on a locally recoverable test RAK3401. Keep the work
directory for resume, and retain the explicit `--accept-test-candidate` gate.
For the tested direct topology:
```bash
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.01-cd824765.zip \
--work-dir ./rak3401-mota-chain-work \
--controller-serial /dev/ttyACM0 \
--source-tcp 192.168.1.51:5001 \
--source-cli-tcp 192.168.1.51:5002 \
--source-shares-controller \
--ota-hops 3 \
--accept-test-candidate \
--target-key 63d8df63 \
--temp-radio 909.950,500,5,5,120 \
--ota-hops 1 \
--motatool /path/to/motatool \
--yes
```
The intended runner logs each isolated step attempt below `steps/`, records every
verified transition in `progress.jsonl`, and prints the source log path. Rerun
the exact command after a host restart or recoverable RF failure. Status polling
starts at 60 seconds, expands adaptively when replies are slow or the link is
contended, and contracts after quick replies. The transfer itself is primary
traffic, so reserve the TempRadio window for OTA on a busy or relayed mesh.
BW500 is for the controlled direct bench setup. Confirm that the selected
frequency and bandwidth are legal at the test location.
## Relayed lab template
Keep the work directory. Each package gets an isolated attempt directory, and
each exact transition is appended to `progress.jsonl`. After a host or RF
interruption, rerun the identical command with the same work directory. The
runner resumes only when the live body hash matches the recorded chain; never
manually skip to a package.
Every intermediate relay must enter the same TempRadio tuple. Add one
`--relay` argument for each intermediate repeater, ordered from the destination
side back toward the controller. In other words, list the farthest relay first
and the nearest relay last. A bare relay name uses
`MESHCORE_ADMIN_PASSWORD`; append `=PASSWORD` only when that relay has a
different password.
## Live deployment with two intermediate relays: BW250/SF5
The word "hop" is sometimes counted two ways:
For the intended private TempRadio deployment, use BW250/SF5 and two
intermediate relays. List relays from the destination side toward the
controller: farthest first, nearest last. With two relays there are three RF
links, so use `--ota-hops 3`.
- `controller -> relay -> destination` is two RF links but has one
intermediate relay, so pass one `--relay`.
- `controller -> near relay -> far relay -> destination` has two intermediate
relays, so pass two `--relay` values, `far` first and `near` second.
For one intermediate relay:
Run a non-mutating preflight first:
```bash
export MESHCORE_ADMIN_PASSWORD='your-destination-and-relay-password'
python3 tools/lora_ota/rak3401_mota_chain.py \
--bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip \
--work-dir ./rak3401-mota-chain-work \
--controller-serial /dev/ttyACM0 \
--source-tcp 192.168.1.51:5001 \
--source-cli-tcp 192.168.1.51:5002 \
--source-tcp SOURCE_IP:5001 \
--source-cli-tcp SOURCE_IP:5002 \
--source-shares-controller \
--ota-hops 3 \
--relay 'Intermediate Repeater' \
--accept-test-candidate \
--motatool /path/to/motatool \
--yes
```
For two intermediate relays:
```bash
python3 tools/lora_ota/rak3401_mota_chain.py \
--work-dir ./rak3401-mota-chain-work \
--controller-serial /dev/ttyACM0 \
--source-tcp 192.168.1.51:5001 \
--source-cli-tcp 192.168.1.51:5002 \
--source-shares-controller \
--ota-hops 3 \
--target-key 63d8df63 \
--relay 'Far Repeater' \
--relay 'Near Repeater' \
--accept-test-candidate \
--temp-radio 909.950,250,5,5,120 \
--ota-hops 3 \
--motatool /path/to/motatool \
--yes
--preflight-only
```
Do not begin a production deployment until a replacement bundle passes a
direct physical smoke test from start to endpoint. Run `--preflight-only` with
the same relay arguments first. The runner sends
the destination to TempRadio, then the relays farthest-to-nearest while their
remaining normal routes still exist. It checks that the 120-minute TempRadio
window covers the selected relay count and all configured timeouts. The
starting build may report `hops=0` after the watchdog-driven reset, so the
runner re-enforces `--ota-hops 3` before every step. That setting accepts OTA
packets received through up to three intermediate repeaters.
A bare relay name uses `MESHCORE_ADMIN_PASSWORD`. Append `=PASSWORD` only for
a relay with a different password. After preflight succeeds, rerun the same
command with `--yes` in place of `--preflight-only`.
For a lossy multi-hop path, increase `--reply-timeout`,
`--discovery-timeout`, or `--transfer-timeout-minutes` as needed. If their sum
no longer fits the TempRadio window, also increase the minutes in
`--temp-radio`; the runner rejects an unsafe combination before connecting.
Arm the relay chain farthest-to-nearest while all nodes are still reachable on
the ordinary mesh. The runner then moves the source and controller to the same
temporary tuple. Keep the private TempRadio network reserved for OTA during
the run; public-mesh flooding does not improve this transfer. A 120-minute
window is the minimum used here—raise it if the selected timeouts and relay
count require more time.
## Completion checks
## Guarded no-EndF rescue
The rescue command is present from the first installed bridge onward. If a
bridge remains reachable but `ota self` reports no EndF, serve and fetch only
its exact successor package. Read that row's 16-hex `base_body_hash` from
`CHAIN.csv`, then issue:
```text
ota rescue install <base_body_hash>
```
This is not a force command. It refuses a valid normal EndF, a base mismatch,
wrong target or hardware, or invalid payload. OTAFIX hashes the physical
running application before its first write. A missing/corrupt EndF can
therefore continue through its exact successor instead of becoming stuck,
provided the installed bridge already contains the rescue command. The
c1caa5ad starting image predates rescue, so a failure before step 1 completes
still requires local USB recovery.
## Safety and completion
The runner performs the following guarded sequence:
1. Persist `system.watchdog off`, allow the already-running watchdog to reset
once, and prove stable uptime with the watchdog off.
2. Recheck destination identity, OTA reach, source identity, and exact package
base before every step.
3. Require `system.watchdog` to report `off` immediately before every install.
4. After every reboot, require the exact version and EndF body hash, then prove
the rescue command is present before exposing that bridge to another step.
5. Re-enable the watchdog only after the exact step-29 endpoint boots.
Success requires all of the following:
- version `v1.17.1.0`
- target ID `2FA509C1`
- hardware `RAK_3401`
- final EndF body hash `AC267B02E055F42E`
- `get system.watchdog` returning `> on`
- version `v1.17.01-halo-keymind-cascade-dev-cd824765`;
- EndF body hash `DD45E7A23438B051`;
- target `2FA509C1`, hardware `RAK_3401`;
- normal radio `910.525 MHz`, `62.5 kHz`, `SF7`, `CR5`;
- `system.watchdog` verified `on`.
Do not treat a successful transfer alone as completion. Each temporary bridge
is a complete bootable image, but the RAK should not be left on a bridge
version between planned maintenance sessions.
See [LoRa OTA automation](lora_ota_automation.md) for the generic single-step
workflow and source topology details.
A completed download or an accepted install reply is not success without the
post-reboot identity checks. The bundle includes exact test-start and final
local recovery UF2/ZIP files if USB recovery is ever needed.
+5 -31
View File
@@ -1126,10 +1126,10 @@ class Controller:
else:
self.connection = ["-a", args.controller_ble]
# Remote-admin authentication belongs to the radio node, not to each
# host command. Reuse it across application reboots; only repeated
# silence suggests that the session needs to be refreshed.
# host command. Reuse it across application reboots. A silent command
# is packet loss or an airtime-starved reply, not evidence that the
# authenticated session was lost.
self._authenticated_targets: set[str] = set()
self._authenticated_target_failures: dict[str, int] = {}
self._meshcli_session = (
PersistentMeshcliSession([
self.meshcli, "-j", "-c", "off", *self.connection,
@@ -1148,9 +1148,6 @@ class Controller:
authenticated = getattr(self, "_authenticated_targets", None)
if authenticated is not None:
authenticated.discard(target)
failures = getattr(self, "_authenticated_target_failures", None)
if failures is not None:
failures.pop(target, None)
def _remote_auth_is_cached(self, target: str) -> bool:
return target in getattr(self, "_authenticated_targets", set())
@@ -1158,25 +1155,7 @@ class Controller:
def _cache_remote_auth(self, target: str) -> None:
if not hasattr(self, "_authenticated_targets"):
self._authenticated_targets = set()
if not hasattr(self, "_authenticated_target_failures"):
self._authenticated_target_failures = {}
self._authenticated_targets.add(target)
self._authenticated_target_failures.pop(target, None)
def _note_remote_silence(self, target: str) -> bool:
if not hasattr(self, "_authenticated_target_failures"):
self._authenticated_target_failures = {}
failures = self._authenticated_target_failures.get(target, 0) + 1
self._authenticated_target_failures[target] = failures
if failures < 2:
return False
self.forget_remote_auth(target)
return True
def _note_remote_reply(self, target: str) -> None:
failures = getattr(self, "_authenticated_target_failures", None)
if failures is not None:
failures.pop(target, None)
def _execute(
self, commands: list[str], label: str
@@ -1369,17 +1348,12 @@ class Controller:
and reply_matches_command(command_text, item["text"])
]
if not messages:
refresh = self._note_remote_silence(target)
refresh_note = (
"; cached admin session will be refreshed on the next retry"
if refresh else ""
)
raise TransmissionError(
f"no matching CLI reply from {target} for {command_text!r}; "
f"check its path and reply timeout{refresh_note}"
"check its path and reply timeout; the cached admin session "
"was retained"
)
reply = messages[-1]["text"]
self._note_remote_reply(target)
print(f"[{target}] {reply}")
return reply
+17 -23
View File
@@ -35,8 +35,8 @@ ASSET_URL = (
f"https://github.com/mikecarper/MeshCore/releases/download/{RELEASE_TAG}/"
f"{ASSET_NAME}"
)
ASSET_SHA256 = "f843221aa9b33c452351b49e80c3cc9c8cc2ae9bfe80b68e0e83506583f5d12b"
CHECKSUM_LIST_SHA256 = "f30eb5169d21f09800eea89d30452c89118b62386e8cb0fb47c7682f06f37c2c"
ASSET_SHA256 = "eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34"
CHECKSUM_LIST_SHA256 = "8097d75c5d11b9e32e3ebd4971068bf743eaa108054ab39eb0049016f89d185d"
BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765"
# This 27-step candidate passed steps 1 and 2, then its test was deliberately
@@ -105,8 +105,8 @@ WATCHDOG_STABILITY_WAIT_SECONDS = 90
# steps 1 through 14, but the first v1.17.01 candidate failed at step 15 because
# that retained 386ae4a5 bridge still used unchecked CC310 SHA. Keep both
# bundles useful for offline diagnosis, but fail closed before a live
# connection. The second corrected replacement remains behind an explicit
# lab-only gate until its end-to-end physical-board test is complete.
# connection. The exact 29-step replacement below passed its complete direct
# physical-board run on 14-Aug-2026.
KNOWN_UNSAFE_STEP = 6
KNOWN_UNSAFE_VERSION = "1.16.8.7"
KNOWN_UNSAFE_IMAGE_SHA256 = (
@@ -141,10 +141,10 @@ SUPERSEDED_27_FINAL_IMAGE_SHA256 = (
"e1376869da043c05792b3458e505e67e818f58d94d3fc050d22ab68578a2f2e8"
)
# Exact anchors for the fresh 29-step candidate. The pinned checksum list
# covers every byte; these anchors also make the live gate fail closed if its
# structural checks are ever reused independently.
CURRENT_CANDIDATE_ANCHORS = (
# Exact anchors for the physically passed 29-step release. The pinned checksum
# list covers every byte; these anchors also make the live gate fail closed if
# its structural checks are ever reused independently.
PHYSICALLY_PASSED_ANCHORS = (
(1, "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530"),
(6, "4909b5cd50ca86e00b1583bf9ca50e0fc69808a4bebbec8b5e368304145e5d43"),
(11, "28ba025251b9cf11376e09c2dc91619d9cc216de9f4998a5eeec7438b689d1c4"),
@@ -183,7 +183,7 @@ SUPERSEDED_27_MESSAGE = (
"live installation of the superseded 27-step candidate is disabled: its "
"physical test was stopped after step 2 so the adaptive primary requester "
"could be moved into every historical bridge. Use --verify-only for that "
"bundle and use the fresh 29-step candidate for the lab rerun."
"bundle and use the physically passed 29-step release."
)
@@ -196,18 +196,12 @@ def require_live_release_safe(
steps: list[ChainStep],
) -> None:
if len(steps) == EXPECTED_STEP_COUNT:
for number, expected_sha256 in CURRENT_CANDIDATE_ANCHORS:
for number, expected_sha256 in PHYSICALLY_PASSED_ANCHORS:
if steps[number - 1].target_sha256 != expected_sha256:
raise KnownUnsafeReleaseError(
"live installation is disabled: the 29-step candidate has "
"live installation is disabled: the 29-step release has "
f"an unrecognized step-{number} image"
)
if not args.accept_test_candidate:
raise KnownUnsafeReleaseError(
"live installation of the fresh 29-step chain requires "
"--accept-test-candidate until its complete physical-board "
"test passes"
)
return
step6_sha256 = steps[KNOWN_UNSAFE_STEP - 1].target_sha256
@@ -527,10 +521,10 @@ 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_CANDIDATE_ANCHORS:
for number, expected_sha256 in PHYSICALLY_PASSED_ANCHORS:
if steps[number - 1].target_sha256 != expected_sha256:
raise ota.OtaError(
f"fresh candidate step {number} does not match its audited "
f"physically passed step {number} does not match its audited "
"image pin"
)
expected_final_version = EXPECTED_FINAL_VERSION
@@ -995,8 +989,8 @@ def build_parser() -> argparse.ArgumentParser:
"--accept-test-candidate",
action="store_true",
help=(
"allow the pinned fresh 29-step chain for its direct physical lab test; "
"it never overrides a bundle known to have failed physical testing"
"deprecated compatibility flag; the exact physically passed 29-step "
"release no longer requires it, and it never overrides a failed bundle"
),
)
@@ -1186,8 +1180,8 @@ def main(argv: list[str] | None = None) -> int:
print(f"WARNING: {SUPERSEDED_27_MESSAGE}", file=sys.stderr)
else:
print(
"Unfailed candidate verified offline; complete physical-board testing "
"is still required before production use."
"Physically passed release verified offline: all 29 direct "
"BW500/SF5 transitions passed on a RAK3401."
)
return 0
+11 -17
View File
@@ -684,7 +684,6 @@ class ReliabilityTests(unittest.TestCase):
controller = object.__new__(ota.Controller)
controller.reply_timeout = 20
controller._authenticated_targets = set()
controller._authenticated_target_failures = {}
key = "A1" * 32
commands_seen: list[list[str]] = []
@@ -709,11 +708,10 @@ class ReliabilityTests(unittest.TestCase):
controller._remote_command_once("remote", "ota status", "secret")
self.assertIn("login", commands_seen[2])
def test_two_silent_commands_refresh_cached_admin_session(self) -> None:
def test_silent_commands_retain_cached_admin_session(self) -> None:
controller = object.__new__(ota.Controller)
controller.reply_timeout = 20
controller._authenticated_targets = {"remote"}
controller._authenticated_target_failures = {}
key = "A1" * 32
commands_seen: list[list[str]] = []
@@ -722,13 +720,13 @@ class ReliabilityTests(unittest.TestCase):
objects = [{"adv_name": "remote", "public_key": key}]
if "login" in commands:
objects.append({"login_success": True})
replies = []
if len(commands_seen) == 3:
replies = [{
"txt_type": 1,
"text": "OTA | no download | target:1234ABCD",
"pubkey_prefix": key[:12],
}]
else:
replies = []
return objects, replies
controller._run_marked = run_marked
@@ -739,7 +737,7 @@ class ReliabilityTests(unittest.TestCase):
controller._remote_command_once("remote", "ota status", "secret")
self.assertNotIn("login", commands_seen[0])
self.assertNotIn("login", commands_seen[1])
self.assertIn("login", commands_seen[2])
self.assertNotIn("login", commands_seen[2])
def test_generic_retry_rejects_state_changing_ota_commands(self) -> None:
controller = object.__new__(ota.Controller)
@@ -1316,24 +1314,20 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
argparse.Namespace(accept_test_candidate=True), steps
)
def test_fresh_29_step_candidate_requires_explicit_lab_gate(self) -> None:
def test_physically_passed_29_step_release_needs_no_lab_gate(self) -> None:
steps = [mock.Mock(target_sha256="") for _ in range(29)]
for number, image_sha256 in rak_chain.CURRENT_CANDIDATE_ANCHORS:
for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_ANCHORS:
steps[number - 1].target_sha256 = image_sha256
with self.assertRaisesRegex(
rak_chain.KnownUnsafeReleaseError,
"requires --accept-test-candidate",
):
rak_chain.require_live_release_safe(
argparse.Namespace(accept_test_candidate=False), steps
)
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_fresh_29_step_candidate_rejects_changed_anchor(self) -> None:
def test_physically_passed_29_step_release_rejects_changed_anchor(self) -> None:
steps = [mock.Mock(target_sha256="") for _ in range(29)]
for number, image_sha256 in rak_chain.CURRENT_CANDIDATE_ANCHORS:
for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_ANCHORS:
steps[number - 1].target_sha256 = image_sha256
steps[10].target_sha256 = "00" * 32
with self.assertRaisesRegex(