escrow: a recovery code can no longer contain a hyphenated word (v0.93.0)

The EFF large list has exactly 4 entries containing the join separator
(drop-down, felt-tip, t-shirt, yo-yo). Drawing one made a code read as 11
words instead of 10 - ambiguous to transcribe in precisely the situation R
exists for. Filter them at init; the draw space goes 7776 -> 7772 and the
10-word code goes 129.248 -> 129.241 bits, still well over the 128 floor.

Generation-only: already-issued codes stay valid, R is verified as a whole
passphrase and never re-split.

Also fixes the ~1/5 flake this same defect caused: the test counted words by
splitting the joined string. It now counts what the generator drew and
asserts segmentation separately, plus a deterministic red-proof fixture.
This commit is contained in:
2026-07-21 14:46:51 +02:00
parent 935904fa4e
commit a452dc3314
5 changed files with 232 additions and 23 deletions
+22 -6
View File
@@ -11,8 +11,16 @@ import (
)
func TestWordlistLoaded(t *testing.T) {
if WordlistSize() != 7776 {
t.Fatalf("EFF large wordlist should be 7776 words, got %d", WordlistSize())
// The EFF large list is 7776 entries; joinSafe removes the 4 that contain RecoveryCodeSep
// (drop-down, felt-tip, t-shirt, yo-yo), leaving 7772 as the effective draw space.
if got := WordlistSize(); got != 7772 {
t.Fatalf("effective wordlist should be 7772 words (7776 EFF - 4 hyphenated), got %d", got)
}
if got := WordlistFilteredOut(); got != 4 {
t.Fatalf("joinSafe should have removed exactly 4 hyphenated entries, removed %d", got)
}
if got := WordlistSize() + WordlistFilteredOut(); got != 7776 {
t.Fatalf("filtered + removed should reconstitute the 7776-word EFF list, got %d", got)
}
}
@@ -25,19 +33,27 @@ func TestGenerateRecoveryCode_EntropyAndFormat(t *testing.T) {
inList[w] = true
}
for i := 0; i < 50; i++ {
r, err := GenerateRecoveryCode()
// Count words by GENERATION count, not by re-splitting the joined string: the two agree
// only because joinSafe holds, and conflating them is what made this test flake ~1/5.
words, err := generateWords(wordlist)
if err != nil {
t.Fatalf("GenerateRecoveryCode: %v", err)
t.Fatalf("generateWords: %v", err)
}
words := strings.Split(r, "-")
if len(words) != RecoveryCodeWords {
t.Fatalf("recovery code must be %d words, got %d (%q)", RecoveryCodeWords, len(words), r)
t.Fatalf("generator must draw %d words, drew %d", RecoveryCodeWords, len(words))
}
for _, w := range words {
if !inList[w] {
t.Errorf("recovery-code word %q is not from the EFF wordlist", w)
}
}
// Separately assert the property joinSafe buys: the joined code segments back to the same
// count. Never print r — it is a live-shaped secret.
r := strings.Join(words, RecoveryCodeSep)
if got := len(strings.Split(r, RecoveryCodeSep)); got != RecoveryCodeWords {
t.Fatalf("joined code must segment into %d words, got %d (a drawn word contained %q)",
RecoveryCodeWords, got, RecoveryCodeSep)
}
}
}