# Secured-line swap-with-revert — hardware acceptance proof Bench-proven on a single nRF5340 custom board, 2026-08-23. All three acceptance rows PASS on the SECURED line (b0 + s0/s1, AES-128 encrypted images, MCUboot swap-using-move) over the real field path — a warm mcumgr/BLE reset, not a pin reset. Every row below required the four SDK patches in `ncs-3.3.1-mcuboot-secured-swap.patch`. Without them the encrypted secured line can never complete an OTA: the staged image is silently erased on every boot. ## 1. Acceptance table | Row | Image | Boots | Advertises | Swap executes | Self-confirms | Persists / reverts | RESETREAS | |---|---|---|---|---|---|---|---| | 1 | baseline v0.1.2 (SWD-flashed) | yes | yes | n/a | n/a (wired flash, already confirmed) | `v0.1.2 [active,confirmed,bootable]` | `0x08` (SREQ only) | | 2 | good v0.1.3 (BLE OTA, warm reset) | yes | yes | **yes**, `Swap type: test` | **yes** | **PASS** — 2nd reset still `v0.1.3 [active,confirmed,bootable]`, no revert | `0x08` | | 3 | broken v0.1.3 (BLE OTA, warm reset) | yes | **no** (by design) | **yes**, `Swap type: test` | **no** (never reaches the confirm point) | **REVERTS** — `Swap type: revert` → `v0.1.2 [active,confirmed,bootable]`, advertising | `0x00` while broken image ran (no self-reset), `0x08` after manual reset | No `DOG0` (watchdog) and no `LOCKUP` bit in RESETREAS in any row. ### Row 2 — good image swaps in, self-confirms, persists MCUboot log (`run14b-artifacts/rtt-row2-swap-pass.log`): ``` 22:22:12.858 I: Starting bootloader 22:22:12.858 I: DIAG ncs img=0 magic=0x96f3b83d flags=0x4 reset_addr=0x3ee3fbc2 22:22:12.858 I: DIAG ncs ASSIGNED img=0 22:22:12.858 I: Image index: 0, Swap type: test 22:22:22.799 I: Starting swap using move algorithm. 22:23:23.393 I: Image version: v0.1.3 22:23:23.393 I: Jumping to the first image slot ``` `flags=0x4` is `IMAGE_F_ENCRYPTED_AES128` and `reset_addr=0x3ee3fbc2` is ciphertext, outside the valid primary range `[0x28000,0xfc000]`. Before the keystone patch this exact input produced `RETURN-NONE` and the slot was erased; with the patch it is `ASSIGNED` and the swap proceeds. State after, read over BLE (mcumgr `image list`): ``` image 0 slot 0 v0.1.3 [active,confirmed,bootable] image 0 slot 1 v0.1.2 [bootable] ``` `confirmed` is the application self-confirming via its `confirmRunningImage()` path once advertising and the SMP transport were up. The old image moved down to slot 1, which is swap-using-move behaviour. A further reset left the state unchanged (persistence proven). ### Row 3 — broken image swaps in, never confirms, reverts Swap-in and broken-image banner (`run14b-artifacts/rtt-row3-revert.log`): ``` 22:26:36.265 I: Starting bootloader 22:26:36.265 I: DIAG ncs img=0 magic=0x96f3b83d flags=0x4 reset_addr=0x9769f57c 22:26:36.265 I: Image index: 0, Swap type: test 22:26:46.280 I: Starting swap using move algorithm. 22:27:46.775 I: Image version: v0.1.3 22:27:46.775 I: Jumping to the first image slot 22:27:51.093 app: benchForceAdvertise(): BENCH BROKEN IMAGE v0.1.3 -- advertising deliberately disabled ``` The broken image then ran for 62 s: header stayed `v0.1.3`, no BLE advertising, and `RESETREAS` stayed `0x00000000` — it did **not** self-reset. That is a known application-side gap (see section 4). Recovery therefore needed an external reset. On that reset: ``` 22:29:37.736 I: Starting bootloader 22:29:37.736 I: DIAG ncs img=0 magic=0x96f3b83d flags=0x4 reset_addr=0xa78fbdc3 22:29:37.736 I: Image index: 0, Swap type: revert 22:29:47.749 I: Starting swap using move algorithm. 22:30:48.389 I: Image version: v0.1.2 22:30:48.389 I: Jumping to the first image slot ``` End state, over BLE: `image 0 slot 0 v0.1.2 [active,confirmed,bootable]`, the unit advertising and serviceable. ## 2. Timings (measured) | Quantity | Value | |---|---| | App image OTA upload (462,492 B over BLE) | 38.3 s | | Broken app image upload (462,667 B) | 38.6 s | | Net-core image upload (145,499 B) | 12.2–12.5 s | | Swap execution, good (`Starting swap using move` → `Jumping`) | 60.59 s | | Swap execution, broken | 60.50 s | | Revert swap execution | 60.64 s | | Boot → new image running, good (bootloader start → jump) | 70.5 s | | Boot → revert complete | 70.7 s | | Baseline boot → BLE advertising | ~8–12 s | | Secondary slot erase, 0xd3000 = 864,256 B (single unfed call) | 15.80 s (img 0), 17.88 s (img 2) | | Image hash + decrypt, 462,224 B | 0.83 s | | ECDSA signature verify | 0.315 s | | Image-2 swap-state read hang (unpatched) | 10.86 s | | Inherited application task watchdog window | 10.5 s (`CRV` 0x54000 @ 32768 Hz) | The swap window is ~60 s, consistent with the ~58 s encrypted-swap figure recorded in earlier encrypted-image testing on the same board. ## 3. Defect chain (four SDK defects, all in NCS v3.3.1 MCUboot) 1. **Encrypted reset-vector misread — the keystone.** `boot_validated_swap_type()` reads the secondary slot's reset vector as plaintext. For an AES-encrypted image it is ciphertext (`0x3ee3fbc2` / `0x9769f57c` / `0xa78fbdc3` observed) and never falls inside the primary range `[0x28000,0xfc000]`, so MCUboot concludes "not intended for any image" and returns `BOOT_SWAP_TYPE_NONE` before `sec_slot_mark_assigned()`. The plaintext net-core image (`flags=0x100`, `reset_addr=0x1023291`) passes, which is why only the encrypted app image ever failed. NCS v3.3.1 has no encryption guard anywhere in that block, although `ih_flags & ENCRYPTIONFLAGS` is available. Implication: `SB_CONFIG_SECURE_BOOT_APPCORE` + `SB_CONFIG_BOOT_ENCRYPTION` are effectively incompatible on nRF5340 in NCS v3.3.1 without this fix. 2. **Image-2 swap-state read blocks the boot.** Image 2 is the NSIB s0/s1 self-update slot. Its primary is `mcuboot_primary_1`, a `nordic_ram_flash_controller` (PCD/RAM-flash) area backed by the network core, which is held off during boot, so the read blocks — 10.86 s measured, versus <1 ms for images 0 and 1 through the same code. Its secondary is the *shared* `mcuboot_secondary`, so any staged app image gives image 2 "good magic" and triggers this on every staged boot. 3. **Whole-secondary erase in one unfed call.** `sec_slot_cleanup_if_unusable()` erases the entire external-QSPI secondary in a single `flash_area_erase` — 15.80 s and 17.88 s measured, each far beyond the 10.5 s watchdog window. It erases the shared area twice, once on behalf of unwired image 2, destroying a good staged app image. This is what made every failed attempt leave a torn slot. 4. **Missing watchdog feeds on the swap/validate path.** `CONFIG_BOOT_WATCHDOG_FEED` exists but upstream calls it from only three places, none on the swap or validate path, which runs for tens of seconds over external QSPI. The nRF watchdog survives `sys_reboot`, so MCUboot inherits the running 10.5 s application `task_wdt` across a warm reset — the real field OTA path. Defects 2, 3 and 4 combine to reset the device mid-operation and erase the staged image; defect 1 guarantees the image would have been rejected anyway. ## 4. Fix inventory ### Already in the application build (not SDK changes) - `sysbuild/mcuboot.overlay` — external-flash (`mx25r64`) properties mirrored from the application overlay so MCUboot drives the external flash identically. Hardening only; bench-proven **not** to be the no-swap cause. - `sysbuild/mcuboot.conf` — documentation block recording that `CONFIG_UPDATEABLE_IMAGE_NUMBER` cannot be lowered from the application repository (sysbuild's `set_config_int` overrides the fragment; the `SB_CONFIG` knob is Kconfig-pinned to `range [1,1]` under `SECURE_BOOT_APPCORE` and hard-fails configuration). The setting is left commented out deliberately. ### SDK patch, required (`ncs-3.3.1-mcuboot-secured-swap.patch`) All four hunks are necessary; none is sufficient alone. | Hunk | File | Fixes | |---|---|---| | 1 | `loader.c` `boot_validated_swap_type()` | defect 1 (keystone) | | 2 | `loader.c` `boot_prepare_image_for_update()` | defect 2 | | 3 | `loader.c` `sec_slot_cleanup_if_unusable()` | defect 3 | | 4 | `swap_move.c` ×3, `bootutil_img_hash.c` ×1 | defect 4 | Rejected during investigation: forcing MCUboot's image count to 2. It produced the intended count but broke the application (no advertising, `RESETREAS` showed `LOCKUP`). ### Additional observations for Nordic - Defects 1–4 above, with the measured numbers. - Shared-secondary coupling: marking image 0 pending also flips image 1's reported state in `image list`. - `boot_read_image_headers()` swallows a failed read (`i > 0 && !require_all` → `rc 0`) and keeps a stale header — observed as a valid cached header alongside a fresh read of `ff ff ff ff`. - `swap_read_status_bytes()` issues roughly 1000 single-byte `flash_area_read` calls over external QSPI (`BOOT_MAX_IMG_SECTORS=256`); should be batched. ### Application-side, not an SDK issue A booted-but-never-advertising image does not self-reset, because the task watchdog is fed unconditionally. Row 3 evidences this: `RESETREAS` stayed `0x00000000` for 62 s while the broken image ran. MCUboot's revert is correct and fires on the next reset, but on a fielded unit nothing generates that reset. Tracked separately on our side. ## 5. Evidence index | File | Contents | |---|---| | `ncs-3.3.1-mcuboot-secured-swap.patch` | the four-hunk SDK patch, applies cleanly to mcuboot @ `3de5b4df5f9f` | | `run14b-artifacts/rtt-row2-swap-pass.log` | row 2: `ASSIGNED` → `Swap type: test` → swap → `v0.1.3` | | `run14b-artifacts/rtt-row3-revert.log` | row 3: broken swap-in + banner, then `Swap type: revert` → `v0.1.2` | | `run14b-artifacts/rtt-keystone-encrypted-reset-vector.log` | pre-fix capture: `RETURN-NONE`, ciphertext `reset_addr` vs primary range | | `run14b-artifacts/rtt-secondary-erase-timing.log` | 15.80 s / 17.88 s erase measurement | | `run14b-artifacts/rtt-img2-hang-bisect.log` | 10.86 s image-2 hang bisect | | `run14b-artifacts/partitions.yml` | partition geometry | Build outputs (merged hex images, DFU bundles, `mcuboot-zephyr.config`, `app-zephyr.config`, `mcuboot-zephyr.dts`) were retained locally and are not included here; the logs above are the primary evidence and can be supplied on request. ## 6. Bench provenance - One nRF5340 custom board (the bench unit), BLE OTA over a Linux host. - Debug probe: CMSIS-DAP probe, `probe-rs` v0.32.0 at 500 kHz. - Toolchain: NCS `v3.3.1-1d7a0b0e49b8`, toolchain bundle `911f4c5c26`; MCUboot `v2.3.0-dev-3de5b4df5f9f`. - Line: secured — `SB_CONFIG_SECURE_BOOT_APPCORE`, `SB_CONFIG_BOOT_ENCRYPTION` (AES-128), `SB_CONFIG_MCUBOOT_MODE_SWAP_USING_MOVE`, development keys from a local key directory. - Geometry: `b0` 0x0–0x8000, `s0` 0x8000–0x18000, `s1` 0x18000–0x28000, `mcuboot_primary` 0x28000–0xfc000, `mcuboot_secondary` 0x0–0xd3000 in external QSPI (primary minus one sector, the swap-using-move requirement). - b0 provision written to OTP at `0xff8128` via an NVMC workaround (`probe-rs` 0.32 cannot program the OTP page; error 104). - Bench overlays on all images: auto-advertise and charger-interlock-bypass fragments, plus an RTT console overlay (`CONFIG_SERIAL=n`, so RTT is the only console). ### Builds used | Build | Version | MCUboot binary md5 (first 12) | Role | |---|---|---|---| | baseline build | 0.1.2+0 | `4723182e9a08` | baseline, carries all four SDK patches | | good build | 0.1.3+0 | `a601e851b3f7` | good image: boots, advertises, self-confirms | | broken build | 0.1.3+0 | `0efa90344afe` | broken image: boots, never advertises | Only the app image is staged over BLE for these rows; the good and broken images are app-image-only OTAs, which is the self-healing case under test. Version note: MCUboot compares only major.minor.patch — `CONFIG_BOOT_VERSION_CMP_USE_BUILD_NUMBER` is not set — so a `VERSION_TWEAK` bump is not an upgrade under `MCUBOOT_DOWNGRADE_PREVENTION`. The test images use a PATCH bump (0.1.2 → 0.1.3).