a452dc3314
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.
127 lines
4.7 KiB
Go
127 lines
4.7 KiB
Go
package escrow
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The four EFF large-list entries that contain RecoveryCodeSep. Named here so a wordlist swap that
|
|
// changes the set fails loudly rather than silently re-opening the ambiguity.
|
|
var hyphenatedEFFWords = []string{"drop-down", "felt-tip", "t-shirt", "yo-yo"}
|
|
|
|
func TestJoinSafe_RemovesExactlyTheHyphenatedEFFWords(t *testing.T) {
|
|
raw := parseWordlist(wordlistRaw)
|
|
rawSet := make(map[string]bool, len(raw))
|
|
for _, w := range raw {
|
|
rawSet[w] = true
|
|
}
|
|
for _, w := range hyphenatedEFFWords {
|
|
if !rawSet[w] {
|
|
t.Fatalf("fixture drift: %q is no longer in the embedded EFF list", w)
|
|
}
|
|
}
|
|
|
|
filtered := joinSafe(raw)
|
|
if len(raw)-len(filtered) != len(hyphenatedEFFWords) {
|
|
t.Fatalf("joinSafe removed %d entries, expected exactly %d",
|
|
len(raw)-len(filtered), len(hyphenatedEFFWords))
|
|
}
|
|
got := make(map[string]bool, len(filtered))
|
|
for _, w := range filtered {
|
|
if strings.Contains(w, RecoveryCodeSep) {
|
|
t.Errorf("filtered wordlist still contains a separator-bearing word %q", w)
|
|
}
|
|
got[w] = true
|
|
}
|
|
for _, w := range hyphenatedEFFWords {
|
|
if got[w] {
|
|
t.Errorf("joinSafe kept %q, which contains %q", w, RecoveryCodeSep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEntropyFloorSurvivesFiltering states the numbers explicitly: dropping 4 of 7776 words costs
|
|
// ~0.0007 bits/word, so the 10-word code stays above the 128-bit floor with room to spare.
|
|
func TestEntropyFloorSurvivesFiltering(t *testing.T) {
|
|
const floorBits = 128.0
|
|
before := float64(RecoveryCodeWords) * math.Log2(7776)
|
|
after := RecoveryCodeEntropyBits()
|
|
|
|
if after < floorBits {
|
|
t.Fatalf("filtered entropy %.3f bits is below the %.0f-bit floor", after, floorBits)
|
|
}
|
|
if want := float64(RecoveryCodeWords) * math.Log2(float64(WordlistSize())); math.Abs(after-want) > 1e-9 {
|
|
t.Fatalf("RecoveryCodeEntropyBits() = %.6f, want %.6f (10 * log2(%d))", after, want, WordlistSize())
|
|
}
|
|
// Concrete expectations, so a wordlist change that quietly erodes the margin is visible:
|
|
// 10*log2(7776) = 129.248 bits before, 10*log2(7772) = 129.241 bits after — a 0.007-bit cost.
|
|
if math.Abs(before-129.248) > 0.001 {
|
|
t.Fatalf("unfiltered entropy baseline moved: %.3f, expected 129.248", before)
|
|
}
|
|
if math.Abs(after-129.241) > 0.001 {
|
|
t.Fatalf("filtered entropy moved: %.3f, expected 129.241", after)
|
|
}
|
|
if cost := before - after; cost > 0.01 {
|
|
t.Fatalf("filtering cost %.4f bits, expected well under 0.01", cost)
|
|
}
|
|
}
|
|
|
|
// TestGeneratedCodeSegments_FilteredVsUnfiltered is the deterministic red-proof companion.
|
|
//
|
|
// Against a list where EVERY word contains the separator, a 10-word draw MUST segment into more
|
|
// than 10 parts — that is the pre-fix behaviour, reproduced with probability 1 instead of the ~1/5
|
|
// flake the real list produced. Against the same list run through joinSafe, generation must refuse
|
|
// (nothing is left to draw from), proving joinSafe — not luck — is what makes a code segmentable.
|
|
func TestGeneratedCodeSegments_FilteredVsUnfiltered(t *testing.T) {
|
|
unfiltered := hyphenatedEFFWords
|
|
|
|
words, err := generateWords(unfiltered)
|
|
if err != nil {
|
|
t.Fatalf("generateWords(unfiltered): %v", err)
|
|
}
|
|
if len(words) != RecoveryCodeWords {
|
|
t.Fatalf("generator drew %d words, want %d", len(words), RecoveryCodeWords)
|
|
}
|
|
joined := strings.Join(words, RecoveryCodeSep)
|
|
segs := len(strings.Split(joined, RecoveryCodeSep))
|
|
if segs <= RecoveryCodeWords {
|
|
t.Fatalf("unfiltered draw segmented into %d parts; the pre-fix defect should yield more than %d",
|
|
segs, RecoveryCodeWords)
|
|
}
|
|
if segs != 2*RecoveryCodeWords {
|
|
t.Fatalf("every fixture word has exactly one separator, so 10 words must segment into 20 parts, got %d", segs)
|
|
}
|
|
|
|
// Same fixture, filtered: the draw space is empty, so generation must error rather than
|
|
// silently fall back to something ambiguous.
|
|
if _, err := generateWords(joinSafe(unfiltered)); err == nil {
|
|
t.Fatal("generateWords on a fully-filtered list must fail, not return a code")
|
|
}
|
|
}
|
|
|
|
// TestGenerateRecoveryCode_NeverContainsAmbiguousWord is the production-wiring test: it asserts the
|
|
// exported entry point (not just the helper) draws from the filtered list.
|
|
func TestGenerateRecoveryCode_NeverContainsAmbiguousWord(t *testing.T) {
|
|
inFiltered := make(map[string]bool, len(wordlist))
|
|
for _, w := range wordlist {
|
|
inFiltered[w] = true
|
|
}
|
|
for i := 0; i < 500; i++ {
|
|
r, err := GenerateRecoveryCode()
|
|
if err != nil {
|
|
t.Fatalf("GenerateRecoveryCode: %v", err)
|
|
}
|
|
parts := strings.Split(r, RecoveryCodeSep)
|
|
if len(parts) != RecoveryCodeWords {
|
|
// Do not print r: it is a live-shaped secret.
|
|
t.Fatalf("code %d segmented into %d parts, want %d", i, len(parts), RecoveryCodeWords)
|
|
}
|
|
for _, p := range parts {
|
|
if !inFiltered[p] {
|
|
t.Fatalf("segment %q is not a filtered-wordlist word", p)
|
|
}
|
|
}
|
|
}
|
|
}
|