Skip to content

fix: name the values each entry screen accepts, and return a refusal to the field - #156

Merged
aido merged 2 commits into
aido:bip85from
buzzromain:fix/entry-bounds-and-return-to-field-bip85
Aug 6, 2026
Merged

aido merged 2 commits into
aido:bip85from
buzzromain:fix/entry-bounds-and-return-to-field-bip85

Conversation

@buzzromain

Copy link
Copy Markdown

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.py asserted wait_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 of nbgl_useCaseStatus() is the return callback.

Refusal Returned to Returns to
share count out of range display_select_generate_sskr_page display_sskr_select_numshares_page
threshold < 1 display_select_generate_sskr_page display_sskr_select_threshold_page
threshold > share count display_select_generate_sskr_page display_sskr_select_threshold_page
threshold 1-of-m display_select_generate_sskr_page display_sskr_select_threshold_page
BIP85 index out of range display_bip39_select_phrase_length_page display_bip85_select_index_page
BIP85 password length display_bip85_select_app_page display_bip85_select_password_length_page

The 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() 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 from display_home_page() and so from 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.

What the screens now announce

Four entry screens; one already named its range, three did not.

Screen Title Where the bound comes from
share count Enter number of SSKR shares\nto generate (1 - 16) — unchanged SSS_MAX_SHARE_COUNT, now also what the validator compares against, with a _Static_assert tying the literal to it
threshold Enter threshold value (%d - %d) sskr_threshold_min() and sskr_sharenum_get() — the calls the validator makes
BIP85 index Enter index (0 - 9,999,999) the keypad's own digit cap, the number the error message already gave
BIP85 password length Enter password length\n(%d - %d) one accessor, read by the title, the validator and the error message

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 against sskr_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 16 where the title said (1 - 16); it now compares against SSS_MAX_SHARE_COUNT, and a _Static_assert fails 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 %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 _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 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 and drew (20 - on one line and 86) 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 sha256 on nanos, nanox and nanos+ against this PR's base. Only src/nbgl/ and src/common/ui_strings.h are 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 from sskr_descriptor_label() (src/bagl/ux_sskr_menu.c), bounded by sskr_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-size reports .text unchanged on the three touch targets, and that number cannot move: _install_parameters sits at a fixed address on these targets (0xc0df2a00 on stax, before and after), and .text as 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:

Target _etext _ebss
nanos unchanged unchanged
nanox unchanged unchanged
nanos+ unchanged unchanged
stax +276 +32
flex +220 +32
apex_p +220 +32

The RAM is the two static buffers. A sorted strings diff 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.c is in no unit target. test_sskr_unsupported_values.py is 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.py gained the same shape for the password length, and both password tests assert their own range, which differ (20 - 86 against 10 - 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 to re.match(), where an unescaped (2 - 3) is a group and would match a screen reading Enter threshold value 2 - 3 instead.

Unit — tests/unit/tests/ui_strings.c 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 rather than substrings: a substring search passes on exactly the half that matters, since lowering the keypad cap to six digits still finds 999999 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.

Verification run: six unit configurations 80/80 each; six targets built without warnings; Speculos on stax 12 passed / 6 skipped, flex 12/6, apex_p 12/6, nanox 9/9, nanos+ 9/9; clang-format --dry-run -Werror clean.

What no test holds

  • The BIP85 index refusal. 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. Corrected as a guard, not as a lived path; no functional test can drive it.
  • The dangling-pointer fix. Speculos has no system UX takeover, so it never produces the redraw that would have read the dead stack frame. The chain is established by reading the SDK — pointer stored, object tree walked on redraw — not by reproducing it.
  • The Nano S. No emulator covers it. It is in the build matrix and is byte-identical here, which is the whole of what can be said.
  • Functional CI. This repository's workflows do not run the functional suite on pull requests from a fork — the workflow token is read-only there. The runs above were made locally under Speculos.

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.
@aido
aido merged commit eb46bc9 into aido:bip85 Aug 6, 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