Skip to content

Commit b701ff3

Browse files
authored
Merge pull request #3 from sandman21vs/coin-word-entry
Flip each word on its own screen, and show what it spells
2 parents 9036c9d + db9d0cf commit b701ff3

5 files changed

Lines changed: 627 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,14 @@ one and move their funds.
9090

9191
Origo exists to make that class of bug structurally impossible rather than
9292
merely unlikely. There is no RNG-to-seed code path for a misconfigured build
93-
flag to misroute in the first place: a mnemonic is a pure function of a
94-
canonical transcript the human recorded by hand — dice, coins or cards —
95-
reduced with SHA256, and, as stated just above, the device RNG plays no part
96-
in it. A firmware regression that silently swapped the entropy source behind
93+
flag to misroute in the first place: a mnemonic is a pure function of what the
94+
human recorded by hand — dice, coins or cards — and, as stated just above, the
95+
device RNG plays no part in it. Two derivations turn that record into words,
96+
both deterministic and neither touching the RNG: the transcript reduced with
97+
SHA256, which every source uses, and — for coins only — the flips packed
98+
straight into the entropy without a hash, which is the one path a reader can
99+
check against a printed wordlist with no device at all. Both are described
100+
below. A firmware regression that silently swapped the entropy source behind
97101
a device owner's back, the same shape of bug that cost Coldcard users tens of
98102
millions of dollars, has nothing to act on here: stub the RNG out entirely
99103
and Origo generates the identical mnemonic.
@@ -133,6 +137,10 @@ count 12-word cards uses.
133137

134138
### From transcript to mnemonic
135139

140+
This is what every source does, and what `Coin flips` does under **Flip and
141+
hash**. The alternative for coins, which skips the hash so the arithmetic can
142+
be checked by hand, is [described below](#flipping-words-directly).
143+
136144
Turning that transcript into words is three mechanical steps, always in this
137145
order, and nothing else touches them: no RNG, no timestamp, no per-device
138146
salt.
@@ -170,6 +178,43 @@ Neither path ever reads the device RNG. The entropy quality bar described
170178
next grades what has already been typed; it cannot add or remove a single bit
171179
from what gets hashed.
172180

181+
### Flipping words directly
182+
183+
`Coin flips` offers that same unhashed arithmetic as a second method, **Flip
184+
each word**, reached by flipping the words rather than typing them. It exists
185+
because coins are the one source a person can convert to BIP39 words by hand,
186+
and hashing throws that away: re-entering a transcript elsewhere reproduces the
187+
mnemonic, but nobody checks on paper that word 7 is the word their coin
188+
produced, because checking it means computing SHA256 of a 128-character string.
189+
190+
The wordlist is exactly 2048 long, so eleven flips name one word — every word
191+
reachable, none favoured, no rejection sampling and no modulo skew. Heads is 1,
192+
tails is 0, most significant bit first:
193+
194+
```
195+
00000000000 -> 0001 -> abandon
196+
01100110011 -> 0820 -> grid
197+
11111111111 -> 2048 -> zoo
198+
```
199+
200+
Eleven such words are 121 bits, so seven loose flips finish the 128 a 12-word
201+
mnemonic needs and BIP39's checksum supplies the twelfth word; 24 words is
202+
twenty-three words and three loose flips for 256. The flip count is identical
203+
to the hashed method's — 128 or 256 — so neither method asks for more coin
204+
tosses than the other, and neither has more entropy behind it.
205+
206+
The final word is the one step a wordlist alone cannot do, since part of it is
207+
the checksum. That is the same arithmetic `Complete checksum` above performs,
208+
reached from a different direction.
209+
210+
There is no quality bar on this path, deliberately: eleven flips are eleven
211+
bits by construction, nothing is being estimated, and a gate could only
212+
second-guess the reader's coin. The hashed method keeps its bar unchanged.
213+
214+
`tools/origo_verify.py complete` checks the result — convert each group of
215+
eleven flips against a printed wordlist, then hand it those words and the loose
216+
flips.
217+
173218
### The quality bar
174219

175220
Every source's run opens on a screen naming how many rolls, flips or cards it
@@ -654,10 +699,17 @@ BIP39, BIP32, BIP84, BIP86 and Bech32/Bech32m. Examples:
654699
```sh
655700
python3 tools/origo_verify.py generate d20 --words 12 1 2 3 ...
656701
python3 tools/origo_verify.py complete "abandon ... abandon" 0000000
702+
python3 tools/origo_verify.py coin-words 0110011001101100110011... # 128 flips
657703
python3 tools/origo_verify.py inspect "abandon ... about" --index 0
658704
python3 tools/origo_verify.py inspect "abandon ... about" --index 0 --change
659705
```
660706

707+
`coin-words` reproduces the Flip-each-word method from the flips alone,
708+
printing each group of eleven with the word it names before the finished
709+
mnemonic — so the device's screens can be checked one at a time, or the whole
710+
run at once. It is the same arithmetic `complete` performs, entered from the
711+
coins rather than from words already converted by hand.
712+
661713
`inspect` prints the checksum verdict, one-based word numbers, the Compact
662714
SeedQR payload, master fingerprint, both account xpubs, the exact payload of
663715
both account key QR codes, both addresses and both output descriptors, so

‎host/origo_simulator.c‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,80 @@ static const char mnemonic24[]
3838
static const char bad_checksum_mnemonic[]
3939
= "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
4040

41+
/* Eleven flips name one word, most significant bit first, and the words they
42+
* name are handed to seedtool_complete_checksum - the same call the typed-word
43+
* flow makes. The screen that shows the reader "these eleven flips are word
44+
* 0819" is only telling the truth if that index survives into the finished
45+
* mnemonic unchanged, so that is what is checked, for every one of the 2048
46+
* words rather than for a chosen few.
47+
*
48+
* The all-zero case is pinned to the published vectors the rest of this file
49+
* already uses: eleven words of index 0 plus seven zero bits are 128 zero
50+
* entropy bits, and twenty-three plus three zero bits are 256 - `mnemonic` and
51+
* `mnemonic24`. That is the one case where the whole chain, checksum included,
52+
* is fixed by something outside this project. */
53+
static bool coin_words_build_the_entropy(const size_t words, const uint16_t index, char* out, const size_t out_len)
54+
{
55+
const size_t word_count = words == 12 ? 11 : 23;
56+
const size_t tail_count = words == 12 ? 7 : 3;
57+
char prefix[SEEDTOOL_MAX_MNEMONIC_LEN + 1] = { 0 };
58+
uint8_t tail[7] = { 0 };
59+
size_t used = 0;
60+
for (size_t i = 0; i < word_count; ++i) {
61+
const char* const word = seedtool_word(index);
62+
const size_t len = strlen(word);
63+
if (used + len + (i ? 1 : 0) >= sizeof(prefix)) {
64+
return false;
65+
}
66+
if (i) {
67+
prefix[used++] = ' ';
68+
}
69+
memcpy(prefix + used, word, len);
70+
used += len;
71+
prefix[used] = '\0';
72+
}
73+
return seedtool_complete_checksum(prefix, tail, tail_count, out, out_len) == SEEDTOOL_OK;
74+
}
75+
76+
static bool coin_word_encoding_is_sound(void)
77+
{
78+
char completed[SEEDTOOL_MAX_MNEMONIC_LEN + 1];
79+
80+
/* Zero flips, both lengths, against the published vectors. */
81+
if (!coin_words_build_the_entropy(12, 0, completed, sizeof(completed)) || strcmp(completed, mnemonic) != 0) {
82+
return false;
83+
}
84+
if (!coin_words_build_the_entropy(24, 0, completed, sizeof(completed)) || strcmp(completed, mnemonic24) != 0) {
85+
return false;
86+
}
87+
88+
/* Every word survives the trip, at both lengths: whatever the reader was
89+
* shown on the confirmation screen is what the finished mnemonic spells. */
90+
for (uint16_t index = 0; index < SEEDTOOL_WORDLIST_LEN; ++index) {
91+
const size_t lengths[] = { 12, 24 };
92+
for (size_t l = 0; l < 2; ++l) {
93+
const size_t word_count = lengths[l] == 12 ? 11 : 23;
94+
uint16_t numbers[24];
95+
size_t count = 0;
96+
if (!coin_words_build_the_entropy(lengths[l], index, completed, sizeof(completed))
97+
|| seedtool_mnemonic_word_numbers(completed, numbers, 24, &count) != SEEDTOOL_OK
98+
|| count != lengths[l]) {
99+
return false;
100+
}
101+
for (size_t i = 0; i < word_count; ++i) {
102+
if (numbers[i] != index + 1) {
103+
return false;
104+
}
105+
}
106+
}
107+
}
108+
109+
/* An eleven-bit index is the whole wordlist and nothing past it: the top
110+
* index must be reachable, and the encoding must not overflow into a
111+
* twelfth bit. */
112+
return SEEDTOOL_WORDLIST_LEN == (1u << 11);
113+
}
114+
41115
/* `count` words, all "abandon" when `spread` is zero and a spread of the
42116
* wordlist otherwise.
43117
*
@@ -433,6 +507,7 @@ static bool word_numbers_round_trip_is_sound(void)
433507
static const char* const menu_labels[] = { "Master fingerprint", "Native SegWit (BIP84)", "Taproot (BIP86)",
434508
"Account key", "Addresses", "Account key format", "xpub", "zpub", "Erase and restart", "New Seed", "From entropy",
435509
"Restore Seed", "Complete checksum", "About", "Settings", "11 words + 7 coins", "23 words + 3 coins",
510+
"Coin method", "Flip each word", "Flip and hash",
436511
"No passphrase", "Enter passphrase", "D6 dice", "D20 dice", "Coin flips", "Cards", "Back", "12 words",
437512
"24 words", "[delete]", "[back]", "Type the letters", "Enter word numbers", "Plain text", "Backup",
438513
"Stackbit 1248", "Compact SeedQR", "Simple grid", "Physical layout" };
@@ -448,6 +523,37 @@ static bool labels_fit_a_row(void)
448523
return true;
449524
}
450525

526+
/* The coin-word screens are not choice lists, but they are stacked text at a
527+
* fixed y: draw_centered wraps a line too wide for the display rather than
528+
* cutting it, and the wrapped remainder would land on top of the line below.
529+
* The word's number and the word itself are on one of those lines, so the
530+
* check runs over all 2048 rather than over a chosen few.
531+
*
532+
* seedtool_render_fit_row measures against the choice list's text column,
533+
* which is narrower than the full width these screens centre in - a
534+
* conservative bound, and the one measurement this file can reach. */
535+
static bool coin_word_screens_fit(void)
536+
{
537+
static const char* const fixed[] = { "Word 11 of 23", "Final bits", "Flip word 11 again?",
538+
"These flips are lost", "11111111111", "Heads (up) Tails (down)" };
539+
for (size_t i = 0; i < sizeof(fixed) / sizeof(fixed[0]); ++i) {
540+
if (seedtool_render_fit_row(fixed[i]) != strlen(fixed[i])) {
541+
return false;
542+
}
543+
}
544+
for (uint16_t index = 0; index < SEEDTOOL_WORDLIST_LEN; ++index) {
545+
char subject[32];
546+
(void)snprintf(subject, sizeof(subject), "%04u %s", (unsigned)index + 1u, seedtool_word(index));
547+
if (seedtool_render_fit_row(subject) != strlen(subject)) {
548+
return false;
549+
}
550+
if (seedtool_render_fit_row(seedtool_word(index)) != strlen(seedtool_word(index))) {
551+
return false;
552+
}
553+
}
554+
return true;
555+
}
556+
451557
/* Scrolling is only navigation if the selection is always on screen and the end
452558
* of a list looks like the end of it. Both hold for every list the firmware can
453559
* build, from any previous scroll position, so both are checked exhaustively. */
@@ -1577,6 +1683,14 @@ static int self_test(void)
15771683
fputs("Origo word number round-trip self-test failed\n", stderr);
15781684
return 1;
15791685
}
1686+
if (!coin_word_encoding_is_sound()) {
1687+
fputs("Origo coin word encoding self-test failed\n", stderr);
1688+
return 1;
1689+
}
1690+
if (!coin_word_screens_fit()) {
1691+
fputs("Origo coin word screen fit self-test failed\n", stderr);
1692+
return 1;
1693+
}
15801694
if (!final_word_filter_is_exact()) {
15811695
fputs("Origo final word filter self-test failed\n", stderr);
15821696
return 1;

0 commit comments

Comments
 (0)