CORRECTED 2026-08-05 — please read this first. Several claims in this original report
have since been disproven by a reproduction on unmodified upstream firmware
(pico-hsm 1ad8444 + pico-keys-sdk 843a3dc, no patches, after a full 16 MB erase).
The corrected mechanism with full forensics is in this comment.
In short: the card does not complete INITIALIZE and then sit dead. Core0 panics during
INITIALIZE inside flash_range_erase(), while holding the multicore lockout with interrupts
disabled — which parks core1 and mutes the card. The origin is scan_region() (file.c:336)
accepting an out-of-region chain link and deriving file->data from it.
Affected sections are marked [CORRECTED] inline below. The original wording is kept so the
maintainer's review replies still make sense.
Context — how we use pico-hsm (and why it matters for this report)
We run an open-source, hardware-rooted key-custody project. Production tokens are Nitrokey
HSM 2s (genuine SmartCard-HSM). The Pico HSM is used strictly as a STAGING and
procedure-rehearsal device — it speaks the same SmartCard-HSM protocol, so ceremonies and
drills can be exercised end-to-end before any production hardware is touched. Nothing in this
report asks you to treat the Pico HSM as a primary HSM, and none of our findings bear on
production use of the firmware for everyday smartcard workloads.
What staging demands that everyday use does not is wipe-and-reprovision cycles: our central
drill is "any card can die and be replaced from the seed with no loss", which means INITIALIZE
DEVICE, DKEK domain creation, share import, and wrapped-key unwrap in a tight loop, measured
statistically. That workload surfaces a family of reliability defects in the reset path and in
the flash persistence layer. All findings below were measured on a Waveshare RP2350-PiZero
(RP2350B, 16 MB flash) with SWD forensics (Raspberry Pi Debug Probe + OpenOCD rp2350 target +
arm-none-eabi-gdb), not inferred. We are happy to provide raw logs, register dumps, and
reproduction scripts.
Environment: OpenSC sc-hsm-tool and CardContact Smart Card Shell (scsh 3.18.77) on the host;
polhenarejos/pico-hsm at 1ad8444 plus our patch series (see linked PR); Pico SDK 2.x.
Bug 1 — INITIALIZE DEVICE leaves the card dead until a physical replug (already #9) [CORRECTED]
Known upstream as polhenarejos/pico-hsm#9. After re-personalisation the card completes the
wipe but never rebuilds state; the reader reports a card but no ATR, indefinitely. Our
measurement matches: only a replug recovers. The PR resets the device after INITIALIZE (the
response is transmitted first; the reset is deferred so it never lands mid-command).
[CORRECTED] Two errors here. (1) The pico-hsm#9 attribution is wrong — you were right,
that was a 2022 inter-core queue fix and is unrelated. (2) The card does not "complete the
wipe but never rebuild state". On unmodified firmware it panics mid-command: the in-flight
UPDATE BINARY (INS D7, EF 2F03) inside sc_hsm_initialize times out after ~5 s and core0 is
already dead in panic(). A post-INITIALIZE reset therefore masks a panic rather than fixing
the cause; PR #27 and pico-hsm#136 are now drafts. Full trace: comment.
Bug 2 — warm boot wedges in ~5–10% of resets; both cores never start (fixed by deep resets)
Reproduced repeatedly via a cycle harness (sc-hsm-tool --initialize in a loop). Forensics on a
live hang: watchdog REASON.TIMER set — the watchdog did fire — then both Cortex-M33 cores
reading all-zero (PC/SP/xPSR), XIP SSI all-zero, and the bootrom's bootram diagnostics
untouched. The chip reset; the boot never started.
Reset mechanism was ruled out: identical signature with SDK watchdog_reboot() and the
bootrom's rom_reboot(). AIRCR.SYSRESETREQ was ruled out separately — on RP2040/RP2350 it
resets only the asserting core (raspberrypi/pico-feedback#329); we measured it as a complete
no-op (RAM statics survived the write).
What finally closed it: the default watchdog reset leaves the switched core domain powered.
Arming POWMAN_WDSEL = RESET_SWCORE | RESET_PSM | RESET_POWMAN_ASYNC (with PSM_WDSEL
all-ones, per the datasheet note that POWMAN ignores watchdog resets that don't select CLOCKS
or earlier) makes every watchdog-family reset power-cycle the switched core domain — "the same
effect as a power-on reset" per the RP2350 datasheet. Because RESET_POWMAN restores powman
defaults, the bits are re-armed at every boot.
Validation: 100/100 consecutive reboots with zero hangs against a ~5–10% measured wedge
rate (p ≈ 0.0007 under the old rate). The fix is in the PR.
Bug 3 — multicore launch handshake reports success while core1 never starts
Twice caught live via SWD: core1 parked in the bootrom's wait-for-launch loop (PC 0x19e,
polling SIO FIFO_ST, bootrom stack 0xf0000000), one unread word in the FIFO (VLD=1),
while card_locked_func showed the launch "succeeded" and USB enumerated (TinyUSB runs on
core0). The card enumerates but never answers an APDU. The SDK's
multicore_launch_core1_raw() echo handshake can complete against stale/misaligned FIFO data —
plausibly residue across a warm reset or a word consumed by the trampoline's drain phase.
The PR verifies the launch (a flag set by the launched thread), retries, and escalates to a
bounded chip reset; it also adds a dead-man's switch (ping via the existing queue echo
protocol) for the mid-session variant, which we also caught live.
Bug 4 — 1 ms multicore-lockout timeouts orphan core1 permanently [CORRECTED]
low_flash.c calls multicore_lockout_start/end_timeout_us(1000) around flash erases. A
timed-out END leaves core1 parked in the lockout victim handler forever (SWD: PC inside
multicore_lockout_handler, card mute, core0 healthy). 1 ms is far inside normal IRQ/entry
latency during real erase workloads. The PR raises the timeouts to 100 ms and re-syncs with a
start+end pair after an end-timeout.
[CORRECTED] You asked directly whether the END-timeout warning was ever captured. It was
not — and the reproduction shows why: core1 is not orphaned by a timeout at all. Core0
panics between lockout START and END (and after save_and_disable_interrupts()), leaving
the lockout held and interrupts off permanently. The start+end re-sync was also wrong —
lockouts are non-nestable, as you said — and has been removed. PR #29 is now a draft and
addresses a scenario we never observed.
Concurrency audit you asked for: the only in-tree lockout callers are low_flash.c:116/125/138/146,
all inside low_flash_task()'s drain loop on core0, plus multicore_lockout_victim_init() at
:243. phymarker_write() erases/programs without the lockout but is #ifdef PICO_RP2040 and
runs from low_flash_init() before core1 launches — not a concurrency hazard.
Bug 5 — hard_assert panics brick the device forever [CORRECTED: not "brick"]
A corrupted flash_pages entry (an SRAM pointer, 0x20001A00, queued after interrupted wipe
cycles) reached hard_assert(flash_offs + count <= PICO_FLASH_SIZE_BYTES) and the device sat
in panic() → __breakpoint → _exit for a day — no APDU service, no reset, physical replug
required. The PR (a) validates queued page addresses against the real fs data region before
touching flash, and (b) overrides hard_assertion_failure() to reboot via rom_reboot instead
of bricking.
[CORRECTED] "Brick" is the wrong word and you were right to push back — BOOTSEL plus a full
flash_nuke and reflash always recovers the board. The accurate statement is that the panic
leaves the running firmware permanently unresponsive until an external reset.
The reproduction also confirms this is not patch-induced: the identical corrupt-SRAM-pointer
panic occurs on unmodified upstream (0x2000D5A5, from scan_region()). Your ordering critique
was also correct — validation now happens before the first read/queue and returns an error
(PR #30) — but that is a safety net, not the fix. The panic-recovery policy has been withdrawn
from the PR series.
Bug 6 — flash persistence layer: writes do not survive, and find_free_page hard-faults on corrupted metadata — NOT FIXED, need upstream help
This is the defect we cannot fix from the outside, and the one that most needs your eyes.
Symptoms (all on the same fs, all reproducible):
- DKEK metadata reads as corrupt:
sc-hsm-tool reports DKEK key check value : 0000000000000000 for a domain whose share was just imported successfully. On a truly
factory-clean fs (fs region fully erased) the KCV reads correctly and host/card KCVs match —
so the "always-zero KCV" behaviour is a persistence symptom, not a device quirk.
unwrapKey fails SW=6400 even with host and card KCVs identical
(292848FACCC4F1BE) — the domain key is right, the unwrap still refuses. The same unwrap
flow passed on this exact card weeks earlier.
- PKCS#11 reports
CKR_USER_PIN_NOT_INITIALIZED minutes after the same PIN verified — the
initialize's own writes (PIN state, PKCS#15 files) do not survive a chip reset.
[CORRECTED — attribution for symptoms 1 and 3] These are consequences of the Bug 1/5 panic,
not independent persistence failures. The EF write that would create the PKCS#15 structures is
exactly the operation that panics, so those structures are never written — hence
CKR_USER_PIN_NOT_INITIALIZED from PKCS#11 even though a raw VERIFY APDU (00 20 00 81)
returns 9000, and hence the all-zero KCV. Both reproduce on unmodified upstream firmware.
Honest note on your question 2: we have not reproduced the unwrapKey SW=6400 (symptom 2) on
unmodified firmware — that one remains unattributed.
- HardFault in the DKEK store path. Captured live via SWD (fault escalated to a debug
halt; register dump available):
- backtrace:
isr_hardfault ← memcpy ← find_free_page (low_flash.c:403) ← flash_program_uintptr ← flash_clear_file (file.c:675) ← flash_write_data_to_file_internal ← file_put_data ← mkek_store_file ← store_dkek_key ← save_dkek_key(id=0, key=NULL) ← sc_hsm.c:478 (deferred save at PIN verify)
BFAR = 0x6CB44000 (bus fault, precise, CFSR=0x8200): find_free_page received
addr_alg = 0x6CB44000, read from a prev_addr field in the fs metadata — i.e. the
on-flash fs structure itself held a garbage pointer.
Environment note that may matter: the fs data region on this build is partition-dependent —
measured bounds start_data_pool ≈ 0x100FF000, end_flash = 0x10400000 (≈ [1 MB, 4 MB)), not
[FLASH_SIZE/2, FLASH_SIZE) as low_flash_init's own comment suggests. Any code assuming the
latter (including, candidly, a guard we added ourselves — corrected in the PR to use
flash_addr_in_fs() against the real bounds) misclassifies every address.
Ask: review of the page-cache/lazy-drain design in low_flash.c + flash.c —
find_free_page's whole-sector memcpy(p->page, (uint8_t *)addr_alg, FLASH_SECTOR_SIZE) and
the erase-then-reprogram drain — for how interrupted operations (and resets landing while lazy
writes are queued, which our reset path now explicitly guards against) can persist garbage
prev/next links or stale sector images. We can reproduce on request and can test patches.
What the linked PR changes
- reset after INITIALIZE (deferred until the APDU response is out),
- deep resets via POWMAN_WDSEL (Bug 2),
- launch verification + dead-man's switch (Bug 3),
- lockout timeout raise + re-sync (Bug 4),
- fs-page address guard with correct bounds + panic→reboot (Bug 5),
EV_PING handling fix (our dead-man's probe re-executed staged APDUs — included for
completeness since it touches shared code paths),
- and, defensively, a drain-guard so a scheduled reset never lands while lazy flash writes are
queued (one plausible contributor to Bug 6's corruption).
Bug 6 is not claimed fixed by the PR.
Context — how we use pico-hsm (and why it matters for this report)
We run an open-source, hardware-rooted key-custody project. Production tokens are Nitrokey
HSM 2s (genuine SmartCard-HSM). The Pico HSM is used strictly as a STAGING and
procedure-rehearsal device — it speaks the same SmartCard-HSM protocol, so ceremonies and
drills can be exercised end-to-end before any production hardware is touched. Nothing in this
report asks you to treat the Pico HSM as a primary HSM, and none of our findings bear on
production use of the firmware for everyday smartcard workloads.
What staging demands that everyday use does not is wipe-and-reprovision cycles: our central
drill is "any card can die and be replaced from the seed with no loss", which means INITIALIZE
DEVICE, DKEK domain creation, share import, and wrapped-key unwrap in a tight loop, measured
statistically. That workload surfaces a family of reliability defects in the reset path and in
the flash persistence layer. All findings below were measured on a Waveshare RP2350-PiZero
(RP2350B, 16 MB flash) with SWD forensics (Raspberry Pi Debug Probe + OpenOCD
rp2350target +arm-none-eabi-gdb), not inferred. We are happy to provide raw logs, register dumps, andreproduction scripts.
Environment: OpenSC
sc-hsm-tooland CardContact Smart Card Shell (scsh 3.18.77) on the host;polhenarejos/pico-hsmat1ad8444plus our patch series (see linked PR); Pico SDK 2.x.Bug 1 — INITIALIZE DEVICE leaves the card dead until a physical replug (already #9) [CORRECTED]
Known upstream as
polhenarejos/pico-hsm#9. After re-personalisation the card completes thewipe but never rebuilds state; the reader reports a card but no ATR, indefinitely. Our
measurement matches: only a replug recovers. The PR resets the device after INITIALIZE (the
response is transmitted first; the reset is deferred so it never lands mid-command).
Bug 2 — warm boot wedges in ~5–10% of resets; both cores never start (fixed by deep resets)
Reproduced repeatedly via a cycle harness (
sc-hsm-tool --initializein a loop). Forensics on alive hang: watchdog
REASON.TIMERset — the watchdog did fire — then both Cortex-M33 coresreading all-zero (PC/SP/xPSR), XIP SSI all-zero, and the bootrom's bootram diagnostics
untouched. The chip reset; the boot never started.
Reset mechanism was ruled out: identical signature with SDK
watchdog_reboot()and thebootrom's
rom_reboot().AIRCR.SYSRESETREQwas ruled out separately — on RP2040/RP2350 itresets only the asserting core (raspberrypi/pico-feedback#329); we measured it as a complete
no-op (RAM statics survived the write).
What finally closed it: the default watchdog reset leaves the switched core domain powered.
Arming POWMAN_WDSEL = RESET_SWCORE | RESET_PSM | RESET_POWMAN_ASYNC (with PSM_WDSEL
all-ones, per the datasheet note that POWMAN ignores watchdog resets that don't select CLOCKS
or earlier) makes every watchdog-family reset power-cycle the switched core domain — "the same
effect as a power-on reset" per the RP2350 datasheet. Because RESET_POWMAN restores powman
defaults, the bits are re-armed at every boot.
Validation: 100/100 consecutive reboots with zero hangs against a ~5–10% measured wedge
rate (p ≈ 0.0007 under the old rate). The fix is in the PR.
Bug 3 — multicore launch handshake reports success while core1 never starts
Twice caught live via SWD: core1 parked in the bootrom's wait-for-launch loop (PC
0x19e,polling SIO
FIFO_ST, bootrom stack0xf0000000), one unread word in the FIFO (VLD=1),while
card_locked_funcshowed the launch "succeeded" and USB enumerated (TinyUSB runs oncore0). The card enumerates but never answers an APDU. The SDK's
multicore_launch_core1_raw()echo handshake can complete against stale/misaligned FIFO data —plausibly residue across a warm reset or a word consumed by the trampoline's drain phase.
The PR verifies the launch (a flag set by the launched thread), retries, and escalates to a
bounded chip reset; it also adds a dead-man's switch (ping via the existing queue echo
protocol) for the mid-session variant, which we also caught live.
Bug 4 — 1 ms multicore-lockout timeouts orphan core1 permanently [CORRECTED]
low_flash.ccallsmulticore_lockout_start/end_timeout_us(1000)around flash erases. Atimed-out END leaves core1 parked in the lockout victim handler forever (SWD: PC inside
multicore_lockout_handler, card mute, core0 healthy). 1 ms is far inside normal IRQ/entrylatency during real erase workloads. The PR raises the timeouts to 100 ms and re-syncs with a
start+end pair after an end-timeout.
Bug 5 — hard_assert panics brick the device forever [CORRECTED: not "brick"]
A corrupted
flash_pagesentry (an SRAM pointer,0x20001A00, queued after interrupted wipecycles) reached
hard_assert(flash_offs + count <= PICO_FLASH_SIZE_BYTES)and the device satin
panic()→__breakpoint→_exitfor a day — no APDU service, no reset, physical replugrequired. The PR (a) validates queued page addresses against the real fs data region before
touching flash, and (b) overrides
hard_assertion_failure()to reboot viarom_rebootinsteadof bricking.
Bug 6 — flash persistence layer: writes do not survive, and
find_free_pagehard-faults on corrupted metadata — NOT FIXED, need upstream helpThis is the defect we cannot fix from the outside, and the one that most needs your eyes.
Symptoms (all on the same fs, all reproducible):
sc-hsm-toolreportsDKEK key check value : 0000000000000000for a domain whose share was just imported successfully. On a trulyfactory-clean fs (fs region fully erased) the KCV reads correctly and host/card KCVs match —
so the "always-zero KCV" behaviour is a persistence symptom, not a device quirk.
unwrapKeyfailsSW=6400even with host and card KCVs identical(
292848FACCC4F1BE) — the domain key is right, the unwrap still refuses. The same unwrapflow passed on this exact card weeks earlier.
CKR_USER_PIN_NOT_INITIALIZEDminutes after the same PIN verified — theinitialize's own writes (PIN state, PKCS#15 files) do not survive a chip reset.
halt; register dump available):
isr_hardfault ← memcpy ← find_free_page (low_flash.c:403) ← flash_program_uintptr ← flash_clear_file (file.c:675) ← flash_write_data_to_file_internal ← file_put_data ← mkek_store_file ← store_dkek_key ← save_dkek_key(id=0, key=NULL) ← sc_hsm.c:478 (deferred save at PIN verify)BFAR = 0x6CB44000(bus fault, precise,CFSR=0x8200):find_free_pagereceivedaddr_alg = 0x6CB44000, read from aprev_addrfield in the fs metadata — i.e. theon-flash fs structure itself held a garbage pointer.
Environment note that may matter: the fs data region on this build is partition-dependent —
measured bounds
start_data_pool ≈ 0x100FF000,end_flash = 0x10400000(≈ [1 MB, 4 MB)), not[FLASH_SIZE/2, FLASH_SIZE)aslow_flash_init's own comment suggests. Any code assuming thelatter (including, candidly, a guard we added ourselves — corrected in the PR to use
flash_addr_in_fs()against the real bounds) misclassifies every address.Ask: review of the page-cache/lazy-drain design in
low_flash.c+flash.c—find_free_page's whole-sectormemcpy(p->page, (uint8_t *)addr_alg, FLASH_SECTOR_SIZE)andthe erase-then-reprogram drain — for how interrupted operations (and resets landing while lazy
writes are queued, which our reset path now explicitly guards against) can persist garbage
prev/nextlinks or stale sector images. We can reproduce on request and can test patches.What the linked PR changes
EV_PINGhandling fix (our dead-man's probe re-executed staged APDUs — included forcompleteness since it touches shared code paths),
queued (one plausible contributor to Bug 6's corruption).
Bug 6 is not claimed fixed by the PR.