Skip to content

fix: bound the two-button entry writes, and split the field they depend on - #143

Merged
aido merged 2 commits into
aido:bip85from
buzzromain:fix/bagl-entry-bounds-bip85
Aug 4, 2026
Merged

aido merged 2 commits into
aido:bip85from
buzzromain:fix/bagl-entry-bounds-bip85

Conversation

@buzzromain

@buzzromain buzzromain commented Aug 4, 2026 •

Copy link
Copy Markdown

Three writes in the two-button entry screens reach a buffer through a value bounded somewhere else, and one field that carries two unrelated meanings is what most of them are bounded by.

The writes

The stem. Each accepted letter is appended at G_ux.string_buffer[16 + strlen(G_ux.string_buffer + 16)], and the candidate letters begin at offset 32. Nothing at the write says so. What keeps the two apart is the wordlist — the longest BIP-39 word is eight characters, a ByteWord is four, and the keyboard only offers letters that extend a real prefix — so the stem stops growing long before it reaches them. The layout is now named in ux_nano.h and the write checks against it.

The mnemonic buffer. bolos_ux_bip39_idx_strcpy() writes a word and a terminator at words_buffer_length, and the separator that follows is written with a bare post-increment. Neither was bounded. The SSKR branch immediately below, in the same function, does bound its own buffer and says why; this one was left as it was.

Neither guard can fire on any input the screens can produce today. That is the point: they hold what the wordlist already holds, at the place where the write happens rather than three files away.

The field

What bounded the mnemonic buffer was bip39_type, and bip39_type held two unrelated things. The 12/18/24 menu wrote a word count into it, and the SSKR entry path wrote the wire length of a share — taken from the CBOR byte-string header as it is typed, up to SSKR_SHARE_MAX_WIRE_LENGTH — into the same field a few lines above. A word count of 46 would have run the BIP-39 entry loop 46 times over a 257-byte buffer.

The two never met, and that was traced in each direction rather than assumed:

  • number_of_bip39_words_selector() always reassigns bip39_type before screen_onboarding_bip39_restore_init(), so a BIP-39 entry never starts on a share length;
  • generate_sskr() is reachable only from ux_bip39_match_flow; after an SSKR check the only offer is recover_bip39(), which displays and nothing more.

Both are properties of the screen graph, held nowhere near the writes that depend on them. A flow that offered "generate shares" after an SSKR recovery — a natural thing to add — would have broken both at once.

sskr_share_word_count now carries the share length. bip39_type is written by the menu and by nothing else, so what bounds the BIP-39 entry no longer depends on which screen was visited last. The entry loop additionally refuses a bip39_type that is not one of the three word counts, so a screen that ever reached word entry without passing through the menu stops rather than running the loop to whatever the field happened to hold.

The new field starts at zero, which is also why zero is the right "not yet known": the loop compares it against a step count that has already been incremented, so it cannot match before the header has been read at the fourth word. It is reset only on RESTORE_WORD_ACTION_FIRST_WORD — moving to the next share of a set arrives with REENTER_WORD precisely so that the shape read from the first share is kept.

The test that was missing

Share entry on the two-button screens is a separate implementation of that screen, not a different rendering of one, and it had no end-to-end coverage: every SSKR test skips on Nano X and Nano S+. It is also the only path that exercises the field this branch introduces.

tests/functional/test_sskr_entry_two_button.py enters the shares test_sskr_128bit.py enters, on both devices, and asserts both lines of the verdict — the first one is shared with the screen that says the phrase does not match, so asserting it alone would assert nothing about the answer.

It was checked against a broken field rather than assumed to cover it: setting the initial value to 2 stops the entry at the third word and turns the test red, while 5 leaves it green. That difference is the header-read timing described above, and it is what makes the test meaningful.

nano.confirm() clicks through a screen that only asks to be acknowledged. The SSKR flow opens on one; the BIP-39 flow reaches the keyboard through its word-count menu instead.

Share generation on the same devices

The other half of the feature had the same gap, for the same reason: the two
menus that pick the share count and the threshold, and generate_sskr() behind
them, are two-button code that no test reached. That path is not screen code —
it goes through bolos_ux_bip39_to_sskr_convert() to the Shamir split, its
randomness, and the CBOR and ByteWords encoding of every share.

tests/functional/test_sskr_generate_two_button.py covers it. The shares
cannot be asserted, since the share-set identifier is random, but the CBOR tag
and the byte-string header in front of them are fixed for a 12-word seed — the
same tuna next keep gyro the touch tests assert. Changing one byte of that
tag turns the test red, so it is reading generated output.

Two helpers were needed, neither a wrapper around navigate_until_text():
choose_in_flow() for the bounded verdict flows, where a right click on the
last step changes nothing and navigate_until_text() reports that as a stuck
screen; and choose_in_carousel() for UX_STEP_MENULIST, which shows several
entries and selects one, so "3 is on the screen" is true while "1" is the entry
that would be validated.

Verification

  • six declared targets build, zero application-source warnings on each
  • clang-format --dry-run --Werror clean over src/
  • functional suite: Nano X and Nano S+ go from 2 passing to 4; Stax and Flex unchanged at 11
  • unit suite 75/75
  • both new tests falsified against a deliberately broken build, as described above

Not covered

Share entry and generation on the two-button screens are exercised for the successful path only. The error paths — an invalid phrase, invalid shares, a wrong CRC, too few shares, a duplicated share, a threshold the scheme refuses — remain uncovered there, as does the rest of the suite that still skips on those devices.

Three writes in the two-button entry screens reach a buffer through a value
that is bounded somewhere else, for another reason.

The stem. Each accepted letter is appended at
`G_ux.string_buffer[16 + strlen(G_ux.string_buffer + 16)]`, and the candidate
letters begin at offset 32. Nothing at the write says so. What keeps the two
apart is the wordlist -- the longest BIP-39 word is eight characters, a
ByteWord is four, and the keyboard only offers letters that extend a real
prefix -- so the stem stops growing long before it reaches them. The layout is
now named in ux_nano.h and the write checks against it.

The mnemonic buffer. bolos_ux_bip39_idx_strcpy() writes a word and a
terminator at words_buffer_length, and the separator that follows is written
with a bare post-increment. Neither was bounded. The SSKR branch immediately
below, in the same function, does bound its own buffer and says why; this one
was left as it was.

What bounds both is bip39_type, and bip39_type carries two unrelated meanings:
the 12/18/24 menu writes a word count into it, and the SSKR entry path writes
the wire length of a share -- up to SSKR_SHARE_MAX_WIRE_LENGTH -- into the
same field a few lines above. A word count of 46 would run the entry loop 46
times over a 257-byte buffer.

They never meet. number_of_bip39_words_selector() always reassigns bip39_type
before screen_onboarding_bip39_restore_init(), and generate_sskr() is reachable
only from ux_bip39_match_flow, never from the SSKR flows -- which was traced
both ways rather than assumed. But both are properties of the screen graph,
held nowhere near the writes that depend on them, and a flow that offered
"generate shares" after an SSKR recovery would break them together. The loop
now refuses a bip39_type that is not one of the three word counts, so that a
future flow gets a stopped entry rather than a run past the end of the buffer.

None of the three guards can fire on any input the screens can produce today,
which is the point: they hold what the wordlist and the screen graph currently
hold, at the place where the write happens. For the same reason no test can
exercise them on their own -- the same situation the share-length bound in
bolos_ux_sskr_combine() already records.
bip39_type held two unrelated things. The 12/18/24 menu wrote a word count
into it, and the SSKR entry path wrote the wire length of a share -- taken
from the CBOR byte-string header as it is typed, up to
SSKR_SHARE_MAX_WIRE_LENGTH -- into the same field. It bounded both entry
loops, and through the BIP-39 one it bounded the writes into words_buffer: a
word count of 46 would have run that loop 46 times over a 257-byte buffer.

The two never met. number_of_bip39_words_selector() always reassigns
bip39_type before screen_onboarding_bip39_restore_init(), and generate_sskr()
is reachable only from ux_bip39_match_flow, never from the SSKR flows. Both
were traced in each direction rather than assumed. But both are properties of
the screen graph, held nowhere near the writes that depend on them, and a flow
that offered "generate shares" after an SSKR recovery would have broken them
together.

sskr_share_word_count now carries the share length. bip39_type is written by
the menu and by nothing else, so what bounds the BIP-39 entry no longer
depends on which screen was visited last.

The new field starts at zero rather than at whatever the previous session
left, which is also why zero is the right "not yet known": the loop compares
it against a step count that has already been incremented, so it cannot match
before the header has been read at the fourth word. It is reset only on
RESTORE_WORD_ACTION_FIRST_WORD -- moving to the next share of a set arrives
here with REENTER_WORD precisely so that the shape read from the first share
is kept.

tests/functional/test_sskr_entry_two_button.py enters the shares
test_sskr_128bit.py enters, on Nano X and Nano S+. Share entry on the
two-button screens is a separate implementation of that screen, not a
different rendering of one, and it had no end-to-end coverage: every SSKR test
skips on those devices. It is also the only path that exercises the field this
commit introduces -- setting the field's initial value to 2 instead of 0 stops
the entry at the third word and turns the test red, while 5 leaves it green,
which is the header-read timing the field's comment describes.

nano.confirm() clicks through a screen that only asks to be acknowledged. The
SSKR flow opens on one; the BIP-39 flow reaches the keyboard through its
word-count menu instead.
@aido
aido merged commit f390284 into aido:bip85 Aug 4, 2026
8 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.

2 participants