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:
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user