Skip to content

Derive a PIN, and give the BIP-85 secrets a list to be chosen from - #159

Merged
aido merged 6 commits into
aido:bip85from
buzzromain:feat/bip85-pin-bip85
Aug 11, 2026
Merged

aido merged 6 commits into
aido:bip85from
buzzromain:feat/bip85-pin-bip85

Conversation

@buzzromain

Copy link
Copy Markdown

A PIN can now be derived from the seed, as BIP-85's DICE application with a
ten-sided die. The list of secrets it joins is rebuilt on the SDK's own list
component, because a fourth hand-drawn button did not fit.

Why

BIP-85 defines DICE, and a PIN is what most people would actually use it for:
PIN(length, index) = DICE(sides = 10, rolls = length, index = index), which
is m/83696968'/89101'/10'/{length}'/{index}'. Each roll of a ten-sided die
is a decimal digit, so the digits are the rolls, in order, untouched — and
that is the whole point. A PIN that went through a hash, a modulo or an
integer on the way to the screen could not be derived again by any other
implementation of the specification, which is the only reason to derive one
rather than invent one.

The device already had bolos_ux_bip85_dice(). It had no caller, no path
formatter, and no way to reach it.

What changed

PIN is a preset, not a primitive. The application type recorded is DICE,
which BIP-85 defines; a separate field records what the derivation is being
used for. That keeps a fifth secret — generic rolls, any number of sides —
from having to fight the label "PIN" for the same enumeration slot, and it is
why the review takes its Application row from the use rather than from the
list's own table.

Nothing is derived before the long press, and nothing partial is ever
drawn.
The derivation reports how many rolls it actually produced — the DRNG
stream can run out under rejection sampling — so that count is compared
against what was asked for before a digit reaches a screen. Anything else
erases both buffers and says so. A PIN one digit short looks exactly like a
PIN, which makes it the one failure a user cannot see.

Leading zeros are kept, because they are digits and not the absence of
one: four rolls at index 1 of the specification's seed are 0, 9, 3, 4, and
that PIN is 0934. Nothing converts to an integer anywhere.

The length is asked with three buttons, not a keypad. 4, 6 or 8 digits is
a choice; a keypad would put a number pad in front of someone about to be
shown a number, with a range error waiting behind every other value they
could type.

The roll count gets its own field rather than sharing the existing length,
which already means "how much data the app buffer holds" and is what the
password screens collect. A third meaning would be read by whichever screen
asked first — and the review builds the path it announces out of exactly these
fields.

Which BIP85 secret
How many digits

The secret list is the SDK's own now. Four buttons stacked upward from the
bottom of the screen did not fit: the fourth reached into the title, and Flex
drew "PIN" across the second line of the question while clipping the first.
Nothing in a test could see it — every text event is still reported at full
height, with its full text, so every assertion on the wording passes.
nbgl_useCaseGenericConfiguration() draws a titled header with a back arrow
and one touchable bar per secret, and it paginates itself: the fifth entry is
an entry rather than another silent overlap. Each bar carries its own token,
because the index the SDK reports is a position on the page it drew, which
stops meaning "which secret" the moment the list paginates.

The same screen, on Stax and Apex:

Stax
Apex

The review shows the path before anything is derived, built by the
formatter that sits beside the derivation and takes the same arguments, so the
screen cannot announce a path the derivation does not walk. The warning names
a PIN rather than "a secret": these digits open whatever they open.

The review
The warning

The result carries its path in the label, folded in rather than given a
pair of its own, so a page break can never separate a secret from the only
thing that reproduces it:

The PIN

A stack cost, written where it is paid

bip85_dice_roll() allocates 2 048 bytes of stack for its digest, whichever
attempt is reached and whether four rolls are asked for or five hundred.
Measured free stack at the deepest point of a derivation: 1 848 bytes on
nanos, against 23 403 on nanox, 35 691 on nanos2, 29 086 on stax, 29 146 on
flex and 33 242 on apex_p. Only nanos does not fit, and it does not fit by a
margin no rearrangement of that function closes.

Nothing is exposed there today — BIP-85 has no screen on the two-button stack,
and its entry points are not even compiled for it — so this is a note for
whoever adds one, in the function that would overflow. Speculos dropped nanos,
so no emulator would show it.

Cost

target flash Δ RAM Δ
nanos 42 696 +0 2 760 +0
nanox 49 720 +0 5 269 +0
nanos2 49 736 +0 5 269 +0
stax 83 569 +2 048 7 850 +72
flex 84 109 +2 048 7 790 +72
apex_p 79 473 +1 536 7 790 +72

Measured against this branch's base, six clean builds each. The two-button
targets pay nothing: the new helpers compile there but nothing calls them, so
the linker drops them. RAM is _ebss - _bss; the bss figure size reports
is a fixed-size region and says nothing.

Testing

Six targets build with no warnings. 83 unit tests, one file added: the
rolls-to-digits rendering, pinned on the properties that have no other symptom
— leading zeros kept, a short buffer refused whole with nothing written, a
roll above 9 refused before any digit reaches the buffer. The DICE path is
pinned against the decimal numbers in the specification rather than against
what the builder happens to produce.

The functional campaign passes on the five emulated devices: 32 tests on stax,
flex and apex_p, 13 on nanox and nanosp. Eight of those are new and walk the
PIN journey to the digits and to nothing at all — the refusal asserted by the
destination rather than by an absence, since a screen that failed to draw
would satisfy "the PIN is not on screen".

The vectors were derived offline with bipsea from the seed BIP-85 itself
publishes, and checked both ways: the same oracle reproduces the
specification's own six-sided dice vector and the mnemonic the BIP39 test
already expects, so it agrees with this repository everywhere the two can be
compared before being asked for anything new. 039262, 0934 and 61615716
are what the device draws.

Also in this branch: the style commit from the previous pull request, which
was merged while it was being written and so missed the two checks it fixes.

…ct style

Two checks this branch was failing.

The first refuses a string conversion anywhere under src/, because that is how
a secret leaks through a trace statement, and it is enforced by grep rather
than by reading -- so it holds for a screen's format string exactly as it does
for a PRINTF. Two labels were composed with one: the BIP-85 result header,
which joins a header and a derivation path, and the review's application
value, which appends a language.

Both are built by appending now, through a bounded helper whose result is
checked at each step. That check is not decoration: the result header carries
the derivation path, and a path silently cut short does not lead back to the
secret, so a label that cannot be composed whole is not shown at all.

The second is clang-format. The project sets a 100-column limit and aligns
consecutive macros; these two files were written to 80 and unaligned. Only
alignment and continuation lines move -- no comment is reflowed.
Two pieces the DICE application was missing, both of them pure arithmetic and
both of them what a PIN is made of.

The path formatter is the fourth of a set of three. Every other application
has one that sits beside its derivation and takes the same arguments, so that
the path a review displays and the path the derivation walks come from one
builder call; DICE had the builder and no formatter, which left the only way
to show its path being to assemble the components again somewhere else. A
BIP-85 path wrong in one component still derives a perfectly well-formed
secret -- just not the one on screen, and with nothing to say so.

bip85_dice_rolls_to_digits() is the whole of what turns DICE into a PIN: with
a ten-sided die each roll is a decimal digit, so the digits are the rolls,
written down in order. Nothing is hashed, reduced or folded, because anything
that were could not be reproduced by another implementation of the
specification -- which is the only reason to derive a PIN rather than invent
one. It writes characters and never converts to an integer: strtol("0934") is
934, and a leading zero lost that way leaves a four-digit PIN looking like a
three-digit one. Everything is checked before anything is written, so a
refusal leaves an empty string rather than the digits that were valid before
the one that was not.

The stack cost of bip85_dice_roll() is now written where it is paid. Its
digest buffer is 2048 bytes, allocated whether four rolls are asked for or
five hundred, and the measured free stack at the deepest point of a
derivation is 1848 bytes on nanos against 23403 or more everywhere else. The
application is safe today only because BIP-85 has no screen on the two-button
stack at all: the function is compiled on all six targets and called from
none of them but the touch flow. Speculos dropped nanos, so nothing would
show the overflow -- whoever exposes DICE there has to shrink the buffer
first, rather than only adding a screen.

The unit tests pin the DICE path against the decimal numbers in the
specification and the digits against the properties that have no other
symptom: leading zeros kept, a short buffer refused whole, a roll above 9
refused before any digit is written.
A PIN is offered as a fourth secret to derive, and it is not a fourth
primitive: PIN(length, index) is DICE(sides = 10, rolls = length, index),
path m/83696968'/89101'/10'/{length}'/{index}'. That is what the review shows
before anything is derived, and it is what lets the same digits be derived
again by any other implementation of the specification. The enumeration
records DICE, which BIP-85 defines, and a separate field records the use it
is being put to -- the PIN today, generic rolls later -- so that the label on
a screen never becomes the thing the derivation is named after.

The roll count gets a field of its own rather than sharing the existing
length. That variable already means "how much data the buffer holds" and is
what the password screens collect; a third meaning would be read by whichever
screen asked first, and the review builds the path it announces out of
exactly these fields.

Nothing is derived before the long press, and nothing partial is ever drawn.
The derivation reports how many rolls it actually produced -- the DRNG stream
can run out under rejection sampling -- so the count is compared against what
was asked for before a digit reaches a screen; anything else erases both
buffers and says so. A PIN one digit short looks exactly like a PIN, which
makes it the one failure the user cannot see.

The length is asked with three buttons rather than a keypad: 4, 6 or 8 digits
is a choice, and a keypad would put a number pad in front of someone about to
be shown a number, with a range error waiting behind every other value.

The secret list is the SDK's own now. Four hand-drawn buttons stacked upward
from the bottom of the screen did not fit: the fourth reached into the title,
and Flex drew "PIN" across the second line of the question while clipping the
first. Nothing in a test could see it -- every text event is still reported at
full height with its full text. nbgl_useCaseGenericConfiguration() draws a
titled header with a back arrow and one touchable bar per secret, and it
paginates itself, so the fifth entry is an entry rather than another silent
overlap. Each bar carries its own token: the index the SDK reports is a
position on the page it drew, which stops meaning "which secret" the moment
the list paginates.

The list reads top-down where the buttons read bottom-up, so the functional
tests that walked it now use the SDK's list component and count from the top.
The constants naming those positions sit beside the menu's own, for the same
reason: a bare number on a screen is a claim nothing else checks.
The journey end to end on the three touch devices, compared against vectors
derived offline with bipsea from the seed BIP-85 itself publishes -- the same
seed the BIP39 test already uses, and the same oracle that reproduces both
the specification's six-sided dice vector and the mnemonic that test expects.
So the oracle agrees with this repository everywhere the two can be compared
before being asked for anything new.

Four digits at index 1 derive 0934, and that case is why the file exists: a
PIN read through an integer anywhere would draw "934" and look perfectly
healthy doing it. Eight digits at index 3 are there because the roll count
and the index sit in adjacent path components, so a flow that swapped them
would still produce eight perfectly good digits.

The other half is that refusing produces nothing, and it is asserted by the
destination rather than by an absence: a screen that failed to draw for an
unrelated reason would satisfy "the PIN is not on screen", while arriving
home is only possible through the branch that erases and goes home. Leaving
the length screen and the index keypad both return to the secret list, which
is what asked the question; closing the result and walking the journey again
finds nothing carried over.

Values are read off the screen with the wrapping removed rather than matched
against one drawn line. A derivation path has no space in it, so it wraps on
characters -- Flex draws it as two events -- and wait_for_text_on_screen()
matches with re.match(), which turns the parentheses of "PIN (Index #0)" into
a group that matches something else.
The fuzzing build stopped configuring the moment the builder image was
republished: SeedParsers.cmake named `unit-tests/mock/src/cx_crc.c` inside the
SDK, and that directory is gone from it. Every ClusterFuzzLite job now fails
at CMake generate, on a file no commit in this repository ever touched.

That file was there because the SDK's own cx_crc32() computes nothing -- it
delegates to the cx_crc_hw() syscall, so a host build needs a stand-in, and
using the SDK's own was preferred over writing a third CRC-32. With it gone,
the remaining choice is between writing that third implementation and using
the one this repository already has, and the second is better: the cmocka
suite and the fuzzers then agree on the checksum by construction rather than
by review.

So the fuzzers link tests/unit/lib/bolos/cx_crc.c, and cx_crc32() joins the
other host stand-ins in extra/host_syscalls.c -- byte-for-byte what
tests/unit/lib/testutils.c defines for the cmocka build, which is the rule
that file already states for the three stand-ins it carries.

Linking that file needed one more thing: it included cx.h, which reaches
cx_ed25519.h, which includes <openssl/bn.h>. A CRC-32 depending on OpenSSL is
invisible to the cmocka suite, which builds OpenSSL anyway, and fatal to the
fuzzer build, which deliberately does not -- the whole reason SeedParsers.cmake
leaves the big-number stand-in out. The include is now cx_crc.h, which is what
those two functions actually need.

Verified in the image ClusterFuzzLite builds: the four targets compile, link
and run, and the unit suite still passes.
bip85_password_length_set() and bip85_get_get() are declared in bip85_app.h
and defined nowhere, in this repository or outside it. Nothing calls them
either, so neither has ever been more than a line of a header: a promise the
compiler was never asked to check and the linker never had to keep.

The second one is also a rename that stopped halfway -- "bip85_get_get" is
not a name, and the comment above it says "Gets the BIP85 child password
length", which is bip85_length_get() one screen down and already used.

A header is the part of a module a caller is entitled to believe. Two entries
of this one described functions that do not exist, and the only way to find
that out was to go looking for their definitions.
@aido
aido merged commit f8f0f8a into aido:bip85 Aug 11, 2026
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