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
+58 -17
View File
@@ -29,9 +29,20 @@ import (
//go:embed eff_large_wordlist.txt
var wordlistRaw []byte
// wordlist is the EFF large wordlist (7776 words, 12.92 bits/word) — the diceware standard for
// human-transcribed passphrases. Parsed once at init.
var wordlist = parseWordlist(wordlistRaw)
// RecoveryCodeSep joins the words of a recovery code R. It is ALSO the reason for the
// joinSafe filter below: a word that itself contains the separator makes the joined code
// ambiguous to segment by eye, which is unaffordable in the one situation R exists for — a
// customer transcribing it during a disaster. Do not change it: R is consumed as a whole
// passphrase (see Wrap/Unwrap), so the separator is a transcription aid, not a parsed delimiter.
const RecoveryCodeSep = "-"
// wordlist is the EFF large wordlist (the diceware standard for human-transcribed passphrases),
// minus the handful of entries that contain RecoveryCodeSep. Parsed and filtered once at init.
// Sizes are asserted in wordlist_test.go so a wordlist swap cannot silently move the entropy floor.
var wordlist = joinSafe(parseWordlist(wordlistRaw))
// wordlistRawSize is the unfiltered parse length, kept for audit (see WordlistFilteredOut).
var wordlistRawSize = len(parseWordlist(wordlistRaw))
func parseWordlist(raw []byte) []string {
var w []string
@@ -44,28 +55,55 @@ func parseWordlist(raw []byte) []string {
return w
}
// RecoveryCodeWords is the number of words in a recovery code R. 10 words from the 7776-word EFF
// list ≈ 129.2 bits (≥128) — the chosen entropy floor (slice7-escrow-spike-findings.md §4).
// joinSafe drops every word containing RecoveryCodeSep, so that a generated code always segments
// back into exactly RecoveryCodeWords words. In the EFF large list this removes exactly 4 entries
// (drop-down, felt-tip, t-shirt, yo-yo) of 7776, costing ~0.0007 bits/word — the floor still holds
// (asserted in the tests). Generation-time only: codes already issued remain valid, because R is
// verified as a whole passphrase and is never re-split.
func joinSafe(words []string) []string {
out := make([]string, 0, len(words))
for _, w := range words {
if strings.Contains(w, RecoveryCodeSep) {
continue
}
out = append(out, w)
}
return out
}
// RecoveryCodeWords is the number of words in a recovery code R. 10 words from the filtered EFF
// list (7772 words) ≈ 129.2 bits (≥128) — the chosen entropy floor (slice7-escrow-spike-findings.md §4).
const RecoveryCodeWords = 10
// GenerateRecoveryCode returns a fresh recovery code R: RecoveryCodeWords words chosen uniformly
// (crypto/rand via big.Int — no modulo bias) from the EFF large wordlist, hyphen-joined.
//
// SECRET: the returned string is R. Surface it to the customer exactly once; never log or persist it.
func GenerateRecoveryCode() (string, error) {
if len(wordlist) < 2 {
return "", fmt.Errorf("escrow: wordlist not loaded (%d words)", len(wordlist))
// generateWords draws RecoveryCodeWords words uniformly (crypto/rand via big.Int — no modulo bias)
// from list. Split out from GenerateRecoveryCode so tests can drive an unfiltered list and prove
// the filter is what keeps a code segmentable.
func generateWords(list []string) ([]string, error) {
if len(list) < 2 {
return nil, fmt.Errorf("escrow: wordlist not loaded (%d words)", len(list))
}
n := big.NewInt(int64(len(wordlist)))
n := big.NewInt(int64(len(list)))
words := make([]string, RecoveryCodeWords)
for i := range words {
idx, err := rand.Int(rand.Reader, n)
if err != nil {
return "", fmt.Errorf("escrow: recovery-code rng: %w", err)
return nil, fmt.Errorf("escrow: recovery-code rng: %w", err)
}
words[i] = wordlist[idx.Int64()]
words[i] = list[idx.Int64()]
}
return strings.Join(words, "-"), nil
return words, nil
}
// GenerateRecoveryCode returns a fresh recovery code R: RecoveryCodeWords words chosen uniformly
// from the filtered EFF large wordlist, joined with RecoveryCodeSep.
//
// SECRET: the returned string is R. Surface it to the customer exactly once; never log or persist it.
func GenerateRecoveryCode() (string, error) {
words, err := generateWords(wordlist)
if err != nil {
return "", err
}
return strings.Join(words, RecoveryCodeSep), nil
}
// RecoveryCodeEntropyBits is the approximate entropy of a generated code, for display/audit only
@@ -77,5 +115,8 @@ func RecoveryCodeEntropyBits() float64 {
return float64(RecoveryCodeWords) * math.Log2(float64(len(wordlist)))
}
// WordlistSize is the loaded wordlist length (for audit/tests).
// WordlistSize is the effective (filtered) wordlist length — the draw space. For audit/tests.
func WordlistSize() int { return len(wordlist) }
// WordlistFilteredOut is how many parsed entries joinSafe removed. For audit/tests.
func WordlistFilteredOut() int { return wordlistRawSize - len(wordlist) }