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) } } } }