Skip to content

Explain each journey before it asks, and warn before it reveals - #158

Merged
aido merged 7 commits into
aido:bip85from
buzzromain:feat/reviews-and-warnings-bip85
Aug 11, 2026
Merged

aido merged 7 commits into
aido:bip85from
buzzromain:feat/reviews-and-warnings-bip85

Conversation

@buzzromain

@buzzromain buzzromain commented Aug 11, 2026 •

Copy link
Copy Markdown

Every journey now says what it is about to do before it asks for anything, and
what is about to be revealed before it reveals it. Two memory-erasure defects
found on the way are fixed.

Why

The four-intention menu gave the user a way to say what they came to do. What
followed still did not: choosing "Generate Backup Shares" led straight to a
BIP-39 keyboard, and the first thing that ever named a Share was the sheet of
them at the end. The two generating journeys jumped from the last keypad to
the result, so nobody was told that 5 shares of a 24-word phrase is 230 words
to copy by hand, and a derived secret never showed the path that reproduces
it.

What changed

Explanations before the ask. One per journey, at the point where the thing
they explain is about to be needed rather than at the top:

  • Backing up opens on what SSKR makes, and on the part that makes it a backup
    at all — keep the Shares apart. Whoever writes them all on one sheet has
    made a copy of their Recovery Phrase with none of the protection.
  • The share count and the threshold are explained on the screen before the
    keypad that asks for them. "Threshold" was a word that appeared cold.
  • Rebuilding says not all the Shares are needed and that any order works. That
    second claim was checked on a device before being printed: two shares
    entered in reverse give the same verdict.
  • Deriving says a path exists and has to be written down.

Checking a Recovery Phrase deliberately gets none. Entering the Phrase is the
task the entry asked for, so it goes straight to the length choice, the same
shape app-recovery-check has.

A review before generating. Both generating journeys now list what was
chosen, and end on the warning plus a long-press button. The BIP-85 review
shows the derivation path, built from the same values the derivation is about
to be handed, so it cannot announce a path that is not taken.

Warnings before anything secret is drawn. Rebuilt Phrases, generated
Shares and derived secrets each get a screen saying who could use what is
about to appear, and — where it matters — that it is not the Phrase this
device holds.

Two erasure defects

reset_globals() cleared buttonTexts, the array of pointers, but not
wordCandidates, where the characters live. The BIP-39 words matching the
prefix last typed stayed in memory after a journey ended.

Worse, three of the four places that clear the two-button context measured the
SSKR erase on sskr_words_buffer_length rather than on the buffer, and the
entry paths set that length to 0 without erasing. Enter shares, get "not
valid", press "Re-enter Shares" and leave: the exit path ran
memzero(buffer, 0) over a buffer still holding every ByteWord typed — up to
3664 of them. Shares are secret-equivalent; enough of them rebuild the Phrase.

Both are fixed, and every erase now covers the whole object.

The review component

nbgl_useCaseStaticReviewLight is replaced by nbgl_useCaseGenericReview.
Two measured reasons: the former discards the reject text it is given and
prints "Reject" over it, and its long-press button confirmed on a plain tap.
The same gesture, in the same test, reveals the secret under the old component
and does nothing under the new one.

Screenshots

Backing up, on Flex:

Backup journey

Checking, rebuilding and deriving, on Flex:

Checking, rebuilding and deriving

The two-button journeys, on Nano S+ — the same intentions minus BIP-85, which
has no screen on that stack:

Two-button journeys

Cost

target flash Δ RAM Δ
nanos 42 504 +896 +72
nanox 49 464 +768 +72
nanos2 49 736 +1 024 +72
stax 81 521 +4 608 +788
flex 82 061 +4 608 +788
apex_p 77 937 +4 096 +788

Measured against this branch's base. The touch figures are larger because the
explanation screens, the reviews and the warnings exist only on NBGL. RAM is
read from _ebss; the bss figure size reports is a fixed-size region and
says nothing.

Testing

Six targets build with no warnings. 82 unit tests, including three added here:
the derivation path renderer, the share arithmetic checked against a real
generated set, and a pass over every user-visible string.

That last one is two checks. One refuses a macro the table does not name, so a
string cannot be added without being measured. The other measures every
fixed-layout Nano string against the pixel box it has to fit; it exists
because strings were found clipped on device.

The functional campaign passes on the five emulated devices — stax, flex,
apex_p, nanox, nanosp. Nano S is emulated by nothing, so its two-line variants
of the explanation screens have the width test as their only check.

Journeys that reveal something now assert the refusals too: that declining a
review generates nothing, and that declining a warning reveals nothing.

reset_globals() cleared buttonTexts, which is only the array of pointers the
keyboard hands to nbgl. The characters those pointers point at live in
wordCandidates, and that was left behind -- so after a journey ended, the set
of BIP-39 words matching the prefix last typed stayed in memory with nothing
scheduled to erase it.

That is not the phrase, but it is enough to narrow one of its words, and the
rule this function applies is "everything a screen composed" rather than
"everything that is itself secret". Leaving one half of a single object behind
made the rule look like a judgement call.

The declaration moves up beside buttonTexts for the same reason: the two are
one object and reset_globals() has to be able to reach both.

textToEnter needs nothing here. Both keyboard dispatchers memzero it before
they route anywhere, so it cannot outlive the keyboard.
How long a share is on the wire, how many bytes its CBOR header takes and how
many words it becomes were computed inline, each at the point that needed
them, with the magic numbers repeated. The UI has to state the word count
before a share exists -- "each one is 29 words to write down" -- so the same
arithmetic was about to be written a fourth time, in a screen, where nothing
would check it against the generator.

So it is named once and tested:

  bolos_ux_sskr_cbor_header_length()  short form up to 23 bytes, long past it
  bolos_ux_sskr_share_length()        header + payload + CRC
  bolos_ux_sskr_share_wordcount()     what the user actually has to copy

The constants they are built from get names too, in sskr-constants.h, rather
than sitting as literals: the CRC length, the short-form bound and the two
header lengths.

The wire buffer is renamed to say what it holds -- it carries the CBOR header,
the share and the CRC, not just the share -- and is given a _Static_assert
against the maximum it has to fit.

The test does not check the arithmetic against itself. It predicts a length
and then generates a real set of shares to see whether the prediction was
right, for every phrase length the application accepts.
A derived secret is reproducible only from its path, and nothing in this
application ever showed one. The user chose an index, saw a secret, and had
nothing to write down that would bring it back.

bip85_path_format() turns a built path into "m/83696968'/39'/0'/24'/42'". It
takes the array the derivation itself was handed, so the rendering cannot
drift from what was derived: the three per-application formatters sit beside
the three derivations, and each builds its path with the same helper the
generator calls.

It refuses rather than truncates. A path cut short is a path that does not
lead back to the secret, and one that looks plausible is worse than none, so
running out of room writes an empty string and returns false. The caller shows
nothing.

Written without snprintf(): its return value is unusable on one of the SDKs
this builds for, which is already why seed_bip85.c formats by hand elsewhere.

The test covers the three applications, the hardened marks, the index and
length actually reaching the path, and the refusal -- against a buffer one
byte short of every path it builds.
Three gaps, all of them in the same place: what the screens say.

Explain each journey before it asks for anything. Backing up opens on what
SSKR makes and what has to be done with it -- splitting a Phrase into Shares
is only a backup if the Shares are kept apart, and nothing said so. The share
count and the threshold are explained on the screen before the keypad that
asks for them, not at the top of the journey where neither is relevant yet.
Rebuilding says that not all the Shares are needed and that any order works,
which is what decides someone who has lost a sheet; that claim was checked on
the device before it was printed. Deriving says a path exists and must be
written down.

Checking a Recovery Phrase gets no such screen, deliberately. Entering the
Phrase is the task the menu entry asked for, so it goes straight to the length
choice, as app-recovery-check does.

The carry-on control is a grey "tap to continue" rather than a black button.
Black is what this application uses for an act with a consequence, and reading
is not one; the two screens that lead to entering a Phrase or Shares keep it.

Review the parameters before generating. Both generating journeys jumped from
the last keypad straight to the result: nobody was told that 5 shares of a
24-word phrase is 230 words to hand-copy, and no derived secret ever showed
the path that reproduces it. The review lists what was chosen, and its last
page carries the warning and the long-press button that performs the act.

That review is nbgl_useCaseGenericReview rather than the lighter one it
replaces, for two measured reasons: the lighter one discards the reject text
it is given and prints "Reject" over it, and its long-press button confirmed
on a plain tap.

Warn before anything secret is drawn. Rebuilt Phrases, generated Shares and
derived secrets each get a screen that says who could use what is about to
appear -- and, where it matters, that it is not the Phrase this device holds.

Every user-visible string is a named macro in one header, with the reason it
is worded that way beside it, and the Nano ones carry the pixel budget of the
box that draws them. Two unit tests hold that file honest: one refuses a macro
the table does not name, the other measures every fixed-layout string against
the width it has to fit. Both were written after strings were found clipped on
device.

The functional suite gains helpers for the three new shapes and tests for the
refusals -- that declining a review generates nothing, that declining a
warning reveals nothing.
Three of the four places that clear the two-button context measured the SSKR
erase on sskr_words_buffer_length instead of the buffer's own size, and the
entry paths set that length to 0 without erasing anything. Together those make
an erase that erases nothing:

  enter shares -> "SSKR Shares are not valid" -> "Re-enter Shares" -> leave

"Re-enter Shares" reaches the first-word branch, which zeroes the length and
leaves the bytes. Leaving then reaches ui_idle_init(), which ran
memzero(sskr_words_buffer, 0) over a buffer still holding every ByteWord that
had been typed -- up to 3664 of them, 1603 on Nano S.

Shares are secret-equivalent: enough of them rebuild the Recovery Phrase.

Fixed in all four: ui_idle_init(), clean_exit() and both keyboard
dispatchers now erase sizeof(buffer), and the entry paths erase both buffers
whole before zeroing the lengths.

Whether the bytes survive the application being unloaded depends on the OS
clearing app RAM, which is not verified here. The erase is owed either way.
The two-button stack had two of the six explanation screens the touch stack
has, and the two it was missing were the ones a reader acts on.

"Select / threshold" was the only place the word appeared on this stack and
nothing defined it. The review further on shows "any k of n", which
demonstrates the relationship but arrives after the choice has been made. A
screen now says what a threshold is immediately before the menu that asks for
one.

The Backup journey never said what a Share is. A reader typed twenty-four
words and reached a share count having been told only why the Phrase was
wanted. The new first screen names what the journey makes -- and says the part
that makes it a backup at all: keep the Shares apart. Whoever writes them all
on one sheet has made a copy of their Recovery Phrase with none of the
protection.

Not added: the words-per-share count. The touch stack states it before the
choice; this one already states the total in its review, so a third screen
would have repeated it.

Both screens have a two-line variant for Nano S, and so do the two that
existed. Those two used to drop their third line on that target, which cost
"Enter it to split it." and "Any order works." -- the second being the line
that stops someone hunting for the Share numbered 1, and the only claim on
these screens that was checked on a device before being printed. Tightening
keeps both facts in two lines.

Nano S is emulated by nothing, so the unit test that measures every
fixed-layout string against the 116px box it has to fit is the only check
those variants will ever get. All ten new strings are held to it.
The Backup journey gained a step and this test walked past it: it selected the
menu entry and asserted the "why the Phrase" lines, which are now the second
screen rather than the first.

Asserting both, in order, is what the journey is worth testing for. Walking
straight to the second would pass on a build that had lost the first, which is
the screen naming what the journey produces.
@aido
aido merged commit 1b73d66 into aido:bip85 Aug 11, 2026
8 of 10 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