hub v0.119.0 — English households get English words for their codes (R-597); R-596/R-598 closed
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
This commit is contained in:
2026-09-21 07:56:56 +02:00
parent a499327236
commit e02bc03819
18 changed files with 8859 additions and 50 deletions
File diff suppressed because it is too large Load Diff
+175 -14
View File
@@ -3,44 +3,205 @@ 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
// wordList is populated from the embedded hungarian.txt at init time.
// 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() {
seen := make(map[string]struct{}, 30000)
for _, line := range strings.Split(hungarianWords, "\n") {
w := strings.TrimSpace(line)
if w == "" {
continue
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{}{}
wordList = append(wordList, w)
out = append(out, w)
}
return out
}
// RandomPassphrase generates a human-friendly passphrase from Hungarian words.
// Format: "szó-szó-szó-szó-szó" (words separated by dashes).
// With a ~29K word list, 4 words gives ~59 bits of entropy, 5 words ~74 bits.
// Easy to read, type, and dictate by Hungarian-speaking customers.
// 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(wordList)))
max := big.NewInt(int64(len(list)))
for i := range words {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
words[i] = wordList[idx.Int64()]
words[i] = list[idx.Int64()]
}
return strings.Join(words, "-"), nil
return strings.Join(words, passphraseSep), nil
}
@@ -0,0 +1,168 @@
package configgen
import (
"math"
"strings"
"testing"
)
// R-597 — ENGLISH WORDS FOR ENGLISH HOUSEHOLDS, AND NEVER A WEAKER CODE.
//
// The 2026-09-20 English drill received a setup code of three Hungarian words with accents inside an
// otherwise English e-mail (`képző-szkítia-ásatás`). It can be pasted; it cannot be read aloud, and
// it cannot be retyped by someone who does not have the accents on their keyboard.
//
// The English list is smaller than the Hungarian one, so "translate the code" is not free: fewer
// bits per word. These tests exist so the fix cannot quietly buy readability with security.
// S3 — for every use, the English code carries at least as many bits as the Hungarian one.
//
// NOT A CONSTANT-FOR-MEASUREMENT DECOY: both sides are computed from the embedded lists' actual
// lengths and the shipped count table. Shrink english.txt, or drop a word count in wordCounts, and
// this fails. (Red-proofed by setting UseSetupCode's "en" to 3 — see the session REPORT.)
func TestEnglishIsNeverWeakerThanHungarian(t *testing.T) {
if len(wordList) < 2 || len(englishList) < 2 {
t.Fatalf("a wordlist did not load: hu=%d en=%d", len(wordList), len(englishList))
}
for _, use := range []Use{UseSetupCode, UseOwnerPassphrase} {
hu := EntropyBitsFor("hu", use)
en := EntropyBitsFor("en", use)
if hu <= 0 || en <= 0 {
t.Fatalf("%s: entropy came out zero (hu=%.2f en=%.2f) — the table or a list is missing", use, hu, en)
}
if en < hu {
t.Errorf("%s: the ENGLISH code is WEAKER than the Hungarian one — en %d words = %.2f bits, "+
"hu %d words = %.2f bits. An English household must not be given a code that is easier "+
"to guess in exchange for being readable.",
use, WordCountFor("en", use), en, WordCountFor("hu", use), hu)
}
t.Logf("%-18s hu %d words = %6.2f bits | en %d words = %6.2f bits (%.2f bits/word hu, %.2f en)",
use, WordCountFor("hu", use), hu, WordCountFor("en", use), en,
math.Log2(float64(len(wordList))), math.Log2(float64(len(englishList))))
}
}
// The Hungarian side is UNCHANGED. Not "still fine" — identical: same list, same counts, so every
// Hungarian household's code is exactly what it was before v0.119.0.
func TestHungarianCodesUnchanged(t *testing.T) {
if got := WordCountFor("hu", UseSetupCode); got != 3 {
t.Errorf("the Hungarian setup code is now %d words, was 3", got)
}
if got := WordCountFor("hu", UseOwnerPassphrase); got != 5 {
t.Errorf("the Hungarian owner passphrase is now %d words, was 5", got)
}
// The Hungarian list itself: the file has 29634 lines with 25 duplicates. Pinned so a list swap
// cannot move the Hungarian entropy floor without saying so.
if len(wordList) != 29609 {
t.Errorf("the Hungarian list is %d words, was 29609 — the entropy floor moved", len(wordList))
}
// And a Hungarian code must still be drawn from the Hungarian list.
code, err := RandomPassphraseFor("hu", UseSetupCode)
if err != nil {
t.Fatal(err)
}
hu := make(map[string]struct{}, len(wordList))
for _, w := range wordList {
hu[w] = struct{}{}
}
for _, w := range strings.Split(code, passphraseSep) {
if _, ok := hu[w]; !ok {
t.Errorf("a Hungarian setup code contains %q, which is not in the Hungarian list", w)
}
}
}
// An English code is drawn from the English list, has the right number of words, and segments back
// into exactly that many — the joinSafe guarantee.
func TestEnglishCodeIsEnglishAndSegments(t *testing.T) {
en := make(map[string]struct{}, len(englishList))
for _, w := range englishList {
en[w] = struct{}{}
}
for _, use := range []Use{UseSetupCode, UseOwnerPassphrase} {
want := WordCountFor("en", use)
for i := 0; i < 200; i++ {
code, err := RandomPassphraseFor("en", use)
if err != nil {
t.Fatalf("%s: %v", use, err)
}
parts := strings.Split(code, passphraseSep)
if len(parts) != want {
t.Fatalf("%s: code %q segments into %d words, want %d — a word carrying the separator "+
"slipped past joinSafe", use, code, len(parts), want)
}
for _, w := range parts {
if _, ok := en[w]; !ok {
t.Fatalf("%s: code contains %q, which is not in the English list", use, w)
}
}
}
}
}
// What the discarded "phone rule" was actually reaching for, kept as an assertion instead of a
// filter (see joinSafe's note). A word that carries a digit, an accent, a capital or a separator is
// the thing that genuinely breaks transcription and retyping.
func TestEnglishListIsTranscribable(t *testing.T) {
if len(englishList) != 7772 {
t.Errorf("the English list is %d words, expected 7772 (EFF large, minus the four hyphenated "+
"entries) — the entropy floor moved", len(englishList))
}
for _, w := range englishList {
if len(w) < 3 || len(w) > 9 {
t.Errorf("%q is %d characters — outside the 3-9 range a person can hold in their head", w, len(w))
}
for _, r := range w {
if r < 'a' || r > 'z' {
t.Errorf("%q contains %q — an English code must be lower-case ASCII letters only, so it "+
"can be typed on any keyboard, which is the whole reason this list exists", w, r)
break
}
}
}
}
// The ENTIRE POINT of an English code is that it survives a round trip through the box's comparison,
// which lower-cases and re-joins. A household who types their code with spaces, or in capitals, must
// be let in.
func TestEnglishCodeSurvivesNormalisation(t *testing.T) {
code, err := RandomPassphraseFor("en", UseSetupCode)
if err != nil {
t.Fatal(err)
}
for _, typed := range []string{
code,
strings.ToUpper(code),
strings.ReplaceAll(code, passphraseSep, " "),
" " + strings.ReplaceAll(code, passphraseSep, " ") + " ",
} {
if got := NormalizePassphrase(typed); got != code {
t.Errorf("typing %q normalises to %q, want %q", typed, got, code)
}
}
}
// An unsupported or empty language must land on Hungarian — the list AND the count together. A
// caller that got the list right and the count wrong would produce a code weaker than either.
func TestUnknownLanguageFallsBackToHungarianWholesale(t *testing.T) {
for _, lang := range []string{"", "de", "EN-GB", "xx"} {
if got, want := WordCountFor(lang, UseSetupCode), WordCountFor("hu", UseSetupCode); got != want {
t.Errorf("language %q: %d words, want the Hungarian %d", lang, got, want)
}
code, err := RandomPassphraseFor(lang, UseSetupCode)
if err != nil {
t.Fatalf("language %q: %v", lang, err)
}
if n := len(strings.Split(code, passphraseSep)); n != WordCountFor("hu", UseSetupCode) {
t.Errorf("language %q produced a %d-word code", lang, n)
}
}
}
// An unknown USE must be an ERROR, never a silently short code. This is the direction that matters:
// a typo'd Use returning a one-word passphrase would be a catastrophic silent weakening.
func TestUnknownUseIsRefused(t *testing.T) {
if code, err := RandomPassphraseFor("en", Use("retrieval_key")); err == nil {
t.Errorf("an unknown use produced a passphrase %q instead of an error", code)
}
}