fix: auth/2FA security — stdout-log code delivery is dev/test-only, production fails closed until email/SMS; verification-code hashing, lockout recovery, sabredav fail-closed

- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak
- verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle
- sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD
- delete-account re-auth (current_password + fresh 2FA code when enforced)
- prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check
- startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals
- env-docs parity updated (flag removed, 38 vars)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 1429eddd34
commit f9e8385d5a
19 changed files with 1047 additions and 472 deletions
+186 -19
View File
@@ -21,6 +21,7 @@ package auth
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
@@ -32,6 +33,7 @@ import (
"crussell/auth"
"crussell/clock"
"crussell/db"
"crussell/internal/twofa"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
@@ -46,6 +48,25 @@ func resetTestData(t *testing.T) (context.Context, db.Querier) {
return ctx, tx
}
// insertVerificationCode creates a verification_codes row storing only the
// digest of a fresh random code (mirroring GenerateVerificationCodeHandler —
// the code column holds twofa.Hash(plaintext), never the plaintext) and
// returns the plaintext so the test can submit it to VerifyCodeHandler exactly
// like a delivered code would be used.
func insertVerificationCode(ctx context.Context, q db.Querier, userID, purpose string, expiresAt time.Time) (string, error) {
code, err := generateVerificationCode()
if err != nil {
return "", err
}
_, err = q.Exec(ctx,
`INSERT INTO verification_codes (user_id, purpose, code, expires_at) VALUES ($1, $2, $3, $4)`,
userID, purpose, twofa.Hash(code), expiresAt)
if err != nil {
return "", err
}
return code, nil
}
// =============================================================================
// Register Handler Tests
// =============================================================================
@@ -650,12 +671,11 @@ func TestVerifyCheck_ValidCode(t *testing.T) {
}
defer fixtures.DeleteUser(tx, userID)
// Create a verification code
// Create a verification code (only the digest is stored — see
// insertVerificationCode); the test submits the plaintext code.
var code string
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
@@ -683,7 +703,7 @@ func TestVerifyCheck_ValidCode(t *testing.T) {
// Verify code is marked as used
var usedAt *time.Time
err = tx.QueryRow(ctx,
"SELECT used_at FROM verification_codes WHERE code = $1", code).Scan(&usedAt)
"SELECT used_at FROM verification_codes WHERE code = $1", twofa.Hash(code)).Scan(&usedAt)
if err != nil || usedAt == nil {
t.Error("expected verification code to be marked as used")
}
@@ -726,9 +746,7 @@ func TestVerifyCheck_ExpiredCode(t *testing.T) {
// Create an expired verification code
var code string
expiresAt := clock.Now().Add(-1 * time.Hour) // Expired 1 hour ago
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
@@ -888,9 +906,7 @@ func TestVerifyCheck_AlreadyUsed(t *testing.T) {
// Create a verification code
var code string
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
@@ -942,9 +958,7 @@ func TestVerifyCheck_RoleChangeToVerified(t *testing.T) {
// Create a verification code
var code string
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
@@ -1757,9 +1771,8 @@ func TestVerifyCheck_AttemptBudget_LocksOutAfterFive(t *testing.T) {
defer fixtures.DeleteUser(tx, userID)
var realCode string
expiresAt := clock.Now().Add(24 * time.Hour)
if err := tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&realCode); err != nil {
realCode, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
if err != nil {
t.Fatalf("failed to create verification code: %v", err)
}
defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
@@ -1784,14 +1797,168 @@ func TestVerifyCheck_AttemptBudget_LocksOutAfterFive(t *testing.T) {
t.Errorf("expected 429 for a spent budget, got %d. body: %s", w.Code, w.Body.String())
}
// A DIFFERENT code (the real one) is unaffected by the spent budget and
// verifies successfully — budgets are per code.
// A DIFFERENT code (the real one) is unaffected by the spent miss-path
// budget and verifies successfully: the miss-path budget is keyed per
// submitted code value (a guess cannot resolve a user), while the real
// code resolves to the user and uses the (fresh) per-user budget.
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: realCode}, ctx)
if w.Code != http.StatusOK {
t.Errorf("a valid code must still verify after another code's budget was spent, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestVerifyGenerate_StoresDigestNotPlaintext pins the MEDIUM finding fix:
// the verification_codes.code column stores ONLY the twofa.Hash digest (a
// 64-char hex SHA-256), never the 12-hex-char plaintext the old schema stored.
func TestVerifyGenerate_StoresDigestNotPlaintext(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
userID, err := fixtures.CreateTestUserWithEmail(tx, "hash-at-rest@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate",
VerificationCodeRequest{Email: "hash-at-rest@test.com"}, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var stored string
err = tx.QueryRow(ctx,
`SELECT code FROM verification_codes WHERE user_id = $1 AND purpose = 'email_verify'`, userID).Scan(&stored)
if err != nil {
t.Fatalf("failed to read stored code: %v", err)
}
if len(stored) != 64 {
t.Errorf("expected a 64-char hex digest at rest, got %q (len %d)", stored, len(stored))
}
if _, err := hex.DecodeString(stored); err != nil {
t.Errorf("stored code %q is not hex: %v", stored, err)
}
}
// TestVerifyCheck_PasswordReset_ClearsLoginLockout pins the HIGH finding fix:
// verifying a password_reset code clears a locked-out account's
// failed_attempts/locked_until, so the owner can log in again and change their
// password — the self-service recovery for the repeatable login-DoS.
func TestVerifyCheck_PasswordReset_ClearsLoginLockout(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(VerifyCodeHandler)
userID, err := fixtures.CreateTestUserWithEmail(tx, "reset@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Simulate the attacker-lockout state (7 failures → 30-minute lock).
_, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 7, locked_until = NOW() + INTERVAL '30 minutes' WHERE id = $1`, userID)
if err != nil {
t.Fatalf("failed to lock account: %v", err)
}
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposePasswordReset, clock.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create password_reset code: %v", err)
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for a valid password_reset code, got %d. body: %s", w.Code, w.Body.String())
}
var failed int
var locked *time.Time
err = tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failed, &locked)
if err != nil {
t.Fatalf("failed to read lockout state: %v", err)
}
if failed != 0 {
t.Errorf("expected failed_attempts reset to 0, got %d", failed)
}
if locked != nil {
t.Errorf("expected locked_until cleared, got %v", locked)
}
// The unlocked account can log in again with the correct password.
login := http.HandlerFunc(LoginHandler)
w = testutils.MakeRequestNoAuth(login, "POST", "/api/login",
LoginRequest{Email: "reset@test.com", Password: "testpassword123"}, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected login to succeed after lockout cleared, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestVerifyCheck_AttemptBudget_KeyedPerUser pins the MEDIUM finding fix: once
// a submitted code resolves to a user (an existing-but-expired row), the
// brute-force budget follows the USER, so draining it with one code value
// exhausts it for every other code value of that user — and a different user's
// budget stays independent.
func TestVerifyCheck_AttemptBudget_KeyedPerUser(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
handler := http.HandlerFunc(VerifyCodeHandler)
userID, err := fixtures.CreateTestUserWithEmail(tx, "budget-user@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
otherID, err := fixtures.CreateTestUserWithEmail(tx, "budget-other@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create second test user: %v", err)
}
defer fixtures.DeleteUser(tx, otherID)
// Five expired codes for the same user, each burned on the expired path.
for i := 0; i < 5; i++ {
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, clock.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("failed to create expired code: %v", err)
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
if i < 4 {
if w.Code != http.StatusBadRequest {
t.Fatalf("attempt %d: expected 400 for an expired code, got %d. body: %s", i+1, w.Code, w.Body.String())
}
} else {
if w.Code != http.StatusTooManyRequests {
t.Fatalf("attempt 5: expected 429, got %d. body: %s", w.Code, w.Body.String())
}
}
}
// A SIXTH expired code for the SAME user must be rejected 429 up front: the
// budget followed the user, not each distinct code value.
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, clock.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("failed to create sixth expired code: %v", err)
}
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 for the same user's next code (per-user budget), got %d. body: %s", w.Code, w.Body.String())
}
// A DIFFERENT user's fresh valid code still verifies — budgets are per user.
otherCode, err := insertVerificationCode(ctx, tx, otherID, verificationCodePurposeEmailVerify, clock.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create other user's code: %v", err)
}
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: otherCode}, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected the other user's valid code to verify, got %d. body: %s", w.Code, w.Body.String())
}
}
// ValidateUKPhoneNumber Security Tests
//
// These tests verify that ValidateUKPhoneNumber rejects or sanitises
+231 -77
View File
@@ -6,9 +6,12 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/dav"
"crussell/internal/twofa"
"crussell/internal/validators"
"crussell/internal/zxcvbnjs"
"crussell/mw"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -103,17 +106,41 @@ func CleanupStaleLoginEntries(ctx context.Context) (int, error) {
return 0, nil
}
// Email-verification attempt budget (Round 2 Loop A finding 8): POST
// /verify/check had no per-user attempt counter, so a client holding a code
// could fail it indefinitely and the endpoint doubled as an unbounded guessing
// oracle. Mirror the 2FA attempt pattern: an in-memory map keys a 5-attempt
// budget per submitted code. The code is the only identifier a wrong guess
// carries, and every code is user-scoped (one code belongs to exactly one
// user), so the budget is effectively per-user-per-code — a distinct user can
// never drain another's budget for the same code. A successful verify clears
// the entry; the 5th failed attempt exhausts the budget (429). The map is
// bounded and stale entries are evicted, so a flood of random guesses cannot
// grow it without bound.
// dummyPasswordHash is a real bcrypt hash of a fixed throwaway string, used to
// burn the same constant-time bcrypt work on the login no-user path as a real
// wrong-password compare (see LoginHandler). It MUST be a well-formed bcrypt
// hash: CompareHashAndPassword on a malformed hash returns immediately (fast),
// which would reintroduce the timing oracle it exists to remove.
const dummyPasswordHash = "$2a$10$x9x4AEOAU.UbaGCmVsVwu.TUhfOfR2LfbmWjB/H2At8Sx69WIlkri"
// Verification-code purpose values (verification_purpose enum in init-script.sql).
const (
verificationCodePurposeEmailVerify = "email_verify"
verificationCodePurposePasswordReset = "password_reset"
)
// verificationCodePepperEnv is the environment variable whose value keys the
// HMAC-SHA256 of stored verification codes (the SAME pepper the 2FA path uses —
// crussell/internal/twofa Hash). Read through the build-tagged
// verificationCodeEnsureIssueAllowed (verifycode_dev.go / verifycode_prod.go):
// dev/test builds fall back to the legacy plain SHA-256 digest with the 2FA
// warning, while production builds refuse to issue codes without the pepper.
const verificationCodePepperEnv = "TWO_FACTOR_PEPPER"
// Email-verification attempt budget (Round 2 Loop A finding 8 + hardening):
// POST /verify/check had no per-user attempt counter, so a client holding a
// code could fail it indefinitely and the endpoint doubled as an unbounded
// guessing oracle. Mirror the 2FA attempt pattern: an in-memory map keys a
// 5-attempt budget. The key is the RESOLVED USER id whenever a submitted code
// matches a verification_codes row (a code belongs to exactly one user, so the
// budget follows the ACCOUNT being attacked, not the submitted code value) and
// the submitted code value only when no row exists to resolve a user (a pure
// guess cannot be attributed). Keying per-user closes the evasion where an
// attacker holding several codes for one victim (or probing which values are
// live) drained a fresh budget per code. A successful verify clears the entry;
// the 5th failed attempt exhausts the budget (429). The map is bounded and
// stale entries are evicted, so a flood of random guesses cannot grow it
// without bound.
const (
emailVerifyMaxAttempts = 5
emailVerifyAttemptWindow = 30 * time.Minute
@@ -130,39 +157,40 @@ var (
emailVerifyAttempts = make(map[string]emailVerifyAttempt)
)
// emailVerifyAttemptsExhausted reports whether the submitted code's attempt
// budget is already spent, rejecting the request before any DB work.
func emailVerifyAttemptsExhausted(code string) bool {
// emailVerifyAttemptsExhausted reports whether the key's (a user id, or a
// submitted code with no resolvable user) attempt budget is already spent,
// rejecting the request before any DB work.
func emailVerifyAttemptsExhausted(key string) bool {
emailVerifyMu.Lock()
defer emailVerifyMu.Unlock()
evictStaleEmailVerifyAttemptsLocked()
a, ok := emailVerifyAttempts[code]
a, ok := emailVerifyAttempts[key]
return ok && a.count >= emailVerifyMaxAttempts
}
// emailVerifyAttemptFailed registers one failed verification attempt for the
// submitted code and reports whether the budget for that code is now exhausted
// (the handler should respond 429).
func emailVerifyAttemptFailed(code string) bool {
// key and reports whether the budget for that key is now exhausted (the handler
// should respond 429).
func emailVerifyAttemptFailed(key string) bool {
emailVerifyMu.Lock()
defer emailVerifyMu.Unlock()
evictStaleEmailVerifyAttemptsLocked()
now := clock.Now()
a := emailVerifyAttempts[code]
a := emailVerifyAttempts[key]
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
a.count = 0
}
a.count++
a.lastAt = now
emailVerifyAttempts[code] = a
emailVerifyAttempts[key] = a
return a.count >= emailVerifyMaxAttempts
}
// emailVerifyAttemptsClear drops the budget for a code after a successful
// emailVerifyAttemptsClear drops the budget for a key after a successful
// verify (the code is consumed; the entry would only leak stale state).
func emailVerifyAttemptsClear(code string) {
func emailVerifyAttemptsClear(key string) {
emailVerifyMu.Lock()
delete(emailVerifyAttempts, code)
delete(emailVerifyAttempts, key)
emailVerifyMu.Unlock()
}
@@ -476,6 +504,18 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
`, req.Email).Scan(&userID, &passwordHash, &role)
if err != nil {
// F2-HIGH (user-existence timing oracle): a non-existent email used to
// return before any bcrypt work, so its latency (~1 DB round trip) was
// measurably shorter than a wrong-password attempt against an existing
// account (~1 DB round trip + ~60ms bcrypt) — an attacker could probe
// which emails are registered from response timing. Burn the same
// constant-time bcrypt compare a real login would, under the shared
// bcrypt slot budget, and discard the result. The dummy hash is a real
// bcrypt hash (see dummyPasswordHash) so the compare runs the full cost.
if release, ok := acquireBcryptSlot(); ok {
_ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(req.Password))
release()
}
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
@@ -601,25 +641,26 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
}
// On success, clear lockout and update last_login
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
// failed_attempts and locked_until — a locked-out user can't call this handler.
//
// LOW 6 documented gap (finding 6): there is NO password-reset UI — the
// backend-only reset flow (GenerateVerificationCodeHandler/VerifyCodeHandler)
// has no frontend link, so a user locked out by a guessing attacker has no
// self-service recovery until locked_until lapses (15min at 5+ failures,
// 30min at 7+, 60min at 10+ — see the failure path above); the operator can
// only intervene at the DB. The escalating ceiling is the repeat-DoS
// mitigation: an attacker who keeps guessing past each unlock makes the lock
// LONGER (up to 60 minutes) instead of merely sustaining the 15-minute tier,
// raising the effort-per-DoS ratio while the response stays the uniform 401
// (never distinguishable from a wrong password). The lockout counter stays
// keyed per-user (not per-(user,IP)) because this codebase deliberately
// rejects IP-in-the-key for account-level budgets (see the 2FA limiter note
// in main.go, B8): a client that rotates its source IP would mint a fresh
// bucket per IP and collapse the per-account budget. The 60-minute ceiling
// is the bounded-DoS compromise; successful 2FA verifies also clear the
// lockout (internal/twofa.Check).
// backend-only reset flow (GenerateVerificationCodeHandler/
// VerifyCodeHandler) has no frontend link, so a user locked out by a
// guessing attacker has no self-service recovery until locked_until lapses
// (15min at 5+ failures, 30min at 7+, 60min at 10+ — see the failure path
// above); the operator can only intervene at the DB. The escalating ceiling
// is the repeat-DoS mitigation: an attacker who keeps guessing past each
// unlock makes the lock LONGER (up to 60 minutes) instead of merely
// sustaining the 15-minute tier, raising the effort-per-DoS ratio while the
// response stays the uniform 401 (never distinguishable from a wrong
// password). The lockout counter stays keyed per-user (not per-(user,IP))
// because this codebase deliberately rejects IP-in-the-key for account-level
// budgets (see the 2FA limiter note in main.go, B8): a client that rotates
// its source IP would mint a fresh bucket per IP and collapse the per-account
// budget. The 60-minute ceiling is the bounded-DoS compromise; a successful
// 2FA verify also clears the lockout (internal/twofa.Check), and — since
// the HIGH finding wiring — so does a successful password_reset verification
// code (VerifyCodeHandler), giving a locked-out user a self-service recovery
// path (generate → verify → log in → change password).
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
@@ -804,6 +845,11 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
type VerificationCodeRequest struct {
Email string `json:"email" validate:"required,email,max=254"`
// Purpose is the verification_purpose the code authorises: "email_verify"
// (default, escalates unverified_email → verified_email) or "password_reset"
// (clears a login lockout — see VerifyCodeHandler). Validated in code so the
// comparison is case-insensitive after trim/lower.
Purpose string `json:"purpose,omitempty"`
}
type VerifyCodeRequest struct {
@@ -815,6 +861,37 @@ type VerificationResponse struct {
Message string `json:"message,omitempty"`
}
// generateVerificationCode returns a 12-hex-character code (48 bits of
// randomness), matching the old DB-default generator
// (gen_random_bytes(6) hex). Only its HMAC-SHA256 digest is ever persisted; the
// plaintext exists solely to be delivered out-of-band (dev [VERIFY] log relay,
// or the future SMTP channel) and is never stored.
func generateVerificationCode() (string, error) {
buf := make([]byte, 6)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// POST /api/verify/generate
// Creates a verification_codes row for the account matching the submitted
// email (if any), storing ONLY the HMAC-SHA256 digest of a fresh
// 12-hex-char code (pepper-keyed via crussell/internal/twofa — see
// verificationCodePepperEnv). The plaintext code is delivered build-dependently
// (verifycode_dev.go / verifycode_prod.go): dev/test builds write it to the
// server log ([VERIFY] prefix) — the loose-fake stand-in for the not-yet-wired
// email/SMS transport (P6) — while production builds fail closed when
// TWO_FACTOR_PEPPER is unset (an unsalted digest in the 48-bit code space
// would be offline-brute-forceable from a DB leak) or when no delivery channel
// is configured (email/SMS unwired; stdout-log delivery is a dev/test-only
// local feature). The response is IDENTICAL whether or not the email exists, so
// the endpoint cannot be used to enumerate registered addresses.
//
// The purpose field wires the lockout-recovery flow (HIGH finding): a locked-out
// user requests a password_reset code for their own email, obtains it (dev log /
// operator relay), verifies it at /api/verify/check, and the lockout is cleared
// so they can log in and change their password.
func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
var req VerificationCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -833,13 +910,39 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
purpose := strings.TrimSpace(strings.ToLower(req.Purpose))
if purpose == "" {
purpose = verificationCodePurposeEmailVerify
}
if purpose != verificationCodePurposeEmailVerify && purpose != verificationCodePurposePasswordReset {
http.Error(w, "purpose must be 'email_verify' or 'password_reset'", http.StatusBadRequest)
return
}
// A locked-out user reaches this endpoint UNAUTHENTICATED by design (the
// whole point of password_reset recovery), so no auth middleware guards it;
// the per-IP rate limit on the route is the only throttle, matching the
// 2FA mint paths.
// Build-dependent issuance gate (pepper + delivery channel in production;
// always allowed in dev/test — see verifycode_dev.go / verifycode_prod.go).
if err := verificationCodeEnsureIssueAllowed(); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
// Fail-closed reference: the user lookup happens AFTER the issuance gate so
// a prod deployment without the pepper/delivery channel refuses BEFORE any
// per-email work (and before the enumeration-uniform path below is reached).
var userID string
err := db.Conn.QueryRow(r.Context(),
"SELECT id FROM users WHERE LOWER(email) = $1", email,
).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"}); err != nil {
// Uniform anti-enumeration response — byte-identical to the
// existing-user branch.
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
@@ -849,24 +952,47 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
code, err := generateVerificationCode()
if err != nil {
log.Printf("Failed to generate verification code: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
expiresAt := clock.Now().Add(24 * time.Hour)
var code string
err = db.Conn.QueryRow(r.Context(),
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt,
).Scan(&code)
// Persist ONLY the digest; the plaintext code exists only in the delivery
// channel (log relay / future SMTP).
_, err = db.Conn.Exec(r.Context(),
`INSERT INTO verification_codes (user_id, purpose, code, expires_at) VALUES ($1, $2, $3, $4)`,
userID, purpose, twofa.Hash(code), expiresAt,
)
if err != nil {
log.Printf("Failed to insert verification code: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"}); err != nil {
verificationCodeDeliver(userID, purpose, code)
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// POST /api/verify/check
// Consumes a verification code submitted by an UNAUTHENTICATED caller (there is
// no auth middleware on this route — the password_reset recovery flow must be
// reachable by a locked-out user). The submitted code is hashed the same way it
// was stored (twofa.Hash) and matched against verification_codes; a match
// resolves the owning user, and the brute-force attempt budget is keyed PER
// USER from that point on (see the emailVerifyAttempts* docs). On a valid,
// unexpired, unused code:
//
// - purpose email_verify: escalates the account to verified_email;
// - purpose password_reset: clears failed_attempts / locked_until so the
// account owner can log in and change their password (the lockout-recovery
// path for the login-DoS finding).
func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
var req VerifyCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -886,44 +1012,30 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
}
// Finding 8: a spent attempt budget rejects before any DB work — the code
// can no longer be guessed against.
// can no longer be guessed against. This pre-check uses the submitted code
// as the key (a guess's miss path; the per-user key cannot be derived until
// a row resolves it).
if emailVerifyAttemptsExhausted(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
codeDigest := twofa.Hash(code)
var userID string
var purpose string
var expiresAt time.Time
var usedAt *time.Time
err := db.Conn.QueryRow(r.Context(),
`SELECT user_id, purpose, expires_at FROM verification_codes
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`,
code,
).Scan(&userID, &purpose, &expiresAt)
`SELECT user_id, purpose, expires_at, used_at FROM verification_codes
WHERE code = $1`,
codeDigest,
).Scan(&userID, &purpose, &expiresAt, &usedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Check if code exists but was already used or expired
var checkUsedAt *time.Time
checkErr := db.Conn.QueryRow(r.Context(),
`SELECT used_at FROM verification_codes WHERE code = $1`, code,
).Scan(&checkUsedAt)
if checkErr != nil {
// Code doesn't exist at all — a guess. Count it against the
// code's attempt budget.
if emailVerifyAttemptFailed(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
// Code exists but was already used — a definite state, not a guess.
if checkUsedAt != nil {
http.Error(w, "code already used", http.StatusForbidden)
return
}
// Code exists but expired — count it against the budget too.
// No row with this digest at all — a guess. No user can be
// resolved, so the attempt budget stays keyed per submitted code.
if emailVerifyAttemptFailed(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
@@ -936,6 +1048,29 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
// The code resolved to exactly one user — from here the attempt budget is
// keyed PER USER, so an attacker draining a victim's codes cannot get a
// fresh budget per submitted value.
if emailVerifyAttemptsExhausted(userID) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
// Code exists but was already used — a definite state, not a guess.
if usedAt != nil {
http.Error(w, "code already used", http.StatusForbidden)
return
}
// Code exists but expired — count it against the user's budget too.
if !expiresAt.After(clock.Now()) {
if emailVerifyAttemptFailed(userID) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
@@ -950,7 +1085,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
_, err = tx.Exec(r.Context(),
`UPDATE verification_codes SET used_at = NOW() WHERE code = $1`,
code,
codeDigest,
)
if err != nil {
log.Printf("Failed to mark code as used: %v", err)
@@ -958,7 +1093,9 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
if purpose == "email_verify" {
message := "Email verified successfully"
switch purpose {
case verificationCodePurposeEmailVerify:
_, err = tx.Exec(r.Context(),
`UPDATE users SET account_role = 'verified_email' WHERE id = $1 AND account_role = 'unverified_email'`,
userID,
@@ -968,6 +1105,21 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
case verificationCodePurposePasswordReset:
// Lockout-recovery consumer (HIGH finding): a verified password_reset
// code proves control of the account's email, so the login lockout is
// lifted. The user then logs in and changes their password via the
// existing change-password flow.
message = "Verification successful - login lockout cleared"
_, err = tx.Exec(r.Context(),
`UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1`,
userID,
)
if err != nil {
log.Printf("Failed to clear login lockout for password_reset: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
@@ -976,10 +1128,12 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Finding 8: a successful verify clears the code's attempt budget.
// A successful verify clears the user's attempt budget (and the miss-path
// key the submitted code used on earlier guesses).
emailVerifyAttemptsClear(userID)
emailVerifyAttemptsClear(code)
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"}); err != nil {
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: message}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build dev || test
package auth
// Dev/test builds of the verification-code flow (POST /api/verify/generate):
// the [VERIFY] log line is the LOCAL DEV delivery channel — the stand-in for
// the not-yet-wired email/SMS transport (P6) — and a missing TWO_FACTOR_PEPPER
// still falls back to the legacy unsalted SHA-256 digest (via the shared
// crussell/internal/twofa provider, which logs its own one-time warning).
// Production builds (!dev && !test) never log the code — stdout-log delivery
// is a dev/test-only local feature — and fail closed — see verifycode_prod.go.
import "log"
// verificationCodeEnsureIssueAllowed always permits code issuance in dev/test
// builds: the loose-fake delivery (the [VERIFY] log line) is the documented
// stand-in until email/SMS lands. Production builds fail closed here — no
// TWO_FACTOR_PEPPER, no codes (see verifycode_prod.go).
func verificationCodeEnsureIssueAllowed() error { return nil }
// verificationCodeDeliver delivers a fresh verification code to the user.
// Dev/test: the [VERIFY] log line is the delivery channel — an operator relays
// the code to the user out-of-band until email/SMS lands (mirrors the [2FA]
// log relay, which the 2FA flow uses identically). MEDIUM-3b: the user id and
// the plaintext code go to SEPARATE log lines so a single record cannot trivially
// pair a code with its owner.
func verificationCodeDeliver(userID, purpose, code string) {
log.Printf("[VERIFY] code delivery requested (user=%s, purpose=%s)", userID, purpose)
log.Printf("[VERIFY] code: %s", code)
}
+46
View File
@@ -0,0 +1,46 @@
//go:build !dev && !test
package auth
// Production builds (!dev && !test) of the verification-code flow
// (POST /api/verify/generate): code issuance fails closed. The stdout-log
// relay ([VERIFY] prefix) is a DEV/TEST-ONLY local feature — the stand-in for
// the not-yet-wired email/SMS transport (P6) — and is deliberately never used
// in a production build:
//
// - a missing TWO_FACTOR_PEPPER: an unsalted SHA-256 digest in the 48-bit
// code space would be offline-brute-forceable from a log/DB leak; and
// - no delivery channel: there is no production email/SMS transport yet (P6)
// and no production opt-in to log delivery, so a minted code could never
// reach the user.
//
// The plaintext code is therefore never written to the server log in a
// production build, under any configuration.
import (
"errors"
"os"
)
// verificationCodeEnsureIssueAllowed reports whether a verification code may be
// issued in this deployment. Production requires TWO_FACTOR_PEPPER and, after
// that, a real delivery channel — which does not exist until email/SMS lands
// (P6) — so issuance is ALWAYS refused (fail-closed); dev/test builds always
// allow issuance (verifycode_dev.go).
func verificationCodeEnsureIssueAllowed() error {
if os.Getenv(verificationCodePepperEnv) == "" {
return errors.New("verification code issuance requires TWO_FACTOR_PEPPER (an unsalted digest in the 48-bit code space would be offline-brute-forceable); set it in the environment")
}
return errors.New("verification code issuance requires a delivery channel; email/SMS is not wired yet (P6) — production has no delivery channel until it lands")
}
// verificationCodeDeliver delivers a fresh verification code to the user.
// Production: a deliberate no-op — the plaintext code is NEVER written to the
// server log, so this is unreachable (verificationCodeEnsureIssueAllowed
// already refused issuance). The dev/test build (verifycode_dev.go) writes the
// [VERIFY] log line instead — stdout-log delivery is a dev/test-only local
// feature until email/SMS lands (P6).
func verificationCodeDeliver(userID, purpose, code string) {
// Deliberate no-op: production never logs plaintext codes, under any
// configuration. Delivery is dev/test-only until email/SMS lands (P6).
}