Skip to content

Round 4 security remediation: storage key scrubbing, CTAP2 truncation, OOB read, cipher-rotation bypass - #582

Merged
BitHighlander merged 6 commits into
alphafrom
fix/round4-storage-secrets
Aug 25, 2026
Merged

Round 4 security remediation: storage key scrubbing, CTAP2 truncation, OOB read, cipher-rotation bypass#582
BitHighlander merged 6 commits into
alphafrom
fix/round4-storage-secrets

Conversation

@BitHighlander

@BitHighlander BitHighlander commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Summary

Round 4 of the ongoing alpha audit/fix cycle. 8 findings, all CONFIRMED by independent adversarial verification (2 refuters per finding, default-to-refuted).

Also adds the Solana (#550) and Ripple (#553) boundary regression tests that independent review required before PR #557 merged but that PR landed without (see #583).

Process note

This branch was held back from merging after an initial CI-gate defect was caught (#583): every PR into alpha, including this one, was merging with the aggregate CI gate red and the entire real build/test/integration graph silently skipped (secret-scan scans the whole fork's ~150 branches by default, one of which is unrelated to alpha and always fails, cascading to skip everything downstream). That's fixed in #585 (merged into alpha), which let this PR's own python-integration-tests run for real for the first time -- surfacing two further issues (both fixed and merged separately, see below), neither a regression from this PR's own changes:

Both fixed in BitHighlander/python-keepkey#57 (merged), with deps/python-keepkey's pin bumped accordingly.

Verification

Closes #574, #575, #576, #577, #578, #579, #580, #581, #584.

@BitHighlander

Copy link
Copy Markdown
Owner Author

Independent audit: do not merge this head yet. The #581 fix only preserves one prior coded word and fails after backspacing across two boundaries; exact state sequence and required multi-boundary regressions are in #584. There are no new recovery tests in this PR. Also, as with #557, both aggregate CI gates are red and every ARM/emulator build, firmware unit, Python integration, and dylib job is skipped; #583 tracks that merge-gate defect. Static analysis/lint/crypto alone do not provide build or behavioral evidence for these changes.

…all paths

storage_secMigrate() (#574, CRITICAL): the encrypt branch memzero'd its AES
ctx but never the iv (which is a direct copy of the raw storage key); the
decrypt branch memzero'd iv but never ctx (the derived AES key schedule).
Same asymmetry, mirrored, in storage_cipherBlock() (#575).

storage_getAuthData() (#576): authdataKey and the decrypted plaintextAuthBlock
were never scrubbed on any of its three exit paths. storage_setAuthData()
(#577): scrubbed plaintextAuthBlock but never the derived authdataKey.
layoutU2FDialog() reimplemented confirm_helper()'s vsnprintf+draw pattern
standalone with no SOURCE-truncation check (vsnprintf's return was
discarded) and no RENDER-fit check at all, unlike every other confirmation
screen. The CTAP2 passkey consent dialog shows the host-controlled rp_id
(up to 253 chars) built this way -- only ~100 chars actually render, with
no ellipsis or warning, while the credential is bound to the FULL string.

layoutU2FDialog() now returns whether the formatted body both fit its
buffer and fit the canvas (via the existing confirm_body_fits()), still
drawing unconditionally so the three bounded-length legacy U2F call sites
are unaffected. ctap2_request_user_presence() -- the one call site with
attacker-controlled unbounded length -- refuses immediately on a non-fit
render rather than waiting for a button press on text the user can't read
in full.
fsm_msgCipherKeyValue() derives data[260] via hmac_sha512(node->private_key,
...) and uses it directly as the AES-256 key (and, when no explicit iv is
given, as the iv too), then builds an AES key schedule from it in ctx --
neither buffer was ever memzero'd on the success path.
…#580)

txin_dgst_save_and_reset()/txin_dgst_compare() take no length parameter and
unconditionally memcpy/strncmp ADDR_STR_LEN (130) bytes from their addr_str
argument, trusting every caller's buffer to be at least that large. The
internal-transfer confirmation path declared node_str as NODE_STRING_LENGTH
(50) and passed it to both, an 80-byte OOB stack read on every such
confirmed output, with the garbage bytes then copied into the global
duplicate-transaction detector state. The sibling needs_confirm call site
already passes a real 130-byte protobuf field, confirming the function's
actual contract.
)

next_character() re-randomizes the single global cipher array on every
call, including after a backspace -- so reconstructing coded_word by
re-encoding decoded_word under the CURRENT cipher cannot recover what was
actually typed under earlier, since-rotated permutations. coded_word is
meant to hold the literal raw bytes the host sent, so
attempt_auto_complete(coded_word) can detect a host bypassing the
substitution cipher; a corrupted coded_word stops that abort from firing,
reachable via an ordinary backspace before a word's 4th character.

An initial fix (preserving only the single most-recently-completed word's
raw bytes) was caught by independent review as still broken: backing up
across TWO OR MORE completed words left coded_word holding the wrong
word's bytes, recreating the same detector gap (#584).

Corrected here with coded_mnemonic, a raw-cipher-bytes mirror of mnemonic
itself -- same appends, same truncations, always the same length (every
CharacterAck.character is nanopb-bounded to one byte) -- so a backspace at
any depth, across any number of word boundaries, is just "truncate like
mnemonic and re-derive the current word" via the new
get_current_coded_word(), exactly how decoded_word already worked.

Adds RecoveryCipher unit tests driving recovery_character()/
recovery_delete_character() directly via a new DEBUG_LINK-only
recovery_debugLinkStart() hook (bypasses recovery_cipher_init()'s
confirm()/PIN gate, which blocks on a real button press) and two new
DEBUG_LINK getters for the raw coded history, covering: backspace within a
word, across one boundary, across two boundaries (the exact #584 repro),
across 23 boundaries, interleaved delete/retype, and a raw unenciphered
prefix still being caught after a multi-boundary backspace.
…ore #557 merged

PR #557's independent review required these before merge but the PR
landed without them (see #583). The underlying fixes were already correct
and already merged; this adds the coverage.

Solana (#550): num_accounts > SOL_MAX_ACCOUNTS(32) must fail closed
(MALFORMED) before it's ever stored or used as a bound -- a small overage
(33) would read past tx->accounts[31] as a uint16_t loop bound; a value
that wraps a uint8_t (256, 512) would silently reintroduce the original
signer-check bypass. All three cases are covered.

Ripple (#553): ripple_serializeAmount()'s assert() and the FSM's runtime
check both use the corrected 1e17-drop ceiling (XRPL's real protocol
maximum of 100,000,000,000 XRP) -- the exact maximum must serialize
without tripping the bound, proving the two enforce the same limit. The
FSM-level runtime check itself isn't covered here: unit-testing it needs a
derived HDNode from real wallet/storage state, a test tier this suite
doesn't establish anywhere else (confirm()-gated init hangs without a
DebugLink driver); the check has been code-inspection-verified to use the
same literal as the serializer.
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