Skip to content

tests: read the SSKR refusals and the generated shares off the screen - #147

Merged
aido merged 1 commit into
aido:bip85from
buzzromain:tests/two-button-sskr-generation-and-refusals-bip85
Aug 4, 2026
Merged

aido merged 1 commit into
aido:bip85from
buzzromain:tests/two-button-sskr-generation-and-refusals-bip85

Conversation

@buzzromain

Copy link
Copy Markdown

Two ways the SSKR flow can refuse had never been read off a screen, on any
device, and the generation test asserted only the first four words of the first
share.

A share entered twice — new

bolos_ux_sskr_hex_check() accepts a pair made of the same share twice, and
does so deliberately: valid CBOR header, identical cross-share metadata,
genuine CRC-32s — there is nothing in the frame to reject. Combination is what
fails, so the screen the user is shown is decided by reconstructed rather
than by the verdict:

if (!reconstructed)   -> "SSKR Recovery"/"phrase invalid"
else if (match)       -> "SSKR Phrase"/"is correct"
else                  -> "SSKR Phrase"/"doesn't match"

Collapsing the first branch into the third is a plausible mistake — combination
failed, so the seed does not match, and reporting a mismatch looks locally
reasonable. It would be the wrong answer: "doesn't match" tells the user their
shares belong to some other seed, sending them to look for the wrong shares,
when what happened is that they entered one share twice and need a different
one. The unit tests pin the refusal at the SSKR layer; nothing pinned the
screen. test_two_button_refusals.py covers the neighbouring case, a share
whose CRC is wrong, which is refused one layer earlier.

A threshold of 1 — new

test_sskr_unsupported_values.py drives the equivalent touch path, but its
assertion on the message is commented out — the touch status page lasts three
seconds and the test cannot catch it. What it asserts instead is that the
application returns to the "Generate SSKR" screen, which a build that silently
ignored the choice would also satisfy. On these devices the refusal is an
ordinary flow that waits for the user, so the message can simply be read.

The other half of that guarantee is structural and already holds:
sskr_threshold_getter() bounds the threshold list by the share count just
chosen, so a threshold above the number of shares is never offered and there is
no screen to assert about it.

Generation — strengthened

test_sskr_generate_two_button.py generated a 2-of-3 split and asserted "tuna
next keep gyro", the CBOR tag and byte-string header. That leaves the split
itself unchecked: the same four words come out of any share of any set.

The shares still cannot be pinned — the 16-bit share-set identifier is drawn at
random, so two runs produce different ByteWords — but their shape can, so each
share is now read back off the display and held to four things:

  • three shares come out when three were asked for;
  • each is 29 ByteWords — 4 of tag and header, 21 of shard, 4 of CRC-32;
  • all three carry the same first eight words, so they belong to one share set;
  • their ninth words are able, acid, also — ByteWords for 0x00, 0x01,
    0x02. That byte is the reserved nibble followed by the member index
    (BCR-2020-011), so this says both that the reserved nibble is zero and that
    the three shares are numbered rather than being three copies of one share,
    which would make the split worthless while still looking like a split.

The flow ends on a step that quits the application, so the shares cannot be fed
back into "Check SSKR" without a second run of the emulator; no round-trip is
attempted.

Helpers

nano.py gains collect_shares(), which reads a share back across the pages
bnnn_paging splits it over, and _lines() under it, ordering the screen by y
so that a title can be told from the body. Walking stops at the "Quit" step:
the flow loops, but get_next_data() clamps the share index rather than
wrapping it, so going further comes back to the last share and stays there.

Checking

Each test was run against a deliberately broken build and confirmed to fail
there. The binary is compared by hash before and after each mutation, because a
build that fails to recompile leaves the previous one in place and turns a
falsification into a test that passes for no reason:

Test Mutation Result
duplicate share !reconstructed sent to the no-match flow fails
threshold refusal the 1-of-m guard made unreachable fails
generation share count taken as idx instead of idx + 1 fails

The duplicate-share mutation touches only the !reconstructed branch and
leaves the hex_check() one alone, so its failure also confirms which of the
two the pair actually goes through.

Two ways the SSKR flow can refuse had never been read off a screen, on any
device, and the two-button generation test asserted only the first four
words of the first share -- which are the CBOR tag and byte-string header,
the same four words any share of any set produces.

A share entered twice is added. hex_check() accepts that pair on purpose:
valid header, identical cross-share metadata, genuine CRC-32s, nothing in
the frame to reject. Combination is what fails, so the screen is decided by
`reconstructed` rather than by the verdict, and reporting "doesn't match"
there would send the user looking for shares belonging to another seed when
what happened is that they entered one share twice. The unit tests pin the
refusal at the SSKR layer; nothing pinned the screen.

A threshold of 1 over 3 shares is added. The equivalent touch test has its
assertion on the message commented out -- the status page lasts three
seconds and cannot be caught -- and asserts only that the application comes
back to the previous screen, which a build that silently ignored the choice
would also satisfy. Here the refusal waits for the user and can be read.

Generation now reads every share back off the display instead of the first
four words of the first one. The shares cannot be pinned, the share-set
identifier being drawn at random, but their shape can: three shares for
three asked for, 29 ByteWords each, a common share-set header, and member
indices 0, 1 and 2, so that three copies of one share would not pass.

nano.py gains collect_shares(), which reassembles a share across the pages
bnnn_paging splits it over and stops at the "Quit" step, get_next_data()
clamping the share index rather than wrapping it.

Each test was run against a deliberately broken build and confirmed to fail
there, the binary being compared by hash before and after each mutation so
that a build which did not recompile could not pass for a passing test.
@aido
aido merged commit 1075884 into aido:bip85 Aug 4, 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