fix: name the values each entry screen accepts, and return a refusal to the field - #156
Merged
aido merged 2 commits intoAug 6, 2026
Merged
Conversation
Four screens ask for a number and refuse it if it is out of range. Only one of the four said what the range was; the other three left it to be discovered from the status screen that came after pressing enter. Two of the four cannot write their range into the string, because it is not the same range every time. The threshold's depends on the share count entered on the previous screen, the password length's on which BIP85 application was chosen. Those two titles are composed at display time from the very calls their validator makes -- one getter for the share count, one accessor for the password pair, replacing the two ternaries only the validator could see. An announced bound the code does not apply is worse than no bound at all, so the point is that there is one read, not two copies that agree today. The threshold's floor is part of that, and is not 1. A threshold of 1 over more than one share means any single share rebuilds the secret, which this screen has always refused; a title reading "(1 - 3)" would have promised a value the next screen rejects. sskr_threshold_min() returns that floor, the title announces it, and the check is written against it rather than against an open-coded `== 1 && sharenum > 1`, so the two cannot say different things. Three shares now offer "(2 - 3)", one share still offers "(1 - 1)". The other two ranges stay literals, since nothing about them varies, and are pinned instead: the share-count validator compares against SSS_MAX_SHARE_COUNT rather than a literal 16, with a _Static_assert failing the build if that constant stops being the number the title and the error message spell out. The index title takes the keypad's own digit cap, which is the number the error message beside it already gave and the largest value that can be typed. The composed titles share one static buffer. nbgl_layoutAddKeypadContent() keeps the pointer it is handed rather than the text, and redraws that area while the keypad is up, so a stack buffer would dangle. Its size is derived from the format literals: every %d stands for a value of at most two digits and is itself two characters wide, so a composed title is never longer than its own format. Every one of those values is asserted at compile time, minima included -- a three-digit minimum would truncate a title in silence while an assertion on the maxima alone stayed happy. The error message built beside them was on the stack, and had the problem the title buffer exists to avoid: nbgl_useCaseStatus() stores the pointer, nbgl_layoutAddCenteredInfo() puts it in the text area, and the status page outlives the function by three seconds. It is static now. It is the only status message in the file that is composed rather than a literal. The line break in the password title is placed rather than left to the layout. Without it, all three touch devices wrapped inside the range itself and drew "(20 - " on one line and "86)" on the next. BAGL is untouched: the three Nano binaries are byte-identical. Their share-count and threshold menus are bounded lists, so no value out of range is representable and no title has a range to announce; BIP85 has no BAGL screen on any Nano. The unit test gains two cases, both run in their failing state. That the two literal bounds name the constant that decides them -- matched as whole runs of digits, not as substrings, because a substring search passes on exactly the half that matters: lower the keypad cap to six digits and "999999" is still found inside the title's "9999999", so a title promising ten times what the keypad accepts would go through. And that each composed format still takes exactly the arguments its call site passes, since a title reworded without its %d would silently drop the bound it was changed to show.
Enter 3 shares, then a threshold of 4. The threshold is refused, correctly, and the application returns to "Generate SSKR Phrase?" -- the first screen of the flow. The share count that was just accepted is discarded along with the value that was not, and has to be typed again before the threshold can be corrected. All four SSKR refusals did this, and the two BIP85 ones returned to screens that were not the field either: the password length went back to the application menu, and the index went back to the BIP39 phrase-length screen, which is not even on the branch of the flow that reaches it. Each of the six now returns to the screen carrying the field at fault: share count out of range -> display_sskr_select_numshares_page threshold < 1 -> display_sskr_select_threshold_page threshold > share count -> display_sskr_select_threshold_page threshold 1-of-m -> display_sskr_select_threshold_page BIP85 index out of range -> display_bip85_select_index_page BIP85 password length -> display_bip85_select_password_length_page Nothing on these paths preserves state the code assumed reset. The threshold screen reads the share count through sskr_sharenum_get(), whose only writer is its own validator, and that screen is reachable only from the validator's success branch or from its own refusals -- so the count it displays and compares against has always been through validation. reset_globals() and sskr_shares_reset() are reached from display_home_page(), review_done() and the check flow, none of which any of the six branches goes through. Reaching the erase path does get longer, and that is worth stating rather than leaving as a side effect. After a refusal, "Cancel" on the offer screen was one tap away, and it leads to display_home_page() and so to reset_globals(); it is now two back arrows and a Cancel. The phrase the user has just entered word by word is in RAM throughout. Nothing bypasses the erase -- none of the three old targets called reset_globals() either -- but the distance to it changed. The index refusal is unreachable from its keypad, which caps entry at seven digits, so the largest value that can arrive is 9,999,999 and the 31-bit bound it guards cannot be crossed from the screen. It is corrected as a guard, not as a lived path, and no test can drive it. This does not make any refusal impossible. A numeric keypad accepts any one- or two-digit number, and bounding what can be entered means replacing the widget; the 1-of-m refusal would survive that anyway, since 1-of-1 is legitimate. The three threshold status screens still exist. What changed is that being refused no longer costs the field before it. The functional test that pinned the old behaviour is rewritten around the new one. It enters the share count exactly once and never again, walks the three threshold refusals, and asserts after each that the screen is the threshold keypad still announcing (2 - 3) -- which is at the same time the evidence that the count survived -- before generating a 2-of-3. Each refusal is waited for on its own before its field is, so arriving at the field is a screen change rather than the screen already displayed. The password-length refusal gets the same shape. Every announced range is matched as a whole screen event, so a layout that wrapped inside one would fail the test rather than ship. Assertions carrying a range escape their parentheses: wait_for_text_on_screen() hands its argument to re.match(), where an unescaped "(2 - 3)" is a group and would match a screen reading "Enter threshold value 2 - 3" instead. BAGL is untouched, and was never affected on the two SSKR screens: its share-count and threshold menus are bounded lists, so neither an out-of-range count nor a threshold above it is representable and there is no status screen to return from. Its 1-of-m refusal does exist, on ux_threshold_warn_flow, and is out of scope here. BIP85 has no BAGL screen at all.
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.
What the user had to redo, and no longer does
Enter 3 shares, then a threshold of 4. The threshold is refused — correctly — and the application returns to "Generate SSKR Phrase?", the first screen of the flow. The share count that was just accepted is thrown away along with the value that was not, and has to be typed again before the threshold can be corrected.
That was true of all four SSKR refusals. The test in this repository encoded it:
test_sskr_unsupported_values.pyassertedwait_for_text_on_screen("Generate SSKR")after each of six refusals, and re-entered the share count every time.The same sequence now — 3 shares, threshold 4, then threshold 2 — produces a 2-of-3 without the share count being typed a second time.
Where each refusal went, and where it goes
All six are in
src/nbgl/ui.c; the second argument ofnbgl_useCaseStatus()is the return callback.display_select_generate_sskr_pagedisplay_sskr_select_numshares_pagedisplay_select_generate_sskr_pagedisplay_sskr_select_threshold_pagedisplay_select_generate_sskr_pagedisplay_sskr_select_threshold_pagedisplay_select_generate_sskr_pagedisplay_sskr_select_threshold_pagedisplay_bip39_select_phrase_length_pagedisplay_bip85_select_index_pagedisplay_bip85_select_app_pagedisplay_bip85_select_password_length_pageThe index one was returning to a screen that is neither the field at fault nor on the branch of the flow that reaches it.
Nothing on these paths preserves state the code assumed reset. The threshold screen reads the share count through
sskr_sharenum_get(), whose only writer is its own validator, and that screen is reachable only from the validator's success branch or from its own refusals — so the count it displays and compares against has always been through validation.reset_globals()andsskr_shares_reset()are reached fromdisplay_home_page(),review_done()and the check flow, none of which any of the six branches goes through.Reaching the erase path does get longer, and that is worth stating rather than leaving as a side effect. After a refusal,
Cancelon the offer screen was one tap fromdisplay_home_page()and so fromreset_globals(); it is now two back arrows and aCancel. The phrase the user has just entered word by word is in RAM throughout. Nothing bypasses the erase — none of the three old targets calledreset_globals()either — but the distance to it changed.What the screens now announce
Four entry screens; one already named its range, three did not.
Enter number of SSKR shares\nto generate (1 - 16)— unchangedSSS_MAX_SHARE_COUNT, now also what the validator compares against, with a_Static_asserttying the literal to itEnter threshold value (%d - %d)sskr_threshold_min()andsskr_sharenum_get()— the calls the validator makesEnter index (0 - 9,999,999)Enter password length\n(%d - %d)The threshold's floor is not 1. A threshold of 1 over more than one share means any single share rebuilds the secret, and this screen has always refused it. A title reading
(1 - 3)would promise a value the next screen rejects, so the floor is announced as well as applied: three shares offer(2 - 3), one share still offers(1 - 1). The check is written againstsskr_threshold_min()rather than an open-coded== 1 && sharenum > 1, so the announced floor and the applied floor cannot say different things.The share-count validator compared against a literal
16where the title said(1 - 16); it now compares againstSSS_MAX_SHARE_COUNT, and a_Static_assertfails the build if that constant stops being the number the two strings spell out. The password bounds were two ternaries inside the validator, invisible to the title; they are four named constants and one accessor.The two composed titles share one static buffer.
nbgl_layoutAddKeypadContent()keeps the pointer it is given (textArea->text = title,lib_nbgl/src/nbgl_layout_keypad.c) and redraws that area while the keypad is up, so a stack buffer would dangle. Its size is derived from the format literals rather than counted: every%dstands for a value of at most two digits and is itself two characters wide, so a composed title is never longer than its own format. Every one of those values is_Static_asserted, minima included — a three-digit minimum would truncate a title in silence while an assertion on the maxima alone stayed happy.The error message built beside them was on the stack, and had exactly the problem the title buffer exists to avoid:
nbgl_useCaseStatus()stores the pointer,nbgl_layoutAddCenteredInfo()puts it in the text area, and the status page outlives the function by three seconds. It isstaticnow. It is the only status message in the file that is composed rather than a literal.The line break in the password title is placed rather than left to the layout: without it, all three touch devices wrapped inside the range and drew
(20 -on one line and86)on the next.Unenterable, or only cheaper?
Cheaper and announced in advance — not unenterable. A numeric keypad accepts any one- or two-digit number; bounding what can be entered means replacing the widget. The 1-of-m refusal would survive that anyway, since 1-of-1 is legitimate. The three threshold status screens still exist. What changed is that the range is visible before the keypad is used, and being refused no longer costs the field before it.
BAGL is unaffected, and why
The three Nano targets are byte-identical before and after — same
sha256onnanos,nanoxandnanos+against this PR's base. Onlysrc/nbgl/andsrc/common/ui_strings.hare touched, and every string changed is an NBGL-only macro.They were never affected on the two SSKR screens. The Nano share count and threshold are chosen from
UX_STEP_MENULISTs whose entries come fromsskr_descriptor_label()(src/bagl/ux_sskr_menu.c), bounded bysskr_descriptor_count()for the count and by the already-chosen count for the threshold — so an out-of-range value is not representable and there is no status screen to return from. BIP85 has no BAGL screen at all, on any Nano.One nuance rather than a clean sweep: BAGL does keep a 1-of-m refusal (
ux_threshold_warn_flow,src/bagl/ux_sskr.c). Two of the three threshold refusals are impossible there; the third is not. It is out of scope here — this change is touch-only.Sizes
arm-none-eabi-sizereports.textunchanged on the three touch targets, and that number cannot move:_install_parameterssits at a fixed address on these targets (0xc0df2a00on stax, before and after), and.textas reported spans to the end of that block. Adding 130 bytes of unused string literal to check left it at 76385 as well. The section end symbols are the honest figures:_etext_ebssThe RAM is the two static buffers. A sorted
stringsdiff on the touch targets shows the changed titles, the(%d - %d)tail of the password format, and the new symbol names — nothing else of substance.Tests
Functional, which is where this change lives —
src/nbgl/ui.cis in no unit target.test_sskr_unsupported_values.pyis rewritten: it enters the share count exactly once and never again, walks the three threshold refusals, asserts after each that the screen is the threshold keypad still announcing(2 - 3)— which is at the same time the evidence that the count survived — and finishes by generating a 2-of-3. Each refusal is waited for on its own before its field is, so arriving at the field is a screen change rather than the screen already displayed.test_bip85_pwd_base64.pygained the same shape for the password length, and both password tests assert their own range, which differ (20 - 86against10 - 80).Every announced range is matched as a whole screen event, so a layout that wrapped inside one would fail the test rather than ship. Assertions carrying a range escape their parentheses:
wait_for_text_on_screen()hands its argument tore.match(), where an unescaped(2 - 3)is a group and would match a screen readingEnter threshold value 2 - 3instead.Unit —
tests/unit/tests/ui_strings.cgains two cases, both run in their failing state. That the two literal bounds name the constant that decides them, matched as whole runs of digits rather than substrings: a substring search passes on exactly the half that matters, since lowering the keypad cap to six digits still finds999999inside the title's9999999, so a title promising ten times what the keypad accepts would go through. And that each composed format still takes exactly the arguments its call site passes, since a title reworded without its%dwould silently drop the bound it was changed to show.Verification run: six unit configurations 80/80 each; six targets built without warnings; Speculos on
stax12 passed / 6 skipped,flex12/6,apex_p12/6,nanox9/9,nanos+9/9;clang-format --dry-run -Werrorclean.What no test holds