fix: bound the two-button entry writes, and split the field they depend on - #143
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 inux_nano.hand the write checks against it.The mnemonic buffer.
bolos_ux_bip39_idx_strcpy()writes a word and a terminator atwords_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, andbip39_typeheld 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 toSSKR_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 reassignsbip39_typebeforescreen_onboarding_bip39_restore_init(), so a BIP-39 entry never starts on a share length;generate_sskr()is reachable only fromux_bip39_match_flow; after an SSKR check the only offer isrecover_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_countnow carries the share length.bip39_typeis 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 abip39_typethat 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 withREENTER_WORDprecisely 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.pyenters the sharestest_sskr_128bit.pyenters, 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()behindthem, 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, itsrandomness, and the CBOR and ByteWords encoding of every share.
tests/functional/test_sskr_generate_two_button.pycovers it. The sharescannot 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 gyrothe touch tests assert. Changing one byte of thattag 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 thelast step changes nothing and
navigate_until_text()reports that as a stuckscreen; and
choose_in_carousel()forUX_STEP_MENULIST, which shows severalentries and selects one, so "3 is on the screen" is true while "1" is the entry
that would be validated.
Verification
clang-format --dry-run --Werrorclean oversrc/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.