# 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  <inf> 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).
