Skip to content

fix(display): seed/BIP-85 clipping, the a3da828 follow-up, and the missing rng_health dependency - #534

Merged
BitHighlander merged 21 commits into
backport/7.15-security-to-alphafrom
fix/alpha-seed-bip85-display
Aug 24, 2026
Merged

fix(display): seed/BIP-85 clipping, the a3da828 follow-up, and the missing rng_health dependency#534
BitHighlander merged 21 commits into
backport/7.15-security-to-alphafrom
fix/alpha-seed-bip85-display

Conversation

@BitHighlander

Copy link
Copy Markdown
Owner

Stacked on #533 as directed. Three things, in dependency order.

1. The compile failure that made #533's full ARM and dylib jobs red

Not MAX_PAGES and not SRAM — it stops during compilation, before linking.

fsm_msg_zcash.h calls rng_health_check() and random_buffer_checked() (lines ~1225–26) without seeing their declarations. Root cause: release/7.15's fsm.c carries #include "keepkey/rand/rng_health.h" at line 39 and alpha's does not — the 3-way merge in #533 took the security changes but not that include. The emulator and unit builds compiled only because a different include ordering supplied the declarations.

Fixed at the narrowest correct location: fsm_msg_zcash.h itself, the file that uses the API. Depending on fsm.c's ordering is the fragility that caused this. Swept every other caller of those two functions — all declare it.

2. The omitted a3da828 security follow-up

#533 carried 8661918 and stopped, so 8661918..a3da828 was missing. Direction-ported with per-file classification, not cherry-picked:

file classification
solana.h, fsm_msg_mayachain.h, fsm_msg_solana.h, fsm_msg_thorchain.h alpha == port base → took a3da828
fsm_msg_zcash.h, unittests/firmware/zcash.cpp diverged → 3-way merged

Both merges clean. "ours" was this candidate, not raw alpha — those two files already carried the security backport and the include fix, so merging against alpha would have reverted them.

Semantics verified present rather than assumed: ZIP-229 v6 empty Orchard digest + ZTxIdOrchardH_v6; empty Ironwood + ZTxIdIronwd_H_v6; exactly-one send/deposit on THORChain and MAYAChain; transaction- and deposit-level memo review; authenticated KKSOLSC1 wired into opaque Solana review; blind-sign warning preserved after annotation. Prior work verified intact: the include, redpallas_sign_digest_with_ak, zcash_T[80], EmptyBundleDigests_MatchZip244, all six RedPallasNonce_*.

3. Seed/BIP-85 clipping — reworked to keep the transcript fixed

My first attempt was wrong and review caught it. Measuring the packing at the real width raised 24-word backups from 6 pages to 7 for 1.78% of seeds, and reset.c runs for (current_page < page_count) { confirm_constant_power(...) } where each call emits exactly one ButtonRequest. A seventh page was a seventh host request — and the host reads one word set per request, so that corrupts reconstruction. "More pages" and "unchanged transcript" were contradictory; raising MAX_PAGES shipped the contradiction rather than resolving it.

Reworked so the protocol boundary does not move:

  • MAX_PAGES back to 6 — no 3.7KB expansion
  • grouping back to BODY_WIDTH — the grouping is the boundary
  • layout still draws at 124px — rows wrap instead of running off the canvas

confirm_constant_power_paged() emits one ButtonRequest for the group, then advances subpages locally. The seam: confirm_helper()/confirm_screen() do not emit requests — only public entry points do. Verified exactly one msg_write() in the function. Subpages split only at row boundaries so a word is never divided across screens; intermediate subpages take a short press, only the last takes the hold; cancelling any subpage cancels the group; the scratch buffer is CONFIDENTIAL and wiped.

The measurement behind it

Exhaustive over every BIP-39 word × indices 1–24, real font tables:

px vs 124
widest single N.word (20.mushroom) 66 fits, 58 spare
widest adjacent pair, zero separator 132 overflows

Two words per line is impossible in the worst case and trimming indentation cannot fix it. A random sample says 141,517/141,519 pairs fit — which would have led to a spacing tweak that still fails.

unittests/firmware/seed_display.cpp pins all of this, registered in the build.

What is NOT done

Stated plainly — do not merge on this description:

  • No local ARM/dylib build. Docker here is wedged (docker ps exceeds 180s). CI is the authority; it is what caught the missing header.
  • Shared seed/BIP-85 packing helper not yet factored.
  • Checksum-valid BIP-39 vectors, cancellation/subpage tests, buffer-wipe assertions, and proof the new tests execute rather than merely compile — outstanding.
  • Memory deltas across a710bb57 / 40da0906 / this head — outstanding.
  • pyk's three red security tests against this candidate — outstanding.
  • pyk nested device-protocol pin alignment to bb5e43e05 + binding regeneration — outstanding.
  • Vault/SDK transcript check and physical OLED evidence — outstanding.

Constant-power screens draw from x = 128 + LEFT_MARGIN, because the display
driver mirrors the right half of the canvas onto the panel. The real budget is

    KEEPKEY_DISPLAY_WIDTH - (128 + LEFT_MARGIN) = 124 px

but both the formatter and the renderer used BODY_WIDTH (225). The wrap
therefore never fired before the canvas edge did: draw_char_impl() rejected the
first glyph crossing 256, draw_string_walk() stopped, and every character after
it was dropped -- including whole later lines. No ellipsis, no warning, no page
indicator. The user writes down a truncated or missing word and the backup does
not restore the wallet.

MEASURED with the real font tables over 200,000 random 24-word mnemonics:

  before   1.712% of backups clipped a page; 0.646% never showed one of the
           words at all
  after    0 clipped pages in 200,000; 0 exceeded MAX_PAGES

EXHAUSTIVE, not sampled. Over every BIP-39 word and indices 1..24:

  widest single "N.word"                  66 px  (20.mushroom)  fits, 58 spare
  widest adjacent pair, NO separator     132 px  OVERFLOWS by 8

So two numbered words per line is impossible at 124 px in the worst case, and
no amount of trimming indentation fixes it -- 3 spaces gives 144 px, 1 space
136, zero 132. A random sample says 141,517 of 141,519 pairs fit and would have
led straight to a spacing tweak that still fails on mushroom-class words. This
is why the acceptance gate demanded exhaustive proof.

Measuring at the real width makes the wrap fire before the canvas edge, which
naturally puts one word on a line when two will not fit, and costs an extra page
only for the seeds that need one:

  98.22% of random 24-word seeds -> 6 pages, unchanged
   1.78%                         -> 7 pages
  worst case, every word widest  -> 12 pages

That 1.78% is the same population that was clipping. Repeated words are legal
BIP-39 so the worst case is reachable, and MAX_PAGES is raised 6 -> 12 to bound
it: sizing to the observed maximum instead would fail closed with "Too many
pages of mnemonic words" during wallet creation on a legal seed. Cost is about
3.7 KB of scratch.

WHY THIS IS NOT THE REVERTED PAGER. That change split a screen inside
page_body_confirm(), which emits one ButtonRequest per page while the device's
current_page never advanced -- so the host read the same words twice and
reconstructed a mnemonic with duplicated words. Here reset.c owns both the page
content and mnemonic_by_screen[], which reset_get_word() reports, so each
ButtonRequest still maps to exactly one page and the host reads each page once.

BIP-85 gets the same fix: it renders derived words on the same constant-power
screens from the same scratch buffers, so it clipped identically, and a derived
mnemonic is as unrecoverable as the master one when a word is missing.
PYTHON-KEEPKEY. alpha's pin (999e776a6) shared NO merge-base with the live
lineage: it sits on pre-squash-197-backup, a backup taken before a squash
rewrite, while .gitmodules declared alpha tracks reconcile/upstream-sync. The
declared branch and the actual pin disagreed, and no single existing branch
carried everything alpha needs:

  canonical reconcile/upstream-sync   CI gates, Aave V3 fixtures, token dedup
  canonical release/7.15-audit-fixes  the 7.15 security test work
  fork develop                        the WETH uniswap-token addition

Pinning any one of them drops the others -- in particular, pinning the branch
alpha claimed to track would have shipped the firmware security fixes WITHOUT
the tests that prove them: the display-disclosure vacuity fix, the v6
empty-Orchard-bundle fixture and Z27.

The three are merged on the fork's develop, which is now the reconciled line,
and the submodule points there. Verified before moving: the tree diff from
alpha's old pin is +975/-97 across 23 files with ZERO deletions, so nothing
alpha carried is lost -- its 54 "ahead" commits are the pre-squash originals of
work the live lineage already contains.

DEVICE-PROTOCOL. a1a1dda3e -> bb5e43e05, the up/release-protocol tip. Pure
fast-forward: 8 ahead, 0 behind, so there is nothing to reconcile.
The backport carried 8661918 but stopped there, so the final audited fixes in
8661918..a3da828 were missing from alpha. Direction-ported rather than
cherry-picked, because alpha diverges.

CLASSIFICATION, per file, against the port base:

  solana.h              alpha == base -> took a3da828
  fsm_msg_mayachain.h   alpha == base -> took a3da828
  fsm_msg_solana.h      alpha == base -> took a3da828
  fsm_msg_thorchain.h   alpha == base -> took a3da828
  fsm_msg_zcash.h       DIVERGED -> 3-way merged, ours = this candidate
  zcash.cpp             DIVERGED -> 3-way merged, ours = this candidate

Both 3-way merges applied clean. "ours" is this branch, not raw alpha, because
the two zcash files already carried the security backport and the rng_health
include fix -- merging against alpha would have reverted them.

SEMANTICS LANDED, each verified present rather than assumed:
  ZIP-229 v6 empty Orchard digest and ZTxIdOrchardH_v6
  empty Ironwood digest and ZTxIdIronwd_H_v6
  exactly-one send/deposit validation, THORChain and MAYAChain
  transaction-level AND deposit-level memo review when both are signed
  authenticated KKSOLSC1 schema data wired into opaque Solana review
  the ordinary blind-sign warning preserved after schema annotation
  the corresponding firmware regressions

PRIOR WORK VERIFIED INTACT after the merges: the rng_health.h include,
redpallas_sign_digest_with_ak, the zcash_T[80] checked draw,
EmptyBundleDigests_MatchZip244, and all six RedPallasNonce_* regressions.
My previous approach was wrong and CI review caught it. Measuring the packing
at the real 124 px width raised a 24-word backup from 6 pages to 7 for 1.78% of
seeds -- and reset.c runs

    for (current_page < page_count) { confirm_constant_power(...) }

with confirm_constant_power() emitting exactly one ButtonRequest per call. So a
seventh page was a seventh host-visible request. The host reads one word set per
request:

    while isinstance(resp, proto.ButtonRequest):
        mnemonic.append(client.debug.read_reset_word())

"More display pages" and "unchanged host transcript" were contradictory in that
implementation. Raising MAX_PAGES did not resolve the contradiction, it shipped
it.

REWORKED so the protocol boundary does not move:

  MAX_PAGES back to 6            no 3.7 KB expansion
  grouping back to BODY_WIDTH    the GROUPING is the protocol boundary
  layout still draws at 124 px   so rows wrap instead of running off the canvas

confirm_constant_power_paged() emits ONE ButtonRequest for the group and then
advances subpages locally. The seam is that confirm_helper() and
confirm_screen() do not emit requests -- only the public entry points do -- so
subpages can be rendered through confirm_screen() under a single request.
Verified: exactly one msg_write() in the function.

Subpages split only at row boundaries, so a word is never divided across
screens, and each chunk is measured at CONSTANT_POWER_BODY_WIDTH against
BODY_ROWS. Intermediate subpages take a short press; only the last takes the
caller's hold, because only the last is the approval. Cancelling any subpage
cancels the group. The scratch buffer is CONFIDENTIAL and wiped on exit.

Both flows use it: reset.c and fsm_msg_bip85.h. No direct confirm_constant_power
call remains in either.

Also lands unittests/firmware/seed_display.cpp, registered in the build: the
budget is derived from the draw origin rather than asserted as a literal; every
BIP-39 word at every index 1..24 fits one line, exhaustively; the widest
adjacent pair provably does NOT fit even with zero separator, so if that test
ever starts passing two-up packing became possible; the worst-case all-widest
mnemonic stays within MAX_PAGES; and the known clipping vector is asserted to
have been ACCEPTED at the old 225 px width, so the test keeps demonstrating the
original bug.
Five things, all found in review rather than by me.

1. DEBUGLINK WOULD HANG AFTER THE FIRST SUBPAGE. debug_decided is a LOCAL in
confirm_screen() while button_request_acked is a file static, so on the second
subpage the local resets to false, the static stays true, and the loop waits for
a decision that never comes -- DebugLink supplies ONE decision per
ButtonRequest, and the whole group is one request.

confirm_screen() now reports whether it exited on a debug decision, and the
pager carries that across subpages: the decision already taken covers the rest
of the group. Remaining subpages are still RENDERED, so DebugLinkGetState reads
the real screen, but they do not wait. Physical presses are unaffected.

2. THE WORST-CASE TEST WAS STALE. It packed at CONSTANT_POWER_BODY_WIDTH and
compared the result to MAX_PAGES -- two different things once the design
changed. Groups are formed at BODY_WIDTH, because the grouping is the protocol
boundary, and the 124 px fitting happens as subpages inside a group which
consume no page slots. It now measures GROUPS, and pins MAX_PAGES == 6 so the
approach that changed the request count cannot come back unnoticed.

3. THE SUBPAGE SPLIT USED calc_str_line(). That is a second model of the screen,
and the guard it backs has been broken three separate ways in this file's
history. It now uses confirm_body_fits_constant_power(), which replays
draw_string()'s own loop and per-glyph fit test with the writes off, at the
origin the layout actually draws from. Measuring and drawing are the same code.
This is a security decision: a row that does not fit is a seed word the user
never sees.

4. CONSTANT-POWER CALLERS AUDITED. Exactly two exist -- reset.c and
fsm_msg_bip85.h -- and both now go through the pager. Nothing else is affected
by the layout's 124 px width.

5. THE STALE COMMENT claiming the fix needs MAX_PAGES raised (~3.7 KB) is
replaced with what was actually done.
DebugLink cannot supply per-subpage visual evidence, and nothing should be built
on the assumption that it can.

The carried subpages inside a group are drawn but NOT waited on -- that is what
stops the second one hanging on a decision that never comes -- so the message
loop never runs between them and DebugLinkGetState has no turn in which to read
them. A screenshot taken over DebugLink sees a group's LAST subpage, not each
one. That limit is now stated at the carry site and on the exposed helper, so a
later reader does not mistake a green screenshot job for per-subpage proof.

Per-subpage content is proven instead by unit instrumentation over the splitter,
which is exposed as confirm_constant_power_subpage_take() for exactly this:

  SubpagesCoverEveryRowExactlyOnceInOrder
      every chunk ends at a row boundary, so a numbered word is never divided
      across screens; every chunk fits when measured by the RENDERER; and the
      chunks reassemble to the original group with nothing lost, duplicated or
      reordered. That last property is the one the host contract depends on.

  SplitterTerminatesOnAnUnsplittableRow
      a row too wide to fit still advances the pager instead of looping.

The no-hang property is covered by the integration suite rather than here:
test_reset_device drives one press per ButtonRequest, so a pager that waited for
a second decision would hang it. It is in the CI filter, so that gate runs.

Physical OLED capture remains the only evidence for what each subpage actually
looks like on the panel.
Three more review findings, all real.

1. THE PAGER STRIPPED INDENTATION. It skipped leading spaces at each chunk
boundary, copied from page_body_confirm() where a line-start space is dropped.
Here every formatter row BEGINS with the indent, so that stripped it from every
subpage after the first: different content on screen, and subpages that no
longer reassemble to the group they came from. Indentation is preserved now.

My test hid this. It skipped spaces the same way before comparing, so the two
agreed by construction while both were wrong. The test now requires BYTE-EXACT
reassembly, which is the property the host contract actually depends on.

2. THE UNSPLITTABLE-ROW FALLBACK WAS UNSAFE. The splitter proved a row did not
fit and then returned it anyway, so the pager would render CLIPPED content and
report success -- reintroducing the exact defect this change exists to remove.
It returns 0 now and the pager fails closed. The truncating
min(take, sizeof(sub) - 1) is gone for the same reason: silently shortening a
row is showing the user something other than what is there.

Proven this cannot reject legitimate content, exhaustively rather than by
sample: over all 50,331,648 combinations of odd index pair and ordered word
pair, the worst a real two-word row occupies at 124 px is 3 lines -- exactly
BODY_ROWS. Fail-closed triggers only ABOVE that. The margin is zero, which is
why a sample was not good enough: 157k rows also said 3, but nothing about a
sample rules out the 4-line case that would have failed wallet creation.

3. THE FIXTURE LEAKED SIG_IGN. It set SIGALRM to ignored globally and never
restored it, so every later test in the binary ran with the alarm suppressed.
The previous handler is saved and restored in TearDown.

Still outstanding and not claimed: dynamic ButtonRequest-count coverage and
checksum-valid BIP-39 vectors.
seed_display.cpp opened extern "C" before any C++ standard header. The board
headers pull in <ctype.h>, and having that inside extern "C" before <string>
and gtest have set up the C++ <cctype> machinery fails to compile:

    /usr/include/ctype.h:17:15: error: expected unqualified-id
    cctype:70:11: error: no member named 'isprint' in the global namespace

ARM stayed green because it does not compile unit tests; only the emulator job
builds firmware-unit, so this surfaced there and nowhere else.

Now mirrors unittests/board/board.cpp exactly, which already gets this right:
C++ standard headers first, THEN the extern "C" block. The board headers still
need extern "C" -- none of them carry __cplusplus guards, so without it they
would take C++ linkage and fail to link against the C-compiled firmware
objects. I nearly dropped the wrapper entirely for the same reason it looked
unnecessary; checking board.cpp rather than guessing is what caught that.
Three defects in my own test file, none of which the previous run could have
revealed because it failed to compile before reaching them.

1. WOULD NOT LINK. BodyFitsEnv::prev_alarm_handler was declared as a static
member and never defined -- my include-block rewrite dropped the out-of-class
definition. The next successful compile would have failed at link with an
undefined reference. It is now an ordinary per-instance member with an in-class
initializer, so there is nothing to define separately.

2. THE SIGALRM SUPPRESSION LEAKED THE OTHER WAY. Ensure() installed SIG_IGN
once, guarded by a static flag, while TearDown() restored the previous handler
after EVERY test. So the first test suppressed the animation tick, restored it,
and every later fixture test then ran with the tick LIVE, repainting the canvas
underneath measurements that assume it is still. Install and restore now happen
per test; only the canvas init stays one-time, because that genuinely is.

3. THE BOUNDARY REGRESSION WAS INERT. It asserted EXPECT_GE(subpages, 1), which
a single-subpage body satisfies trivially -- it would have passed while proving
nothing about splitting. It now requires EXPECT_GT(subpages, 1).

Confirmed the vector actually crosses a boundary rather than assuming it: the
mushroom group is 7 lines at 124 px against a 3-line budget and splits into
exactly 3 subpages of 3 lines each. Had it been one subpage, the stronger
assertion would have failed and I would have needed a wider vector.
The full unit-test binary hit its 10-minute cap on the previous head while
bitcoin-only finished in 77 s. My fixture was the difference, and my first
explanation of WHY was overstated: restoring tim4_sighandler cannot by itself
restart a timer that ualarm(0, 0) disarmed, so something else would have had to
re-arm it. SIGALRM interaction remains the leading hypothesis, not a proven
mechanism -- the log that would settle it is still gated behind the run.

Rather than argue the mechanism, remove the whole class of problem: this fixture
now installs no handler and restores none, so it cannot leak state into the rest
of the binary in either direction.

timer_init() is KEPT, and dropping it would not have been safe. It fills
free_queue, and layout_init() calls post_periodic(), which does
runnable_queue_pop(&free_queue) and dereferences the result with NO null check:

    runnable_node = runnable_queue_pop(&free_queue);
    runnable_node->runnable = callback;

Without timer_init() that is a null dereference, not a missing tick. So the
fixture calls timer_init() for the queue, then ualarm(0, 0) to disarm the 1 kHz
alarm that timer_init() itself creates -- and never touches signal().

The fixture is also renamed SeedDisplayBodyFits and given internal linkage.
unittests/board/board.cpp defines its own BodyFits, and two different class
definitions sharing one external name is an ODR violation even though they land
in separate binaries today. Distinct name and internal linkage means neither can
bite later.
The full unit-tests job was not failing, it was hanging: it burned its
10-minute timeout and reported "cancelled", which reads like infrastructure
flake. The bitcoin-only job stayed green throughout.

kk_board_init() calls kk_timer_init(), and timer_init() does the same work:
both push the three static runnables[] nodes onto free_queue unconditionally.
A second bootstrap therefore relinks nodes that are already linked, free_queue
and active_queue go circular, and the runnable_queue_get() walk inside
post_periodic() never returns.

seed_display.cpp arrived with its own timer_init() guard while thorchain.cpp's
confirm driver already had one, so the full firmware unit binary bootstrapped
twice. thorchain.cpp is not compiled into the bitcoin-only image, which is why
only one variant hung -- exactly the shape the CI matrix showed.

Proven against the real lib/board/timer.c, one bootstrap versus the two that
are actually in the binary:

  timer_init only:            terminated, rc=0
  timer_init + kk_timer_init: never returned, killed by a 5s watchdog

A per-file guard cannot fix this, because the problem is that there is one
guard per file. test_board.cpp holds the single guard for the whole binary and
is compiled into both variants; both call sites now go through it.

Also drops <unistd.h> and timer.h from seed_display.cpp, whose only users were
the ualarm() and timer_init() calls this removes.
…ntity

fix(bitcoin-only): identify physical firmware to Vault

Taken into the seed/BIP-85 branch rather than stacked behind it, so one CI
matrix validates both the bootstrap fix and the Bitcoin-only identity gates
instead of two sequential ones.
@BitHighlander
BitHighlander merged commit 008dcd7 into backport/7.15-security-to-alpha Aug 24, 2026
18 checks passed
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