e02bc03819
gates / gates (push) Successful in 24s
The setup code and the owner passphrase now follow the household's language,
one word longer in English so the entropy never drops (setup 3 hu / 4 en,
passphrase 5 hu / 6 en). List and count are chosen together so a caller cannot
pair an English list with a Hungarian count. Hungarian is byte-unchanged.
Three claims in the row were wrong and are recorded as such:
- the RECOVERY CODE is minted by felhom-agent from the EFF list and has
always been English; the hub does not own it and no row was added.
- no claim mail states a word count; the only count wording was the bind
page's passphrase hint, whose English half is now count-free.
- the proposed phone-safe filter removes 68% of the list (5270 of 7772
words) and was measured, then declined, with the reason in source.
Also: guide_quote_gate binds the English volunteer guide's three quoted
messages to the controller's English bundle — nothing did, so the guide would
have gone on quoting Hungarian after the fix. Seven decoys, all convicting,
including the name-for-fact one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
208 lines
8.0 KiB
Go
208 lines
8.0 KiB
Go
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
|
|
}
|