Skip to content

Name the four things this application can do, in its menu - #157

Merged
aido merged 2 commits into
aido:bip85from
buzzromain:feat/four-intent-menu-bip85
Aug 6, 2026
Merged

aido merged 2 commits into
aido:bip85from
buzzromain:feat/four-intent-menu-bip85

Conversation

@buzzromain

Copy link
Copy Markdown

What you could not find, and now can

Backing up your recovery phrase.

Today it is reachable from exactly one place: check_result_callback() in
src/nbgl/ui.c offers it, and only when the tool was BIP39, the phrase was
well formed, and it matched the device.

if (tool_type == TOOL_TYPE_BIP39 && bip39_mnemonic_check(&seed_match) &&
    seed_match) {
    display_select_generate_sskr_page();

So to split a phrase into SSKR shares you have to open BIP39 Check, type it
in, succeed, and then accept an offer you did not ask for. Nothing in the menu
says any of that is there. Rebuilding a phrase from shares has the same shape,
behind a successful SSKR Check. On the Nano devices it is the same defect:
generating shares is the third step of the BIP-39 match flow.

The menu named formats, and the two operations worth the most in a backup tool
were the two with no entry.

before after

The icon goes with the three-entry menu. Two reasons, both measured: on apex_p
the fourth button occupies the space the icon and title used, and the icons in
this repository name formats, so Generate and Recover would both have taken
icon_sskr — which is what the BIP85 entry already wore, identical to the SSKR
one beside it.

The screen this adds

One, and it is on the path the user walks. Someone who picks Generate backup shares is about to be asked to type twenty-four words into a device that
already holds them, and nothing said why. compare_recovery_phrase() gets a
seed back from the device and never the words, so the words have to come from
the person.

It takes the place of Generate SSKR Phrase?, which sat after the verdict and
asked whether to do a thing the user had not asked for.

What replaced the arithmetic on tool_type

A fourth menu entry does not become a fourth tool_type, and the reason is not
the display code.

compare_recovery_phrase() (src/common/common_seed.c) dispatches on
tool_type to decide which buffer to derive a seed from. A fourth value falls
through both of its branches, hands 64 zero bytes to the comparison, and
reports every phrase as not matching the device — with nothing on screen to say
the derivation never ran. That is worse than the failure the verdict screen's
own comment warns about, and it is not where that comment is looking.

So tool_type keeps its three values and means "what was typed". A separate
user_intent (src/constants.h) carries the four and means "what it is being
typed for". Checking a phrase and backing one up are the same tool and
different intentions, which is exactly what the old enumeration could not
express.

That also retires the indexing the verdict screen used:

const uint8_t text_index =
    (tool_type == TOOL_TYPE_BIP39 || tool_type == TOOL_TYPE_SSKR)
        ? (uint8_t)(1 + (tool_type * 2) + seed_match)
        : 1;

into a [2][5] table, bounded by a test naming the two values that were safe.
It is a table with one row per intention now, indexed by named constants and
sized on the enumeration.

Adding a fifth intention and compiling produces two compile errors and three
-Wswitch diagnostics on the touch stack, and a third compile error on the
Nano stack — each on a line that has to decide something:

src/nbgl/ui.c:173  error: static assertion failed: the menu shows one entry per intention
src/nbgl/ui.c:353  warning: enumeration value 'USER_INTENT_FIFTH' not handled [-Wswitch]
src/nbgl/ui.c:384  warning: ...
src/nbgl/ui.c:700  warning: ...
src/nbgl/ui.c:750  error: static assertion failed: verdict_body[] has one row per intention
src/bagl/ux_nano.c:231  error: static assertion failed: the two functions below choose a flow
                               on the intention, and everything they do not name takes the
                               check flow

The row index is bounded at run time as well, and user_intent is volatile
so that the bound survives compilation. It did not, at first: with the variable
static and every write to it a constant, the compiler proves the comparison
and folds it away — _etext was byte-identical on all three touch targets with
the bound present and with it removed. What a bound is for is a byte that
changed without anyone writing it, which is the case that proof excludes. Same
reason checkpoints is volatile in compare_recovery_phrase_finish().

How the verdict behaves in each flow

The verdict is a destination when you came to check a phrase and a step on the
way when you came to back one up, so it does not say the same thing — and a
failure says least the same thing of all.

check backup
matches …matches the one present on this Ledger device. This is the recovery phrase on this Ledger device. It can be split into shares.
doesn't match …doesn't match the one present on this Ledger device. You would be backing up a phrase this Ledger cannot recover.
not well formed the same in both, advice line included the same
footer Tap to dismiss Tap to continue, and only on the match

doesn't match the one present on this Ledger device is a complete answer to
"is this my phrase?". It is half of one to "can I back this up?", where what
matters is that the shares about to be written down would restore something
this device cannot.

From there the flow is unchanged, reached without a single screen having
offered anything:

Checking no longer offers to split: the offer duplicated a menu entry, and the
screen it stood on is a destination. The share-count keypad's back arrow now
leaves for the home page, reaching reset_globals() in one gesture where the
offer took two.

What the Nano menu becomes

Three entries, not four. BIP-85 has no BAGL screen on any Nano, so a fourth
would lead nowhere. This is the one place the two stacks genuinely differ.

Check phrase        Generate            Recover
on this Ledger      backup shares       from backup

The last two say word for word what the touch buttons say. The first cannot: a
pbb step draws its two lines in an icon-flanked box 87px wide on the 128x32
Nano S, and Check recovery alone is 88px. What it does with the room it has
is worth more than the consistency it loses — on this Ledger says what the
phrase is checked against, which the touch button has no room to say at all.

The two entries these replace ended on recovery phrase, 93px against that
87px box, and had always been over it: one of the two strings the unit test
recorded as failing rather than asserting. All six new fragments are asserted,
and so are the five on the two screens the Nano flow gains.

A mismatch in the backup flow gets a second step, It would not restore this Ledger, because a pbb title has no room for the sentence the touch screen
carries in one. nanos also gains a memzero that nanox_enter_phrase.c
already had on the same branch: after a BIP-39 mismatch nothing reads the
phrase again, and this flow now puts one more screen between the verdict and
the erasure ui_idle_init() performs.

Sizes

_etext and _ebss read with arm-none-eabi-nm. Not text/data/bss from
size: on the touch targets _install_parameters sits at a fixed address and
.text runs to the end of that block, so those numbers are constants rather
than measurements.

target _etext Δ _ebss Δ
nanos c0d0a228 +408 20000c80 +4
nanox c0debd08 +416 da7a144d +4
nanos+ c0debd08 +416 da7a144d +4
stax c0df2adc +284 da7a1b4e 0
flex c0df2c28 +280 da7a1b12 0
apex_p c0df1e28 +288 da7a1b12 0

The 4 bytes of RAM on the three Nanos are the context field recording which
menu entry was chosen. Six targets, zero warnings.

The two commits are split by interface stack, which buys a claim that can be
checked with sha256 rather than by reading: after the first commit nanox
and nanos+ are byte-identical to the base
— nanos differs only in DWARF,
which it alone carries, and --strip-debug gives the same hash with .text,
.rodata, .data and .bss identical — and after the second the three
touch binaries are byte-identical to the first
.

Verification

  • The six configurations of unit-tests-matrix.yml, reported separately:
    80/80 on each.
  • Functional under Speculos: stax 19/10, flex 19/10, apex_p 19/10, nanox 13/16,
    nanos+ 13/16.
  • clang-format --dry-run -Werror clean on every touched file in src/.
  • The guard in front of the shares was proven by mutation on both stacks rather
    than by reading: removing the seed_match requirement fails exactly the test
    that claims to cover it on the touch stack, and two tests on the Nano stack.

This repository's CI does not run the functional tests on pull requests from
a fork
— workflow permissions are read-only there. That is a property of
where the branch lives rather than a fault in the change; the results above
were produced locally on the same six binaries these commits contain.

What is not held by a test

  • Nano S is not emulated at all. Its three menu entries, its two-line
    explanation step and its backup-mismatch step have never been rendered
    anywhere. They are held only by the unit test that measures each fragment
    against the layout's real pixel budget — which measures characters, not
    pixels drawn. That test's metrics are the Nano S ones applied to all three
    Nano devices: conservative, and therefore right, but by approximation.
  • There are no snapshot tests here, by decision. Every screen is held by a
    text assertion, so wording is pinned and placement is not. The menu, the
    explanation screen and the verdicts were checked by reading back the
    coordinates Speculos reports; those checks are not automated.
  • The BIP-85 row of the verdict table is empty and unreachable. Nothing
    tests it because nothing can reach it: that flow goes to
    display_generic_review() and never asks for a verdict. Left empty rather
    than filled with a plausible sentence, so a path that did arrive there would
    draw a title over an empty body instead of an answer about a comparison that
    never happened.
  • Two of the four touch entries do not stand entirely on their own.
    Check recovery phrase does not say what it is checked against, and
    Derive with BIP85 says nothing to someone who does not already know. Both
    would be the better for a line of their own, and there is no room: between
    the back button and the fourth entry Flex has 96px, of which one title line
    takes 44, and a subtitle wrapped to two lines was measured under Speculos
    drawing its second line underneath the first button — which does not clip it,
    it deletes it. The per-entry alternative does not fit either:
    nbgl_layoutAddTouchableBar() makes an entry 94px on apex_p with a one-line
    subtitle, and four come to 376px against 340px of usable height.
  • SSKR recovery still reveals the phrase without a device match, on both
    stacks: ux_sskr_nomatch_flow ends on the step that displays it, and the
    touch side gates on reconstruction rather than on seed_match. That is
    unchanged here and is presumably intended — rebuilding a phrase from its own
    shares on a device holding a different seed is the point of the feature — but
    it is the one place a secret is displayed without a match, and this change
    promotes that path from an offer behind a verdict to a menu entry.
  • wordCandidates[] in src/nbgl/ui.c is still not erased by
    reset_globals(), which clears the pointer array but not the buffer holding
    the suggested words. Pre-existing, untouched, named because this change walks
    past it.

The menu listed formats -- BIP39 Check, SSKR Check, BIP85 Generate -- and
generating a backup was not among them. It was reachable from exactly one
place, check_result_callback(), and only when the tool was BIP39, the phrase
was well formed, and it matched the device:

    if (tool_type == TOOL_TYPE_BIP39 && bip39_mnemonic_check(&seed_match) &&
        seed_match) {
        display_select_generate_sskr_page();

So splitting a phrase into shares meant opening BIP39 Check, typing it in,
succeeding, and accepting an offer nobody asked for. Rebuilding a phrase from
shares had the same shape behind SSKR Check. The two operations worth the most
in a backup tool were the two with no entry.

The four entries name what the user came to do, and a screen between the
backup entry and the keyboard says why the phrase is being asked for at all:
compare_recovery_phrase() gets a seed back from the device and never the
words, so the words have to come from the person.

A fourth entry is not a fourth tool_type, and the reason is not the display
code. compare_recovery_phrase() (src/common/common_seed.c) dispatches on
tool_type to choose which buffer to derive from; a fourth value falls through
both branches, hands 64 zero bytes to the comparison, and reports every phrase
as not matching -- with nothing on screen to say the derivation never ran. So
tool_type keeps three values and means "what was typed", and a separate
user_intent carries the four and means "what it is being typed for". Checking
a phrase and backing one up are the same tool and different intentions.

That retires the indexing the verdict screen used -- 1 + tool_type * 2 +
seed_match into a [2][5] table, bounded by a test naming the two safe values.
It is a table with one row per intention now, indexed by named constants,
sized on the enumeration and static-asserted against it. Adding a fifth
intention is two compile errors and three -Wswitch diagnostics, each on a line
that has to decide something. The row index is also bounded at run time, and
user_intent is volatile so that the bound survives: with the variable static
and every write a constant, the compiler proves the check and folds it away --
measured, _etext was byte-identical with the bound present and removed.

The verdict is a destination in the check flow and a step on the way in the
backup flow, so it does not say the same thing, and a failure least of all:
"doesn't match the one present on this Ledger device" answers "is this my
phrase?", not "can I back this up?". Only the screen that continues reads "Tap
to continue".

Checking no longer offers to split: the offer duplicated a menu entry, and the
screen it stood on is a destination. The share-count keypad's back arrow now
leaves for the home page, which reaches reset_globals() in one gesture where
the offer took two.

The menu has no icon. On apex_p a fourth button occupies the space the icon
and title used; and the icons here name formats, so Generate and Recover would
both have taken icon_sskr -- which is what the BIP85 entry already wore.

The Nano menu is unchanged by this commit: nanox and nanos+ build byte for
byte as before, and nanos differs only in DWARF, which it alone carries.
Three of them, not four: BIP-85 has no BAGL screen on any Nano, so a fourth
entry would lead nowhere. That is the one place the two stacks genuinely
differ rather than merely word things differently.

The defect and the fix are the same as on the touch devices. Splitting a
phrase into shares was the third step of the BIP-39 match flow, so it required
opening Check BIP39, typing the phrase, succeeding, and finding an offer that
arrived unasked. It is an entry of the idle menu now, and a step between it
and the length list says why the phrase is being asked for -- which needs
saying more here than there, since those words are entered one letter at a
time with two buttons.

    Check phrase        Generate            Recover
    on this Ledger      backup shares       from backup

The last two say word for word what the touch buttons say. The first cannot: a
pbb step draws its two lines in an icon-flanked box 87px wide on the 128x32
Nano S, and "Check recovery" alone is 88px. What it does with the room it has
is worth more than the consistency it loses -- "on this Ledger" says what the
phrase is checked against, which the touch button has no room to say at all.

The two entries these replace ended on "recovery phrase", 93px against that
87px box, and had always been over it: one of the two strings
tests/unit/tests/ui_strings.c recorded as failing rather than asserting. All
six fragments here are asserted, and so are the five new ones on the two
screens the flow adds.

The verdict splits in two, as it did on the other stack. Checking ends on it,
and its flow lost the step that generated shares and gained a way back to the
menu; splitting passes through it and keeps that step. A mismatch in the
backup flow gets a second step -- "It would not restore this Ledger" -- because
a pbb title has two short lines and no room for the sentence the touch screen
carries in one.

The choice between the two flows lives next to them in ux_nano.c rather than
at the two call sites: nanos_enter_phrase.c and nanox_enter_phrase.c both
reach the verdict, by different routes, and a copy in each is a third thing to
keep in step. A _Static_assert there gives this stack the guarantee the other
one already had -- src/nbgl/ui.c is compiled into no Nano target, so its
assertions said nothing here, and a new intention would have taken the else
branch in silence.

nanos also gains a memzero that nanox_enter_phrase.c already had on the same
branch: after a BIP-39 mismatch nothing reads the phrase again, and this flow
now puts one more screen between the verdict and the erasure ui_idle_init()
performs. Only that branch -- the SSKR one keeps words_buffer, which is where
the reconstructed phrase is and what recover_bip39() displays.

The three touch binaries are byte for byte what the previous commit produced.
@aido
aido merged commit 259add1 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