diff --git a/auth/email/email.go b/auth/email/email.go index b79b5cc..0694854 100755 --- a/auth/email/email.go +++ b/auth/email/email.go @@ -63,11 +63,14 @@ import ( "context" "errors" "fmt" + "golang.org/x/text/unicode/norm" "net" "net/mail" "strings" "sync" "time" + "unicode" + "unicode/utf8" "golang.org/x/net/idna" "golang.org/x/sync/singleflight" @@ -86,9 +89,10 @@ var idnaProfile = idna.Lookup const DefaultCacheTTL = 5 * time.Minute // maxCacheSize is the maximum number of domains held in the cache at once. -// If the cache is full when a new result arrives, it is silently dropped — -// the next request will query DNS again. Background eviction keeps the cache -// below this limit under normal operation. +// When the cache is full and a result arrives, store first drops every +// expired entry; if the cache is still full, the result is not cached and +// the next request for that domain queries DNS again. There is no +// background eviction since #135. const maxCacheSize = 10_000 // cacheEntry holds the result of a single MX lookup. @@ -183,19 +187,6 @@ func NewWithConfig(p authcore.Provider, cfg Config) (*Email, error) { // and always safe — including multiple times and from multiple goroutines. func (e *Email) Close() {} -// evictExpired deletes all expired entries from the cache, taking the write -// lock itself. -func (e *Email) evictExpired() { - e.mu.Lock() - defer e.mu.Unlock() - now := time.Now() - for k, v := range e.cache { - if now.After(v.expiresAt) { - delete(e.cache, k) - } - } -} - // Name implements authcore.Module. func (e *Email) Name() string { return "email" } @@ -249,14 +240,18 @@ func (e *Email) ValidateAndNormalize(address string) (string, error) { // do not catch a leading-hyphen label, and a malformed name has no // canonical form to store or query. func normalize(address string) (string, error) { - lower := strings.ToLower(strings.TrimSpace(address)) - atIdx := strings.LastIndexByte(lower, '@') + trimmed := strings.TrimSpace(address) + atIdx := strings.LastIndexByte(trimmed, '@') if atIdx < 0 { // Addresses without an "@" fail validation regardless of IDN, so // leaving the input untouched here produces a clearer error path. - return lower, nil + return strings.ToLower(trimmed), nil } - local, domain := lower[:atIdx], lower[atIdx+1:] + local, err := canonicalLocalPart(trimmed[:atIdx]) + if err != nil { + return "", err + } + domain := strings.ToLower(trimmed[atIdx+1:]) ascii, err := idnaProfile.ToASCII(domain) if err != nil { return "", &emailViolation{reason: fmt.Errorf("domain %q is not a valid internationalised name: %w", domain, err)} @@ -264,6 +259,28 @@ func normalize(address string) (string, error) { return local + "@" + ascii, nil } +// canonicalLocalPart returns the one canonical spelling of a local part: NFC, +// then lowercased. Until 2026-09-25 the local part was lowercased as typed, +// so one mailbox had two canonical forms (precomposed and decomposed +// accents), two mailboxes could share one (U+212A KELVIN SIGN lowercases to +// "k", U+0130 to "i"), and control, format and line-separator characters +// travelled into the stored value. Each of those is refused now; the ASCII +// controls were already refused by net/mail. +func canonicalLocalPart(local string) (string, error) { + local = norm.NFC.String(local) + for _, r := range local { + switch { + case unicode.Is(unicode.Cc, r), unicode.Is(unicode.Cf, r), + unicode.Is(unicode.Zl, r), unicode.Is(unicode.Zp, r), + unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r): + return "", &emailViolation{reason: fmt.Errorf("local part holds an invisible or control character %U", r)} + case r >= utf8.RuneSelf && unicode.ToLower(r) < utf8.RuneSelf: + return "", &emailViolation{reason: fmt.Errorf("local part holds %U, which lowercases to an ASCII letter", r)} + } + } + return strings.ToLower(local), nil +} + // validate checks address against RFC 5321 / RFC 5322 rules. // It uses net/mail for syntax and then applies stricter structural checks. func validate(address string) error { diff --git a/auth/email/email_test.go b/auth/email/email_test.go index 7981419..337da58 100755 --- a/auth/email/email_test.go +++ b/auth/email/email_test.go @@ -480,25 +480,40 @@ func TestVerifyDomain_cacheDropsEntryWhenFull(t *testing.T) { } } -func TestEvictExpired_removesStaleKeepsLive(t *testing.T) { +// store is the only eviction there is since #135 removed the background +// goroutine: when the cache is full it drops the expired entries and then +// admits the new one. Until 2026-09-25 the test for eviction called a helper +// nothing in production called, so this path could be deleted with the suite +// green, and a full cache would then never admit another domain. +func TestStore_evictsExpiredEntriesWhenFull(t *testing.T) { m := newMod(t) m.mu.Lock() - m.cache["stale.example"] = cacheEntry{hasMX: true, expiresAt: time.Now().Add(-time.Second)} + for i := 0; i < maxCacheSize-1; i++ { + m.cache[fmt.Sprintf("stale%d.example", i)] = cacheEntry{hasMX: true, expiresAt: time.Now().Add(-time.Second)} + } m.cache["live.example"] = cacheEntry{hasMX: true, expiresAt: time.Now().Add(time.Minute)} m.mu.Unlock() - m.evictExpired() + m.store("new.example", cacheEntry{hasMX: true, expiresAt: time.Now().Add(time.Minute)}) m.mu.RLock() - _, staleOk := m.cache["stale.example"] + _, newOk := m.cache["new.example"] _, liveOk := m.cache["live.example"] + _, staleOk := m.cache["stale0.example"] + size := len(m.cache) m.mu.RUnlock() - if staleOk { - t.Error("evictExpired must remove stale entries") + if !newOk { + t.Error("a full cache of expired entries must admit the new domain") } if !liveOk { - t.Error("evictExpired must keep live entries") + t.Error("eviction must keep live entries") + } + if staleOk { + t.Error("eviction must remove expired entries") + } + if size != 2 { + t.Errorf("cache holds %d entries after eviction, want 2", size) } } diff --git a/auth/email/local_part_test.go b/auth/email/local_part_test.go new file mode 100644 index 0000000..bc01e21 --- /dev/null +++ b/auth/email/local_part_test.go @@ -0,0 +1,85 @@ +package email + +import ( + "context" + "errors" + "net" + "strings" + "testing" +) + +// The local part has one canonical spelling since 2026-09-25: NFC, then +// lowercased. Each refusal below is paired with an address of the same +// shape that is accepted. + +func TestValidateAndNormalize_localPartHasOneCanonicalForm(t *testing.T) { + m := newMod(t) + nfc, err := m.ValidateAndNormalize("José@Example.com") + if err != nil { + t.Fatalf("NFC input: %v", err) + } + nfd, err := m.ValidateAndNormalize("José@example.com") + if err != nil { + t.Fatalf("NFD input: %v", err) + } + if nfc != nfd || nfc != "josé@example.com" { + t.Fatalf("canonical forms differ or are not NFC lowercase: %q vs %q", nfc, nfd) + } +} + +// A non-ASCII letter whose lowercase is ASCII would give two mailboxes one +// canonical form. U+0130 has no canonical decomposition and folds to "i" by +// case mapping alone, so it is refused. U+212A KELVIN SIGN is canonically +// equivalent to K: NFC turns it into the letter before anything else looks, +// the same way it merges a decomposed accent, so it canonicalises to +// "kelly" rather than being refused. +func TestValidateAndNormalize_localPartThatFoldsIntoASCII(t *testing.T) { + m := newMod(t) + _, err := m.ValidateAndNormalize("\u0130nfo@example.com") + if !errors.Is(err, ErrInvalidEmail) || !strings.Contains(err.Error(), "lowercases to an ASCII letter") { + t.Errorf("capital dotted i: %v, want the fold refusal", err) + } + if got, err := m.ValidateAndNormalize("\u212aelly@example.com"); err != nil || got != "kelly@example.com" { + t.Errorf("kelvin sign: %q, %v; want the canonical kelly@example.com", got, err) + } + for _, addr := range []string{"kelly@example.com", "info@example.com", "KELLY@example.com"} { + if _, err := m.ValidateAndNormalize(addr); err != nil { + t.Errorf("%s: %v, want accepted", addr, err) + } + } +} + +func TestValidateAndNormalize_refusesInvisibleAndControlCharactersInTheLocalPart(t *testing.T) { + m := newMod(t) + for name, addr := range map[string]string{ + "zero width space": "admin​@example.com", + "soft hyphen": "ad­min@example.com", + "right-to-left override": "admin‮@example.com", + "next line (C1 control)": "admin\u0085@example.com", + "line separator": "admin
@example.com", + "hangul filler": "adminㅤ@example.com", + } { + _, err := m.ValidateAndNormalize(addr) + if !errors.Is(err, ErrInvalidEmail) || !strings.Contains(err.Error(), "invisible or control character") { + t.Errorf("%s: %v, want the invisible-character refusal", name, err) + } + } + if got, err := m.ValidateAndNormalize("ad-min.user+tag@example.com"); err != nil || got != "ad-min.user+tag@example.com" { + t.Fatalf("a plain local part: %q, %v", got, err) + } +} + +// An MX answer with no records and no error is a domain without MX, not one +// that accepts mail. The branch could be deleted with the suite green. +func TestVerifyDomain_emptyAnswerIsNoMX(t *testing.T) { + m := newMod(t) + stub := newStub([]*net.MX{}, nil) + m.resolver = stub + if err := m.VerifyDomain(context.Background(), "user@nomx.example"); !errors.Is(err, ErrDomainNoMX) { + t.Fatalf("empty answer: %v, want ErrDomainNoMX", err) + } + m.resolver = newStub([]*net.MX{{Host: "mx.example.", Pref: 10}}, nil) + if err := m.VerifyDomain(context.Background(), "user@hasmx.example"); err != nil { + t.Fatalf("a real record: %v, want nil", err) + } +} diff --git a/auth/password/password.go b/auth/password/password.go index a4f5b2f..a7c3376 100755 --- a/auth/password/password.go +++ b/auth/password/password.go @@ -22,7 +22,8 @@ // - Output: PHC string format — self-describing, portable // - Comparison: constant-time — immune to timing attacks // - Policy: Hash rejects weak passwords before spending CPU on them -// - Printable input only: Hash refuses control and invisible characters +// - Printable input only: Hash refuses control, format and other +// default-ignorable characters, and the blank braille pattern // // # What is tunable // @@ -246,7 +247,15 @@ func checkPolicy(plaintext string, cfg Config) error { // this check existed: "Abcdefghijk1\xff" passed the default policy with the // stray byte counted as its special character. func isPrintable(r rune) bool { - return r != utf8.RuneError && unicode.IsPrint(r) + if r == utf8.RuneError || !unicode.IsPrint(r) { + return false + } + // IsPrint admits code points that render as nothing: the Hangul fillers + // and the other default-ignorable letters and marks (U+115F, U+3164, + // U+FFA0, U+034F among them), and U+2800 BRAILLE PATTERN BLANK, a symbol. + // Measured 2026-09-25: "Abcdefghijk1" + U+2800 satisfied RequireSymbol + // with a character the user cannot see, the lockout #347 describes. + return r != 0x2800 && !unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r) } // isSpecial reports whether r satisfies RequireSymbol: Unicode punctuation diff --git a/auth/password/review_round2_test.go b/auth/password/review_round2_test.go new file mode 100644 index 0000000..91ab4af --- /dev/null +++ b/auth/password/review_round2_test.go @@ -0,0 +1,141 @@ +package password + +import ( + "errors" + "strings" + "testing" + + "golang.org/x/text/unicode/norm" +) + +// Controls the 2026-09-25 review found working but unpinned: each could be +// deleted with the package suite green. Every test names what its control +// holds, and the acceptance case sits next to the refusal. + +const nfdPassword = "Café-Espresso-99" // "é" as e + combining acute + +// Hash normalises to NFC before hashing, as Verify does before comparing. +// Without it a user who registers with a decomposed password never signs +// in with the same input. The suite only covered NFC-hash, NFD-verify. +func TestHash_normalisesTheInputItHashes(t *testing.T) { + mod := newMod(t) + hash, err := mod.Hash(nfdPassword) + if err != nil { + t.Fatalf("Hash(NFD): %v", err) + } + for name, input := range map[string]string{"the NFD form": nfdPassword, "the NFC form": norm.NFC.String(nfdPassword)} { + ok, err := mod.Verify(input, hash) + if err != nil || !ok { + t.Errorf("Verify(%s) = %v, %v; want true", name, ok, err) + } + } + if ok, _ := mod.Verify("Cafe-Espresso-99", hash); ok { + t.Fatal("a different password verified") + } +} + +// New snapshots each *bool policy field, so flipping the caller's value after +// New changes nothing. #437 pinned RequireSymbol only; three lines of four +// could go. +func TestNew_snapshotsEveryPolicyPointer(t *testing.T) { + for name, tc := range map[string]struct { + field func(*Config) **bool + password string // fails exactly that requirement + }{ + "RequireUpper": {func(c *Config) **bool { return &c.RequireUpper }, "abcdefghijk1!"}, + "RequireLower": {func(c *Config) **bool { return &c.RequireLower }, "ABCDEFGHIJK1!"}, + "RequireDigit": {func(c *Config) **bool { return &c.RequireDigit }, "Abcdefghijkl!"}, + "RequireSymbol": {func(c *Config) **bool { return &c.RequireSymbol }, "Abcdefghijk12"}, + } { + t.Run(name, func(t *testing.T) { + cfg := DefaultConfig() + on := true + *tc.field(&cfg) = &on + mod, err := New(fakeProvider{}, cfg) + if err != nil { + t.Fatal(err) + } + if err := mod.ValidatePolicy(tc.password); !errors.Is(err, ErrWeakPassword) { + t.Fatalf("before the flip, ValidatePolicy(%q) = %v, want ErrWeakPassword", tc.password, err) + } + on = false // the caller's variable, which the module must not read + if err := mod.ValidatePolicy(tc.password); !errors.Is(err, ErrWeakPassword) { + t.Fatalf("after flipping the caller's %s, ValidatePolicy(%q) = %v, want still refused", name, tc.password, err) + } + }) + } +} + +// The work-factor ceilings at New: a value past them is refused, one at +// them is accepted. Only the parse-time ceilings had tests. +func TestNew_refusesWorkFactorsPastTheCeilings(t *testing.T) { + for name, tc := range map[string]struct { + cfg Config + reason string + }{ + "memory past the ceiling": {Config{Memory: maxMemory + 1, Iterations: 1, Parallelism: 1}, "memory must be at most"}, + "iterations past the ceiling": {Config{Memory: minMemory, Iterations: maxIterations + 1, Parallelism: 1}, "iterations must be at most"}, + } { + _, err := New(fakeProvider{}, tc.cfg) + if !errors.Is(err, ErrInvalidConfig) || !strings.Contains(err.Error(), tc.reason) { + t.Errorf("%s: %v, want ErrInvalidConfig naming %q", name, err, tc.reason) + } + } + if _, err := New(fakeProvider{}, Config{Memory: minMemory, Iterations: maxIterations, Parallelism: 1}); err != nil { + t.Fatalf("iterations at the ceiling: %v, want accepted", err) + } +} + +// A stored hash whose parameter label is shorter than "label=" is refused, +// not sliced out of range. Without the prefix check in labeledValue, Verify +// panicked on "$argon2id$v$...", and the canonical-form check runs too late +// to help. +func TestVerify_refusesAHashWithABareLabel(t *testing.T) { + mod := newMod(t) + valid := phc(t, "argon2id", 19, minMemory, 3, 1) + for name, hash := range map[string]string{ + "bare v": strings.Replace(valid, "v=19", "v", 1), + "bare m": strings.Replace(valid, "m="+itoa(minMemory), "m", 1), + "bare t": strings.Replace(valid, "t=3", "t", 1), + "bare p": strings.Replace(valid, "p=1", "p", 1), + "empty": strings.Replace(valid, "v=19", "", 1), + } { + t.Run(name, func(t *testing.T) { + if hash == valid { + t.Fatal("fixture unchanged") + } + ok, err := mod.Verify("Correct-Horse-Battery-9", hash) + if ok || !errors.Is(err, ErrInvalidHash) { + t.Fatalf("Verify = %v, %v; want false, ErrInvalidHash", ok, err) + } + }) + } +} + +// Code points that render as nothing are refused, whichever class Unicode +// puts them in: the blank braille pattern is a symbol and satisfied +// RequireSymbol; the Hangul filler is a letter. A visible symbol with a +// variation selector stays accepted. +func TestValidatePolicy_refusesBlankRenderingCodePoints(t *testing.T) { + mod := newMod(t) + for name, pw := range map[string]string{ + "braille blank as the symbol": "Abcdefghijk1⠀", + "hangul filler": "Abcdefghijk1!ㅤ", + "combining grapheme joiner": "Abcdefghijk1!͏", + "zero width joiner": "Abcdefghijk1!‍", + } { + err := mod.ValidatePolicy(pw) + if !errors.Is(err, ErrWeakPassword) || !errors.Is(err, ErrNonPrintableCharacter) { + t.Errorf("%s: %v, want ErrNonPrintableCharacter", name, err) + } + } + for name, pw := range map[string]string{ + "plain": "Abcdefghijk1!", + "heart with selector": "Abcdefghijk1❤️", + "accented, real symbol": "Ábcdefghijk1!", + } { + if err := mod.ValidatePolicy(pw); err != nil { + t.Errorf("%s: %v, want accepted", name, err) + } + } +} diff --git a/auth/totp/enroll_test.go b/auth/totp/enroll_test.go index 8fe38ed..ee39a0a 100644 --- a/auth/totp/enroll_test.go +++ b/auth/totp/enroll_test.go @@ -247,8 +247,9 @@ func TestHashRecoveryCode_MatchesEnrollmentHashes(t *testing.T) { t.Fatalf("Enroll: %v", err) } for i, c := range enr.RecoveryCodes { - if got := mod.HashRecoveryCode(c); got != enr.RecoveryHashes[i] { - t.Errorf("HashRecoveryCode(%q) = %q, want %q", c, got, enr.RecoveryHashes[i]) + got, err := mod.HashRecoveryCode(c) + if err != nil || got != enr.RecoveryHashes[i] { + t.Errorf("HashRecoveryCode(%q) = %q, %v; want %q", c, got, err, enr.RecoveryHashes[i]) } } } @@ -260,7 +261,12 @@ func TestHashRecoveryCode_Peppered(t *testing.T) { p2 := fakeProvider{keys: fakeKeys{secret: []byte(strings.Repeat("z", 32))}} m1, _ := New(p1) m2, _ := New(p2) - if m1.HashRecoveryCode("ABCD1234-EFGH5678") == m2.HashRecoveryCode("ABCD1234-EFGH5678") { + h1, err1 := m1.HashRecoveryCode("ABCD1234-EFGH5678") + h2, err2 := m2.HashRecoveryCode("ABCD1234-EFGH5678") + if err1 != nil || err2 != nil { + t.Fatalf("HashRecoveryCode: %v, %v", err1, err2) + } + if h1 == h2 { t.Error("hash is identical under different server secrets; HMAC pepper is not applied") } } diff --git a/auth/totp/errors.go b/auth/totp/errors.go index 80e53ae..5865a01 100644 --- a/auth/totp/errors.go +++ b/auth/totp/errors.go @@ -57,4 +57,9 @@ var ( // // Safety: INTERNAL, a startup or programming error. Treat as a 500. ErrStepRecorderRequired = errors.New("totp: a StepRecorder is required") + + // ErrNotInitialised is returned by every method of a TOTP that New did + // not build: a zero value has no pepper, and a hash under an empty key + // is one anyone can compute. + ErrNotInitialised = errors.New("totp: module not initialised") ) diff --git a/auth/totp/totp.go b/auth/totp/totp.go index 0d6f7c0..0e3094a 100644 --- a/auth/totp/totp.go +++ b/auth/totp/totp.go @@ -125,6 +125,10 @@ type TOTP struct { log authcore.Logger secret []byte // HMAC-SHA256 pepper for recovery-code hashing clock clock.Clock // injected; replaced by clock.Fixed in tests + // initialised is set by New as its last act. A zero-value TOTP hashed + // recovery codes under an empty key, which anyone can compute, until + // 2026-09-25; every method now refuses on it. + initialised bool } // New creates a TOTP module. @@ -185,6 +189,7 @@ func New(p authcore.Provider, cfg ...Config) (*TOTP, error) { secret: secret, clock: clock.New(p.Config().Timezone), } + t.initialised = true t.log.Info("totp: module initialised (skew=%d, recovery_codes=%d, issuer=%q)", *resolved.SkewSteps, resolved.RecoveryCodeCount, resolved.Issuer) return t, nil @@ -200,6 +205,9 @@ func (t *TOTP) Name() string { return "totp" } // without the issuer parameter and the label, and the authenticator // displays only the account name. func (t *TOTP) Enroll(accountName string) (*Enrollment, error) { + if t == nil || !t.initialised { + return nil, ErrNotInitialised + } secretBytes, err := randomBytes(secretLen) if err != nil { return nil, fmt.Errorf("totp: generate secret: %w", err) @@ -273,6 +281,9 @@ func (t *TOTP) Enroll(accountName string) (*Enrollment, error) { // totp.ErrInvalidSecret - secret is not base32, or is not 20 bytes decoded // totp.ErrCodeReused - matches a step at or below lastUsedStep func (t *TOTP) VerifyStep(secret, code string, lastUsedStep uint64) (uint64, error) { + if t == nil || !t.initialised { + return 0, ErrNotInitialised + } if !isSixDigits(code) { return 0, ErrMalformedCode } @@ -367,6 +378,9 @@ func (t *TOTP) VerifyStep(secret, code string, lastUsedStep uint64) (uint64, err // return http.StatusUnauthorized // } func (t *TOTP) Verify(ctx context.Context, secret, code string, rec StepRecorder) error { + if t == nil || !t.initialised { + return ErrNotInitialised + } if isNilRecorderValue(rec) { return ErrStepRecorderRequired } @@ -408,8 +422,14 @@ func isNilRecorderValue(rec StepRecorder) bool { // HashRecoveryCode returns the keyed HMAC-SHA256 hex digest of code, // matching the values stored in Enrollment.RecoveryHashes. -func (t *TOTP) HashRecoveryCode(code string) string { - return t.hashRecoveryCode(code) +// +// It returns ErrNotInitialised on a zero-value TOTP, whose hash would be +// keyed with nothing. +func (t *TOTP) HashRecoveryCode(code string) (string, error) { + if t == nil || !t.initialised { + return "", ErrNotInitialised + } + return t.hashRecoveryCode(code), nil } // VerifyRecoveryCode reports whether code matches any of storedHashes, @@ -422,6 +442,9 @@ func (t *TOTP) HashRecoveryCode(code string) string { // stripped, letters are uppercased), so users can read codes off a // printout with any grouping they like. func (t *TOTP) VerifyRecoveryCode(code string, storedHashes []string) (int, bool) { + if t == nil || !t.initialised { + return 0, false + } candidate := t.hashRecoveryCode(normalizeRecoveryCode(code)) var matchedIdx int var matched byte diff --git a/auth/totp/totp_fuzz_test.go b/auth/totp/totp_fuzz_test.go index f038b5c..7c58154 100644 --- a/auth/totp/totp_fuzz_test.go +++ b/auth/totp/totp_fuzz_test.go @@ -1,8 +1,14 @@ package totp import ( + "context" + "crypto/hmac" + "crypto/sha1" "crypto/sha256" + "encoding/base32" + "encoding/binary" "encoding/hex" + "fmt" "strconv" "strings" "testing" @@ -11,19 +17,37 @@ import ( "github.com/Glyndor/authcore/internal/clock" ) -// FuzzVerify drives Verify with arbitrary secret/code pairs. Both inputs -// come from the network (the secret from the user's stored row, the -// code from the form), so neither must panic. Verify must also never -// report a successful match for a random input: with a fixed clock and -// a known-good secret, the only inputs that succeed are the published -// TOTP values for the current window, and the fuzzer corpus seeds -// deliberately miss those. +// hotpOracle is RFC 4226 written apart from the package: HMAC-SHA1 over the +// big-endian step, dynamic truncation, six digits. The fuzz oracle below +// compares Verify against it rather than against the package's own +// generator, so a shared mistake cannot pass. +func hotpOracle(key []byte, step uint64) string { + var msg [8]byte + binary.BigEndian.PutUint64(msg[:], step) + mac := hmac.New(sha1.New, key) + mac.Write(msg[:]) + sum := mac.Sum(nil) + offset := sum[len(sum)-1] & 0x0f + bin := (uint32(sum[offset])&0x7f)<<24 | uint32(sum[offset+1])<<16 | uint32(sum[offset+2])<<8 | uint32(sum[offset+3]) + return fmt.Sprintf("%06d", bin%1_000_000) +} + +// FuzzVerify drives VerifyStep and Verify with arbitrary secret/code pairs. +// Both inputs come from the network (the secret from the user's stored row, +// the code from the form), so neither must panic. With a fixed clock, a +// code is accepted exactly when the secret decodes to 20 bytes and the code +// is the RFC 4226 value for one of the steps in the window, computed here +// by hotpOracle. Until 2026-09-25 the target discarded both results, so a +// stepMatches that accepted every code passed 3.3 million executions. func FuzzVerify(f *testing.F) { mod, err := New(newFakeProvider(f)) if err != nil { f.Fatalf("totp.New: %v", err) } - mod.clock = clock.Fixed(time.Unix(1234567890, 0).UTC()) + fixed := time.Unix(1234567890, 0).UTC() + mod.clock = clock.Fixed(fixed) + now := uint64(fixed.Unix()) / timeStep + skew := uint64(*mod.cfg.SkewSteps) // Seed with realistic and adversarial inputs. enr, err := mod.Enroll("alice@example.com") @@ -40,13 +64,42 @@ func FuzzVerify(f *testing.F) { f.Add(enr.Secret, "12345") f.Add(enr.Secret, "1234567") f.Add(enr.Secret, "012345") // fullwidth digits + // The codes that must be accepted: each step of the window, and one + // just outside it that must not. + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(enr.Secret) + if err != nil { + f.Fatal(err) + } + for step := now - skew; step <= now+skew; step++ { + f.Add(enr.Secret, hotpOracle(key, step)) + } + f.Add(enr.Secret, hotpOracle(key, now-skew-1)) + f.Add(enr.Secret, hotpOracle(key, now+skew+1)) f.Fuzz(func(t *testing.T, secret, code string) { - // Both lastUsedStep values: 0 (no replay protection) and a - // large number (must reject everything as "already used" if - // it ever matched). Neither path may panic. - _, _ = mod.VerifyStep(secret, code, 0) - _, _ = mod.VerifyStep(secret, code, 1<<63) + // The oracle's verdict: the secret must be exactly 32 base32 + // characters of 20 bytes, and the code must be one of the window's. + want := false + if key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret); err == nil && len(key) == secretLen && len(secret) == 32 { + for step := now - skew; step <= now+skew; step++ { + if code == hotpOracle(key, step) { + want = true + } + } + } + + _, err := mod.VerifyStep(secret, code, 0) + if (err == nil) != want { + t.Fatalf("VerifyStep(%q, %q) = %v, oracle says accept=%v", secret, code, err, want) + } + // A step already used refuses everything the oracle accepts. + if _, err := mod.VerifyStep(secret, code, 1<<63); err == nil { + t.Fatalf("VerifyStep(%q, %q) accepted a code at a step below the recorded one", secret, code) + } + // Verify, the recording entry point, agrees with a fresh recorder. + if err := mod.Verify(context.Background(), secret, code, &memoryRecorder{}); (err == nil) != want { + t.Fatalf("Verify(%q, %q) = %v, oracle says accept=%v", secret, code, err, want) + } }) } @@ -112,7 +165,10 @@ func FuzzVerifyRecoveryCode(f *testing.F) { // Each variation is fed as a sub-call rather than as a // fuzzer argument because Go fuzzing accepts only a limited // set of types in the signature. - h := mod.HashRecoveryCode(code) + h, err := mod.HashRecoveryCode(code) + if err != nil { + t.Fatalf("HashRecoveryCode(%q): %v", code, err) + } for _, hashes := range cases { want := -1 for i, e := range hashes { diff --git a/auth/totp/zero_value_test.go b/auth/totp/zero_value_test.go new file mode 100644 index 0000000..9b65bc9 --- /dev/null +++ b/auth/totp/zero_value_test.go @@ -0,0 +1,43 @@ +package totp + +import ( + "context" + "errors" + "testing" +) + +// A TOTP that New did not build has no pepper. Every method refuses on it +// (2026-09-25); before, HashRecoveryCode hashed under an empty key and +// VerifyRecoveryCode compared against such hashes. +func TestZeroValueTOTP_refusesEveryMethod(t *testing.T) { + for name, mod := range map[string]*TOTP{"zero value": {}, "nil pointer": nil} { + t.Run(name, func(t *testing.T) { + if _, err := mod.Enroll("alice@example.com"); !errors.Is(err, ErrNotInitialised) { + t.Errorf("Enroll = %v", err) + } + if _, err := mod.VerifyStep("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP", "123456", 0); !errors.Is(err, ErrNotInitialised) { + t.Errorf("VerifyStep = %v", err) + } + if err := mod.Verify(context.Background(), "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP", "123456", &memoryRecorder{}); !errors.Is(err, ErrNotInitialised) { + t.Errorf("Verify = %v", err) + } + if h, err := mod.HashRecoveryCode("ABCD1234-EFGH5678"); !errors.Is(err, ErrNotInitialised) || h != "" { + t.Errorf("HashRecoveryCode = %q, %v", h, err) + } + // The hash anyone can compute under an empty key must not match. + empty := (&TOTP{}).hashRecoveryCode("ABCD1234EFGH5678") + if _, ok := mod.VerifyRecoveryCode("ABCD1234-EFGH5678", []string{empty}); ok { + t.Error("VerifyRecoveryCode accepted a hash keyed with nothing") + } + }) + } + // The built module still does all of it. + mod := newTOTP(t) + enr, err := mod.Enroll("alice@example.com") + if err != nil { + t.Fatal(err) + } + if h, err := mod.HashRecoveryCode(enr.RecoveryCodes[0]); err != nil || h != enr.RecoveryHashes[0] { + t.Fatalf("HashRecoveryCode on the built module = %q, %v", h, err) + } +} diff --git a/auth/username/username_fuzz_test.go b/auth/username/username_fuzz_test.go index f2251f6..401f707 100755 --- a/auth/username/username_fuzz_test.go +++ b/auth/username/username_fuzz_test.go @@ -24,6 +24,10 @@ func FuzzValidateAndNormalize(f *testing.F) { "alice!", "alice ", " alice", "admin", "root", strings.Repeat("a", 33), strings.Repeat("a", 32), "\x00alice", "alice\n", + // A disallowed byte between two allowed ones: the start and end + // rules refuse "alice!" and "\x00alice" on their own, so these are + // the seeds that reach isAllowed. + "al!ce", "al.ce", "al ce", "ali\x00ce", "al\u00e9ce", } for _, s := range seeds { f.Add(s) @@ -40,6 +44,22 @@ func FuzzValidateAndNormalize(f *testing.F) { if got != strings.ToLower(strings.TrimSpace(in)) { t.Fatalf("not canonical: in=%q got=%q", in, got) } + // The character set is the homoglyph control: every accepted byte + // is one of [a-z0-9_-], the length is within bounds, and the name + // is not reserved. Until 2026-09-25 the target checked none of it, + // so deleting isAllowed passed 390 thousand executions. + if len(got) < mod.minLen || len(got) > mod.maxLen { + t.Fatalf("accepted %q of length %d outside [%d, %d]", got, len(got), mod.minLen, mod.maxLen) + } + for i := 0; i < len(got); i++ { + c := got[i] + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-') { + t.Fatalf("accepted %q with byte %q outside [a-z0-9_-]", got, c) + } + } + if _, reserved := mod.reserved[got]; reserved { + t.Fatalf("accepted the reserved name %q", got) + } again, err := mod.ValidateAndNormalize(got) if err != nil { t.Fatalf("canonical form rejected: %q err=%v", got, err) diff --git a/docs/errors.md b/docs/errors.md index b01fccb..640dc1a 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -101,6 +101,7 @@ if errors.Is(err, jwt.ErrTokenExpired) { | `totp.ErrInvalidConfig` | ✗ No | `totp.Config` validation failed at startup (treat as 500) | | `totp.ErrInvalidSecret` | ✗ No | Stored secret is not base32 or not 20 bytes decoded (storage corruption) | | `totp.ErrMalformedCode` | ✓ Yes | Presented code is not six decimal digits | +| `totp.ErrNotInitialised` | ✗ No | Method called on a `TOTP` that `New` did not build (a zero value) | | `totp.ErrInvalidCode` | ✓ Yes | Presented code does not match any step in the window | | `totp.ErrCodeReused` | ✓ Yes | Recorder refused to advance the stored step; the code (or its step) was already accepted | | `totp.ErrStepRecorderRequired` | ✗ No | `Verify` was called with a nil `StepRecorder` (programming error, treat as 500) | diff --git a/docs/password.md b/docs/password.md index 74736af..8082d48 100644 --- a/docs/password.md +++ b/docs/password.md @@ -64,6 +64,9 @@ four `Require*` fields off: - control characters: NUL, tab, newline, DEL and the rest of C0 and C1 - invisible format characters, such as the zero-width joiner U+200D +- the other code points Unicode marks default-ignorable, such as the Hangul + filler U+3164 and the combining grapheme joiner U+034F, and the blank + braille pattern U+2800, all of which render as nothing - every space other than the ASCII one, such as the no-break space U+00A0 - unassigned and private-use code points - bytes that are not valid UTF-8, and the replacement character U+FFFD