package configgen import ( "crypto/rand" _ "embed" "fmt" "math" "math/big" "strings" "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" ) //go:embed hungarian.txt var hungarianWords string // english.txt is the EFF "large" diceware wordlist (7776 words), CC BY 3.0 US, published by the // Electronic Frontier Foundation at https://www.eff.org/dice — the standard list for passphrases a // human has to transcribe. It is the SAME FILE felhom-agent already embeds at // internal/escrow/eff_large_wordlist.txt to mint the customer recovery code, copied rather than // imported because the two binaries share no module. // // R-597: an English-speaking household was given a setup code of three HUNGARIAN words with accents // inside an otherwise English e-mail. They can paste it; they cannot read it to anyone, and they // cannot retype it. This list is how the hub answers them in their own language. // //go:embed english.txt var englishWords string // wordList is the Hungarian list, populated from the embedded hungarian.txt at init time. var wordList []string // englishList is the EFF list minus every word containing the separator, so a generated code always // segments back into exactly the number of words drawn. Populated at init. var englishList []string // passphraseSep joins the words of a generated passphrase. const passphraseSep = "-" func init() { wordList = dedupe(splitWords(hungarianWords)) englishList = joinSafe(dedupe(splitWords(englishWords))) } func splitWords(raw string) []string { var out []string for _, line := range strings.Split(raw, "\n") { if w := strings.TrimSpace(line); w != "" { out = append(out, w) } } return out } func dedupe(in []string) []string { seen := make(map[string]struct{}, len(in)) out := make([]string, 0, len(in)) for _, w := range in { if _, dup := seen[w]; dup { continue } seen[w] = struct{}{} out = append(out, w) } return out } // joinSafe drops every word containing passphraseSep. In the EFF large list this removes exactly // four entries — drop-down, felt-tip, t-shirt, yo-yo — of 7776, costing ~0.0007 bits/word. // // THIS IS THE ONLY FILTER, AND THAT IS A DECISION, not an omission (CC, 2026-09-21; operator may // reverse). The closing task proposed a second one: drop any word that differs from another by one // letter at the same position within its first six letters, "the read-it-over-the-phone rule". It // was measured before being adopted and it removes 5270 of 7772 words — 68% of the list, taking it // from 12.92 to 11.29 bits/word. Three reasons not to pay that: // // 1. It would make this list stricter than the one the product already uses for the RECOVERY CODE // — the one secret a household writes on paper and reads back during a disaster. felhom-agent // draws that from this same list with this same single filter. A stricter rule for the setup // code, which is pasted out of an e-mail and expires in 72 hours, is incoherent. // 2. Spelling distance is not dictation distance. The confusions that matter over a telephone are // phonetic, and the EFF list was assembled by people solving exactly that problem. // 3. Every bit it removes has to be bought back with more words, and a longer code is itself a // transcription risk. // // What the rule was reaching for is real, and it is kept as an assertion instead of a filter: // TestEnglishListIsTranscribable pins that no word carries a digit or a separator and that every // word is 3-9 lower-case ASCII letters. func joinSafe(words []string) []string { out := make([]string, 0, len(words)) for _, w := range words { if strings.Contains(w, passphraseSep) { continue } out = append(out, w) } return out } // Use names what a generated passphrase is FOR. The word count depends on it, because the three uses // have different lifetimes and different consequences, and because the entropy floor is per use. type Use string const ( // UseSetupCode is the claim/reset code mailed to the household. 72-hour TTL, single use, // rate-limited and locked out by the box after five wrong attempts. UseSetupCode Use = "setup_code" // UseOwnerPassphrase is the long-lived "Owner passphrase" handed over out of band and typed on // the self-bind page. It is compared exactly (NormalizePassphrase folds only case and spacing), // which is why an English household must not be given Hungarian words with accents. UseOwnerPassphrase Use = "owner_passphrase" ) // wordCounts is the word count per (use, language). // // THE RULE IS: for each use, the English code carries AT LEAST as many bits as the Hungarian one. // The English list is smaller (7772 vs 29609 words, 12.92 vs 14.85 bits/word), so English needs one // more word for both uses. Nothing here may be lowered without lowering the Hungarian first, and // TestEnglishIsNeverWeakerThanHungarian computes both sides from the embedded lists and this table — // it does not compare a constant with itself. // // setup code hu 3 = 44.56 bits en 4 = 51.70 bits // owner passphrase hu 5 = 74.27 bits en 6 = 77.54 bits // // The RECOVERY CODE is deliberately absent: it is not minted here. felhom-agent mints it on the box // (internal/escrow, GenerateRecoveryCode), it has always been ten EFF words, and it was already // English before R-597 existed. Adding a row for it here would invent a second definition of a // secret this repo does not own. var wordCounts = map[Use]map[string]int{ UseSetupCode: {"hu": 3, "en": 4}, UseOwnerPassphrase: {"hu": 5, "en": 6}, } // listFor returns the word list for a language. Anything that is not a supported language falls back // to Hungarian — the same direction every other default in this repo takes. func listFor(lang string) []string { if lang == "en" { return englishList } return wordList } // WordCountFor is the number of words RandomPassphraseFor will draw. Exported so a caller that has // to describe the code ("the four words in this e-mail") reads the number from the same table the // generator uses, rather than writing it down a second time. func WordCountFor(lang string, use Use) int { byLang, ok := wordCounts[use] if !ok { return 0 } if n, ok := byLang[lang]; ok { return n } return byLang[i18n.Default] } // EntropyBitsFor is the approximate entropy of a generated passphrase, for audit and for the test // that pins the floor. Never the passphrase itself. func EntropyBitsFor(lang string, use Use) float64 { n := WordCountFor(lang, use) list := listFor(lang) if n <= 0 || len(list) < 2 { return 0 } return float64(n) * math.Log2(float64(len(list))) } // RandomPassphraseFor generates a passphrase in the household's language for a named use. // // The language decides BOTH the word list and the word count; the two cannot be chosen separately, // which is what keeps the entropy floor from being defeated by a caller passing an English list and // a Hungarian count. func RandomPassphraseFor(lang string, use Use) (string, error) { n := WordCountFor(lang, use) if n <= 0 { return "", fmt.Errorf("configgen: unknown passphrase use %q", use) } return drawWords(listFor(lang), n) } // RandomPassphrase generates a passphrase of wordCount Hungarian words. // // Kept for callers that are language-blind by nature. Every customer-facing caller has moved to // RandomPassphraseFor; this one no longer chooses what a household reads. func RandomPassphrase(wordCount int) (string, error) { return drawWords(wordList, wordCount) } // drawWords picks wordCount words uniformly from list (crypto/rand via big.Int — no modulo bias). func drawWords(list []string, wordCount int) (string, error) { if len(list) < 2 { return "", fmt.Errorf("configgen: wordlist not loaded (%d words)", len(list)) } if wordCount <= 0 { return "", fmt.Errorf("configgen: word count must be positive, got %d", wordCount) } words := make([]string, wordCount) max := big.NewInt(int64(len(list))) for i := range words { idx, err := rand.Int(rand.Reader, max) if err != nil { return "", err } words[i] = list[idx.Int64()] } return strings.Join(words, passphraseSep), nil }