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
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:
@@ -1,3 +1,56 @@
|
||||
## v0.119.0 — English households get English words for their codes (2026-09-21, R-597)
|
||||
|
||||
The 2026-09-20 English drill received a setup code of **three Hungarian words with accents**
|
||||
(`képző-szkítia-ásatás`) inside an otherwise English e-mail. It can be pasted; it cannot be read to
|
||||
anyone over the phone, and it cannot be retyped by someone whose keyboard has no accents.
|
||||
|
||||
- **A second embedded list, `internal/configgen/english.txt`** — the EFF "large" diceware list
|
||||
(7776 words, CC BY 3.0 US, https://www.eff.org/dice). It is **the same file felhom-agent already
|
||||
embeds** to mint the customer recovery code, copied rather than imported because the two binaries
|
||||
share no module. Provenance and licence are recorded in the source.
|
||||
- **`RandomPassphraseFor(lang, use)`** chooses the list **and** the word count together, so the two
|
||||
cannot be picked apart and a caller cannot defeat the floor by passing an English list with a
|
||||
Hungarian count. `Use` is `SetupCode` or `OwnerPassphrase`.
|
||||
- **The rule: per use, the English code carries at least as many bits as the Hungarian one.** The
|
||||
English list is smaller (7772 vs 29 609 words after filtering; 12.92 vs 14.85 bits/word), so
|
||||
English takes one more word:
|
||||
|
||||
| use | hu | en |
|
||||
|---|---|---|
|
||||
| setup / reset code | 3 words, 44.56 bits | **4 words, 51.70 bits** |
|
||||
| owner passphrase | 5 words, 74.27 bits | **6 words, 77.54 bits** |
|
||||
|
||||
`TestEnglishIsNeverWeakerThanHungarian` computes **both sides from the embedded lists' real
|
||||
lengths** and the shipped table — not a constant against itself. Red-proofed by setting the
|
||||
English setup code to 3 words; it named the 38.77-vs-44.56 gap.
|
||||
- **All four callers pass a language**: the claim engine (`CustomerLanguage`), the regenerate-password
|
||||
handler (`CustomerLanguage`), the create-customer form (**the form's own field** — there is no
|
||||
stored customer yet, so `CustomerLanguage` would answer Hungarian for every English household
|
||||
created), and the config-invented-from-a-report path (`i18n.Default`, matching the `Language` that
|
||||
same struct sets two lines below).
|
||||
- **The English bind-page hint is now count-free** ("The word phrase you received from your operator
|
||||
during setup"). It said "five words", which stops being true for an English household the moment
|
||||
their passphrase is six — and would also have been wrong for every English household whose
|
||||
passphrase was issued before this release. Hungarian is unchanged, and „öt szó" stays correct.
|
||||
- **Hungarian is untouched**: same list, same counts, same words. `TestHungarianCodesUnchanged` pins
|
||||
the counts AND the list length, so a list swap cannot move the Hungarian floor quietly.
|
||||
|
||||
**Three claims in the task that were wrong, found at source:**
|
||||
|
||||
1. **The hub does not mint the recovery code.** `felhom-agent` does, in `internal/escrow`, and it has
|
||||
drawn from this same EFF list since it was written — so the recovery code has **always been
|
||||
English**, ten words, ≈129 bits. No work was needed and none was done: adding a row for it here
|
||||
would have created a second definition of a secret this repo does not own.
|
||||
2. **No claim mail states a word count.** They say `Setup code: %s`. The only count wording in the
|
||||
product is on the bind page, and that is what changed.
|
||||
3. **The proposed "read it over the phone" filter** — drop any word differing from another by one
|
||||
letter within its first six — was **measured before adoption** and **not adopted**: it removes
|
||||
**5270 of 7772 words**, 68% of the list, taking it from 12.92 to 11.29 bits/word. It would also
|
||||
have made this list stricter than the one the product already uses for the code a household writes
|
||||
on paper during a disaster. What it was reaching for is kept as an assertion rather than a filter:
|
||||
`TestEnglishListIsTranscribable` pins that every word is 3-9 lower-case ASCII letters with no
|
||||
digit and no separator. Recorded as a decision (CC, operator may reverse) in the source.
|
||||
|
||||
## v0.118.1 — the test mail follows the language too (2026-09-18, R-558)
|
||||
|
||||
Found during v0.118.0's own live proof, which is the only reason it was found: I went to press "send
|
||||
|
||||
@@ -16,10 +16,15 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// codeWords is the claim/reset code length: 3 Hungarian words (~44 bits with the ~29K list) —
|
||||
// dictatable over the phone, and the controller's 5-attempt/15-min lockout makes online guessing
|
||||
// infeasible.
|
||||
const codeWords = 3
|
||||
// The claim/reset code length is no longer a constant here: it is per language, and it lives in ONE
|
||||
// place — configgen.wordCounts, read through configgen.WordCountFor (R-597, v0.119.0).
|
||||
//
|
||||
// It used to be `const codeWords = 3` with the note "3 Hungarian words (~44 bits with the ~29K
|
||||
// list) — dictatable over the phone". Every word of that is still true for a Hungarian household,
|
||||
// and their code is unchanged. It was never true for an English one: they got the same three
|
||||
// Hungarian words, accents and all, inside an English e-mail. The English code is four words from
|
||||
// the EFF list (~51.7 bits — MORE than the Hungarian 44.6, never less), because the English list is
|
||||
// smaller. The controller's 5-attempt/15-minute lockout is unchanged and language-blind.
|
||||
|
||||
// maxResetPerDay is the hub-side cap on controller-forwarded reset requests (per customer).
|
||||
const maxResetPerDay = 3
|
||||
@@ -67,9 +72,14 @@ func (e *Engine) logf(f string, a ...any) {
|
||||
}
|
||||
}
|
||||
|
||||
// newCode generates a fresh code and its bcrypt hash.
|
||||
func newCode() (code, hash string, err error) {
|
||||
code, err = configgen.RandomPassphrase(codeWords)
|
||||
// newCode generates a fresh code and its bcrypt hash, in the household's language.
|
||||
//
|
||||
// The BOX is untouched by this: it stores and compares a bcrypt hash of whatever was minted, with no
|
||||
// notion of which list the words came from (controller internal/web/claim.go). So an English code is
|
||||
// accepted by exactly the same path a Hungarian one is, including the TTL, the single-use
|
||||
// generation and the lockout.
|
||||
func newCode(lang string) (code, hash string, err error) {
|
||||
code, err = configgen.RandomPassphraseFor(lang, configgen.UseSetupCode)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("claim: generating code: %w", err)
|
||||
}
|
||||
@@ -84,7 +94,7 @@ func newCode() (code, hash string, err error) {
|
||||
// keeps the rotated hash (the gate stays armed) and is LOUD: the operator sees emailed_at unset
|
||||
// on the customer page and can resend. Returns the new generation.
|
||||
func (e *Engine) rotateAndSend(cc *store.CustomerConfig, kind EmailKind) (int, error) {
|
||||
code, hash, err := newCode()
|
||||
code, hash, err := newCode(e.Store.CustomerLanguage(cc.CustomerID))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package claim
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-597 — THE WIRING, not the generator.
|
||||
//
|
||||
// configgen's own tests prove it CAN mint an English code. These prove the engine ASKS it to: the
|
||||
// mechanism-vs-consequence distinction this project has been bitten by (R-97b). A perfectly correct
|
||||
// generator that nobody calls with the household's language leaves the English drill exactly where
|
||||
// it was.
|
||||
|
||||
// isASCIILower is the property that actually matters to a household: every letter is on their
|
||||
// keyboard. A Hungarian code from the ~29K list carries accents on almost every draw.
|
||||
func isASCIILower(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// An English household's setup code is made of English words — asserted on the code the MAILER
|
||||
// received, which is the string that reaches the customer's inbox.
|
||||
func TestSetupCodeFollowsTheHouseholdLanguage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
lang string
|
||||
wantWords int
|
||||
wantASCII bool
|
||||
}{
|
||||
{"en", 4, true},
|
||||
{"hu", 3, false},
|
||||
} {
|
||||
e, st, m := newTestEngine(t)
|
||||
cc := cust()
|
||||
cc.Language = tc.lang
|
||||
if err := st.SaveCustomerConfig(cc); err != nil {
|
||||
t.Fatalf("[%s] SaveCustomerConfig: %v", tc.lang, err)
|
||||
}
|
||||
if _, err := e.EnsureIssued(cc); err != nil {
|
||||
t.Fatalf("[%s] EnsureIssued: %v", tc.lang, err)
|
||||
}
|
||||
if len(m.sends) != 1 {
|
||||
t.Fatalf("[%s] expected one mail, got %v", tc.lang, m.sends)
|
||||
}
|
||||
words := strings.Split(m.lastCode, "-")
|
||||
if len(words) != tc.wantWords {
|
||||
t.Errorf("[%s] the code has %d words, want %d (code shape only — the code itself is never "+
|
||||
"logged): %d segments", tc.lang, len(words), tc.wantWords, len(words))
|
||||
}
|
||||
if tc.wantASCII && !isASCIILower(m.lastCode) {
|
||||
t.Errorf("[%s] the mailed setup code is not plain ASCII — an English household cannot type "+
|
||||
"it. This is exactly what the 2026-09-20 drill received.", tc.lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A household created as Hungarian whose BOX later reports English: the next code follows the
|
||||
// language the hub would write the mail in, which is CustomerLanguage's order (reported → created →
|
||||
// Hungarian). Documented in 05-hub-architecture.md; pinned here so the two cannot drift.
|
||||
func TestANewCodeFollowsTheSameOrderAsTheMail(t *testing.T) {
|
||||
e, st, m := newTestEngine(t)
|
||||
cc := cust()
|
||||
cc.Language = "hu"
|
||||
if err := st.SaveCustomerConfig(cc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.EnsureIssued(cc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(strings.Split(m.lastCode, "-")); n != 3 {
|
||||
t.Fatalf("the created-as-Hungarian code has %d words, want 3", n)
|
||||
}
|
||||
|
||||
// The box now reports that the household switched their dashboard to English.
|
||||
if err := st.SaveReport("c1", []byte(`{"language":"en"}`)); err != nil {
|
||||
t.Fatalf("SaveReport: %v", err)
|
||||
}
|
||||
if got := st.CustomerLanguage("c1"); got != "en" {
|
||||
t.Fatalf("CustomerLanguage is %q after an English report, want \"en\" — the fixture is wrong, "+
|
||||
"not the code under test", got)
|
||||
}
|
||||
// RequestReset is the real "Forgot password" path — the one the drill's missing step walks.
|
||||
if err := e.RequestReset(cc); err != nil {
|
||||
t.Fatalf("RequestReset: %v", err)
|
||||
}
|
||||
if !isASCIILower(m.lastCode) {
|
||||
t.Errorf("after the household switched to English, the NEXT code is still Hungarian — the code " +
|
||||
"and the mail that carries it now disagree about the language")
|
||||
}
|
||||
if n := len(strings.Split(m.lastCode, "-")); n != 4 {
|
||||
t.Errorf("the English code has %d words, want 4", n)
|
||||
}
|
||||
// Codes ALREADY ISSUED are untouched: rotation mints a new one, it does not retranslate an old
|
||||
// one. Nothing to assert beyond the generation moving, which the engine's own tests cover.
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -65,8 +65,8 @@
|
||||
"bind.placeholder.pairing": "ABC-234",
|
||||
"bind.hint.pairing": "It appears on the box's monitor, after the install.",
|
||||
"bind.label.passphrase": "Owner passphrase",
|
||||
"bind.placeholder.passphrase": "five words, with hyphens or spaces",
|
||||
"bind.hint.passphrase": "The five-word phrase you received during setup. It proves the account is yours.",
|
||||
"bind.placeholder.passphrase": "the words, with hyphens or spaces",
|
||||
"bind.hint.passphrase": "The word phrase you received from your operator during setup. It proves the account is yours.",
|
||||
"bind.submit": "Link the box",
|
||||
"bind.note": "For safety the link locks after 5 failed attempts. If that happens, contact support.",
|
||||
"bind.success.lead": "Linked successfully.",
|
||||
|
||||
@@ -708,8 +708,14 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate credentials
|
||||
retrievalPassword, err := configgen.RandomPassphrase(5)
|
||||
// Generate credentials.
|
||||
//
|
||||
// R-597: the Owner passphrase follows the language the operator is choosing ON THIS FORM, not
|
||||
// the one in the store — there is no stored customer yet, and CustomerLanguage would answer
|
||||
// Hungarian for every English household created here. The store's own createdLanguage applies
|
||||
// the same default to an absent or unknown value, so the two agree.
|
||||
createLang := i18n.Normalize(strings.TrimSpace(r.FormValue("language")))
|
||||
retrievalPassword, err := configgen.RandomPassphraseFor(createLang, configgen.UseOwnerPassphrase)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1032,7 +1038,10 @@ func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, cus
|
||||
|
||||
// handleConfigRegenPassword regenerates the retrieval password.
|
||||
func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
newPassword, err := configgen.RandomPassphrase(5)
|
||||
// R-597: a regenerated Owner passphrase follows the household's language exactly as their mails
|
||||
// do — CustomerLanguage's order is last-reported → created-with → Hungarian, so a household that
|
||||
// switched their own dashboard to English gets English words on the next regeneration.
|
||||
newPassword, err := configgen.RandomPassphraseFor(s.store.CustomerLanguage(customerID), configgen.UseOwnerPassphrase)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1445,8 +1454,10 @@ func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Req
|
||||
name = customer.CustomerName
|
||||
}
|
||||
|
||||
// Generate credentials
|
||||
retrievalPassword, _ := configgen.RandomPassphrase(5)
|
||||
// Generate credentials. The config below states Language: i18n.Default for this path (no
|
||||
// operator is present), so the passphrase is drawn for the SAME language — reading it from the
|
||||
// one line that decides it, rather than restating the choice.
|
||||
retrievalPassword, _ := configgen.RandomPassphraseFor(i18n.Default, configgen.UseOwnerPassphrase)
|
||||
apiKey, _ := configgen.RandomHex(32)
|
||||
|
||||
cfg := &store.CustomerConfig{
|
||||
|
||||
Reference in New Issue
Block a user