Files
felhom.eu/hub/internal/configgen/passphrase_language_test.go
T
admin e02bc03819
gates / gates (push) Successful in 24s
hub v0.119.0 — English households get English words for their codes (R-597); R-596/R-598 closed
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
2026-09-21 07:56:56 +02:00

169 lines
6.8 KiB
Go

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