Skip to content

fix(passphrase): keep the plaintext off the stack, and zero it - #491

Merged
BitHighlander merged 1 commit into
release/7.14.2from
fix/476-passphrase-residue
Aug 20, 2026
Merged

fix(passphrase): keep the plaintext off the stack, and zero it#491
BitHighlander merged 1 commit into
release/7.14.2from
fix/476-passphrase-residue

Conversation

@BitHighlander

Copy link
Copy Markdown
Owner

Closes #476.

Answers to the three questions worth asking first

Is it the BIP-39 passphrase? Yes — char passphrase[PASSPHRASE_BUF] in
PassphraseInfo. It selects the wallet: the difference between the visible
wallet and a hidden one.

Is it a regression? No. git diff 1af2ffe7de HEAD -- lib/firmware/passphrase_sm.c
shows the only change in this file for 7.14.2 is #475's verdict propagation. The
missing zeroing shipped in 7.14.1 and earlier.

Is the design really "host sends it over the wire, device shows it"? Yes —
PassphraseAck carries it and the device renders "If this is wrong, unplug/replug Keepkey:" so the user can confirm which wallet is opening. That
bounds the severity of this issue
: a compromised host already has the
passphrase in plaintext. What this closes is a device-local residue, not a
disclosure path.

The fix is not the one the issue proposed

#476 suggests marking the local CONFIDENTIAL. That cannot work: on device
builds CONFIDENTIAL is __attribute__((section("confidential")))
(CMakeLists.txt:88), and a section attribute does not apply to an automatic.

So the buffer has to leave the stack — which is exactly what the rest of the
firmware already does:

placement zeroed
confirm_sm.c strbuf static CONFIDENTIAL yes
session cache static SessionState CONFIDENTIAL session session_clear_impl
passphrase_protect() stack never

This makes it static PassphraseInfo CONFIDENTIAL and zeroes it on entry and
exit.

Why both, and not just the section: .confidential is NOLOAD and is wiped
by the bootloader at boot (tools/bootloader/main.c:83, keepkey.ld:23).
That is reboot-scope protection. The residue that matters is the one between two
calls, without a power cycle — so it is zeroed explicitly as well.

Reentrancy: safe. passphrase_protect() is called from fsm handlers on the
single main loop and blocks inside passphrase_request() until the exchange
completes.

Scope

Three lines plus comments, no behaviour change, no wire change, no new screens.
The issue said "not proposed for 7.14.2" on the grounds that it is untestable at
the unit layer and unchanged from the base — both still true. Offered anyway
because it is small and self-contained, and an open issue reading "plaintext
passphrase on the stack" sits badly beside a release whose subject is disclosure.

Happy to defer to 7.15 if you would rather not touch this file again after the
hardware round.

Closes #476.

passphrase_protect() held the BIP-39 passphrase in a stack local that was never
zeroed, on every path -- success, host cancel, and the early-out where
passphrase protection is off. The passphrase selects the wallet: it is the
difference between the visible wallet and a hidden one.

Nothing else in the firmware treats key material that way. confirm_sm.c uses
'static CONFIDENTIAL char strbuf[]'; the session cache is
'static SessionState CONFIDENTIAL session' and is zeroed in session_clear_impl.
This one buffer opted out of both.

Not a regression: unchanged from the shipped base 1af2ffe. The only delta in
this file for 7.14.2 is #475's verdict propagation.

CONFIDENTIAL is not a documentation marker. On device builds CMakeLists defines
it as __attribute__((section("confidential"))), a NOLOAD region that the
bootloader wipes at boot (tools/bootloader/main.c:83, keepkey.ld:23); on
emulator builds it is empty. A section attribute cannot apply to an automatic,
so the buffer has to leave the stack to get that protection -- hence static.
passphrase_protect() is not reentrant: fsm handlers call it on the single main
loop and it blocks in passphrase_request() until the exchange completes.

The section is only cleared at boot, so the buffer is also zeroed on entry and
exit. Reboot-scope protection is not call-scope protection, and the residue
between two calls is the part reachable without a power cycle.

Bounded severity, and worth stating: the passphrase arrives FROM the host in
plaintext by design -- the device displays it so the user can confirm which
wallet they are opening. A compromised host already has it. This closes a
device-local residue, not a disclosure path.
@BitHighlander
BitHighlander merged commit 75f742b into release/7.14.2 Aug 20, 2026
12 checks passed
@BitHighlander
BitHighlander deleted the fix/476-passphrase-residue branch August 20, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant