Files
Crussell/backend/internal/twofa/twofa.go
T
popertots 4e398a7a2b fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

604 lines
27 KiB
Go

// Package twofa owns the shared 2FA verification-code machinery: the
// per-user brute-force attempt map, the constant-time code check, and the
// exported verification entry point.
//
// Why this package exists (B11c coordination contract): handlers/user imports
// handlers/payments (TwoFactorEnforced, SquareClient), so handlers/payments
// CANNOT import handlers/user — Go would reject the cycle. The saved-card
// charge gate (B6/B10, owned by the payments agent) needs to verify a real 2FA
// challenge with the same brute-force lockout as the interactive endpoints, so
// the verification core lives here, importing neither.
//
// Contract for the payments gate:
//
// err := twofa.VerifyForUser(ctx, userID, code, twofa.ConsumeOnVerify)
// if err != nil {
// switch {
// case errors.Is(err, twofa.ErrIncorrect):
// // 400
// case errors.Is(err, twofa.ErrLockedOut):
// // 429
// case errors.Is(err, twofa.ErrMissingOrExpired):
// // 400 — user must request a fresh code
// default:
// // 500 (DB failure)
// }
// }
//
// Consume mode (MEDIUM-2 remediation, finding 1): a successful verify with
// consume=true NULLs the pending code ATOMICALLY in the same critical section
// as the check, so one code authorizes exactly ONE operation — two concurrent
// charges can never both pass the gate with the same code (the per-user mutex
// serializes Check, and the second verify reads a NULLed digest and returns
// ErrMissingOrExpired). The payments saved-card CHARGE gates should therefore
// pass twofa.ConsumeOnVerify for FRESH charges: the code is burned at the gate,
// and a failed/ambiguous Square charge re-mints a fresh code (via the user
// package's exported EnsurePendingTwoFACode — reached through the HTTP mint
// endpoints, since handlers/payments cannot import handlers/user) instead of
// re-verifying the same code. This replaces the earlier MEDIUM-2 deferred
// consume (verify-with-consume=false at the gate + ConsumePendingCode at
// terminal success), which under concurrency let two gates both verify the same
// code before either charge consumed it.
//
// The interactive setup/disable flows pass DeferredConsume (false) — they clear
// the pending fields themselves on success (enableTwoFA / disableTwoFA), so the
// code must stay valid through their whole handshake. The save-card SAVE gate
// (handlers/payments) passes ConsumeOnVerify (true), since saving a card is a
// terminal operation with no downstream charge to attach consumption to.
//
// The failed-attempt counter is keyed per user and resets ONLY on a successful
// verify (or after the 10-minute attempt window elapses) — never on a fresh
// code mint, so minting a new code cannot grant a fresh guessing budget (B11b).
package twofa
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"crussell/clock"
"crussell/db"
)
// MaxAttempts is the number of consecutive failed verify attempts allowed
// before the pending code is invalidated and a new one must be requested.
const MaxAttempts = 5
// Consume mode for VerifyForUser / Check. Named so the magic bool cannot drift
// between call sites (the payments gate vs the interactive flows).
const (
// ConsumeOnVerify makes a successful verify SINGLE-USE immediately: the
// pending-code digest and expiry are NULLed in the same critical section as
// the successful check (see Check). Use this for FRESH terminal operations —
// the saved-card CHARGE gates (finding 1) and the SAVE gate — where one
// code must authorize exactly one operation.
ConsumeOnVerify = true
// DeferredConsume verifies WITHOUT consuming; the caller NULLs the code
// itself when its operation reaches terminal success (ConsumePendingCode) or
// clears the pending fields on success (the interactive enable/disable
// flows).
DeferredConsume = false
)
// AttemptWindow bounds how long a per-user attempt counter lives before
// resetting, and doubles as the stale-entry eviction horizon for the map.
const AttemptWindow = 10 * time.Minute
// MaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
// user IDs cannot grow it without bound.
//
// ACCEPTED LIMITATION (LOW 6 — documentation only, no behavior change): every
// 2FA counter here — the per-user failed-attempt count, the lockout window, and
// the mint-cooldown stamp (AttemptState.LastMintAt, used by twoFAMintCooldown
// in handlers/user) — is purely in-memory and resets on process restart. The
// DB schema is locked (there is no attempt column), and the practical impact
// is bounded by the 10-minute pending-code expiry (AttemptWindow /
// twoFAPendingExpiry): at most one fresh 5-guess budget per 10-minute window.
// A MULTI-INSTANCE deployment would need a shared store (e.g. a DB column or
// Redis) for these counters, because today each instance keeps its own map —
// an attacker could distribute guesses across instances. Single-instance
// deployments (this app) are unaffected.
//
// Declared as a var so the eviction policy is unit-testable at a small cap.
var MaxTrackedAttempts = 10_000
// AttemptState tracks consecutive failed verify attempts for one user. The
// per-user mutex serializes the whole verify critical section so concurrent
// attempts from the same user cannot race the limit check. Count and LastAt are
// atomic so the map eviction path can read them without taking the per-user
// mutex (lock ordering forbids MapMu→st.Mu: Check holds st.Mu then takes
// MapMu). LastAt is stored as nanoseconds since the Unix epoch so the eviction
// scan and LockedOut read it race-free even on 32-bit platforms — a plain
// time.Time read/write pair there could tear the 8-byte timestamp and reset or
// extend the lockout window.
// LastMintAt is the mint-cooldown stamp (see twoFAMintCooldown in the user
// package); it is only ever touched under Mu.
type AttemptState struct {
Mu sync.Mutex
Count atomic.Int32
LastAt atomic.Int64
LastMintAt time.Time
}
// LastActive returns the state's last-activity timestamp (nanoseconds since
// the Unix epoch, UTC). Reads are atomic so the map eviction scan can call it
// while holding only MapMu.
func (st *AttemptState) LastActive() time.Time {
return time.Unix(0, st.LastAt.Load()).UTC()
}
// SetLastActive records a last-activity timestamp. Writes happen under Mu
// (Check) while the eviction scan reads under MapMu only — the atomic store
// makes both race-free.
func (st *AttemptState) SetLastActive(t time.Time) {
st.LastAt.Store(t.UnixNano())
}
// SetLastMintAtLocked records the per-user mint-cooldown stamp. The caller
// MUST already hold st.Mu (matching how the stamp is read by
// twoFAMintThrottled in handlers/user and the payments re-issue cooldown).
//
// Round 2 Loop B finding 3a: writing to the SHARED saturated state is a
// NO-OP. saturatedLockedState is returned by StateFor for EVERY untracked user
// once the map is at capacity, so a mint stamped on it would throttle every
// untracked user for the whole cooldown (one user's mint blocks everyone for
// 60s) and ClearMintCooldownForUser would clear it for all of them. The
// singleton's stamp therefore stays permanently zeroed: under saturation,
// per-user mints are unthrottled, which is SAFE because a mint never grants a
// fresh guessing budget (B11b) and the saturated state is already permanently
// locked out for verification. The stamp must also never be written by the
// reissue/mint paths when st IS the singleton — the no-op below guarantees it.
func (st *AttemptState) SetLastMintAtLocked(t time.Time) {
if st == saturatedLockedState {
return
}
st.LastMintAt = t
}
// LockedOut reports whether the state is inside its lockout window: the attempt
// counter has reached the cap and the window has not yet elapsed. Such a record
// is the rate limit's source of truth for its user and must never be evicted
// while in-window — evicting it would silently reset the counter and grant a
// fresh guessing budget.
func (st *AttemptState) LockedOut(now time.Time) bool {
return st.Count.Load() >= MaxAttempts && now.Sub(st.LastActive()) <= AttemptWindow
}
var (
MapMu sync.Mutex
Map = make(map[string]*AttemptState)
)
// saturatedLockedState is the SHARED attempt state returned by StateFor when
// the attempt map is at capacity and every tracked record is inside its
// lockout window (finding 3, Round 2 Loop A — see StateFor). Its last-activity
// stamp is pinned FAR in the future, so LockedOut always holds and Check's
// window-reset branch (now.Sub(LastActive) > AttemptWindow) can never reach it:
// every untracked user is treated as PERMANENTLY locked out instead of being
// granted a fresh 5-guess budget per request. It is a package-level singleton
// rather than a per-call allocation so the pathological path allocates nothing
// and all saturated requests share one record.
var saturatedLockedState = newSaturatedLockedState()
func newSaturatedLockedState() *AttemptState {
st := &AttemptState{}
st.Count.Store(MaxAttempts)
// Pinned so far in the future that now.Sub(LastActive) is always
// <= AttemptWindow (LockedOut true) and never > AttemptWindow (no reset).
st.SetLastActive(time.Now().Add(24 * 365 * 24 * time.Hour))
return st
}
// StateFor returns the per-user attempt state, creating it if needed. The map
// is bounded: stale (window-expired) entries are evicted opportunistically and,
// when at capacity, the least-recently-active non-locked-out entry is dropped.
// A record still inside its lockout window is NEVER evicted — evicting it would
// reset the victim's attempt counter and bypass the rate limit under a hostile
// flood of new keys. When the map is full of in-window locked-out records (a
// pathological flood), a transient, untracked state is returned instead of
// growing the map past the cap.
func StateFor(userID string) *AttemptState {
MapMu.Lock()
defer MapMu.Unlock()
now := clock.Now()
if len(Map) >= MaxTrackedAttempts {
var oldestID string
var oldestAt time.Time
for id, st := range Map {
if now.Sub(st.LastActive()) > AttemptWindow {
// Idle/expired — its counter has already lapsed; safe to evict.
delete(Map, id)
continue
}
if st.LockedOut(now) || st.Count.Load() > 0 {
// Round 2 Loop B finding 3b: an in-window record with a
// NON-ZERO attempt counter is the rate limit's IN-PROGRESS
// state for a genuine user (a locked-out record, or a user
// mid-window with failed attempts banked). Evicting it would
// silently reset the counter and grant a fresh guessing
// budget. Only count==0 in-window records (fresh lookups /
// idle mint-cooldown stamps) are evictable.
continue
}
if at := st.LastActive(); oldestID == "" || at.Before(oldestAt) {
oldestID, oldestAt = id, at
}
}
if len(Map) >= MaxTrackedAttempts && oldestID != "" {
delete(Map, oldestID)
}
if len(Map) >= MaxTrackedAttempts {
// Every entry is a protected in-window record (locked out, or
// carrying an in-progress counter). Do not evict one (that would
// reset its rate limit) and do not grow past the cap: return the
// SHARED permanently-locked state (finding 3, Round 2 Loop A).
// Previously a fresh transient state was returned per call, so
// every untracked user received a fresh 5-guess budget per
// request — silently disabling the brute-force lockout exactly
// under the hostile flood that saturated the map. The shared state
// treats every untracked user as locked out instead. It is never
// stored in Map (so the eviction scan / ResetAttempts /
// DeleteAttempts never touch it) and self-heals: as soon as one of
// the real locked-out records lapses out of its window, StateFor
// evicts it and normal per-user tracking resumes.
//
// Round 2 Loop B finding 3c — ACCEPTED RESIDUAL: an attacker can
// still fill the map with up to MaxTrackedAttempts (~10k)
// in-window locked-out records, forcing every other user into the
// shared saturated state. That is a bounded, FAIL-CLOSED outcome:
// the fallback is a locked-out-everyone state (brute force
// impossible, availability reduced), never a locked-out-nobody one.
// Mint cooldowns are unthrottled under saturation (the shared
// stamp is zeroed — see SetLastMintAtLocked), which is safe
// because a mint grants no guessing budget (B11b). The map drains
// as locked-out windows lapse.
return saturatedLockedState
}
}
st := Map[userID]
if st == nil {
st = &AttemptState{}
st.SetLastActive(now)
Map[userID] = st
}
return st
}
// ResetAttempts resets a user's attempt counter in place (count only) WITHOUT
// deleting the entry, preserving LastMintAt so the mint cooldown survives a
// fresh-code delivery. Called on successful verify only — a fresh code mint
// MUST NOT reset the counter, or a password-only attacker could loop
// mint → burn 5 guesses → mint forever (B11b). LastAt is deliberately not
// touched here: it is re-stamped by Check on real activity, and writing it
// under MapMu would race with Check's Mu-guarded write. Lock ordering is
// Mu→MapMu at call sites, never the reverse (StateFor takes MapMu only and
// never takes Mu).
func ResetAttempts(userID string) {
MapMu.Lock()
defer MapMu.Unlock()
if st := Map[userID]; st != nil {
st.Count.Store(0)
}
}
// DeleteAttempts removes a user's attempt-map entry entirely, unlike
// ResetAttempts which only zeroes the count in place. The admin 2FA removal
// flow uses it so any lingering lockout/counter/mint-cooldown state is dropped
// wholesale and a re-setup starts from a clean slate.
func DeleteAttempts(userID string) {
MapMu.Lock()
defer MapMu.Unlock()
delete(Map, userID)
}
// PepperProvider supplies the server-side HMAC pepper (TWO_FACTOR_PEPPER). The
// build-dependent behavior — dev/test fallback to the legacy unsalted digest
// with a one-time warning vs production fail-closed — is registered by the
// handlers/user build-tagged files via SetPepperProvider.
var PepperProvider = func() string { return "" }
// SetPepperProvider registers the build-specific pepper reader.
func SetPepperProvider(f func() string) { PepperProvider = f }
// Hash returns the hex digest of a verification code as stored in the DB. With
// TWO_FACTOR_PEPPER set the digest is HMAC-SHA256 keyed by the pepper, so a
// leaked digest cannot be brute-forced offline (the key stays server-side).
// When the pepper is unset it falls back to the legacy unsalted SHA-256 digest:
// dev/test builds also log a one-time warning, while production builds can
// never persist such a digest because issuance fails closed without the pepper
// — the fallback survives only for the legacy-row migration window and the
// dev/test loose-fake flow. The plaintext code is never stored.
func Hash(code string) string {
if p := PepperProvider(); p != "" {
mac := hmac.New(sha256.New, []byte(p))
mac.Write([]byte(code))
return hex.EncodeToString(mac.Sum(nil))
}
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// LegacyHash returns the pre-pepper plain SHA-256 digest, used to verify rows
// written before TWO_FACTOR_PEPPER was provisioned during the migration window
// (see VerifyHash).
func LegacyHash(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// VerifyHash reports whether reqCode matches a stored pending-code digest,
// always in constant time (subtle.ConstantTimeCompare). The first comparison
// uses the current pepper'd digest; when that fails the stored hash may be a
// legacy pre-pepper plain SHA-256 (rows written before TWO_FACTOR_PEPPER was
// provisioned), so the legacy digest is tried too. When a legacy row matches,
// legacy is true and the caller should re-hash with the pepper on the next
// successful verify, retiring the plain digest.
func VerifyHash(reqCode, storedHash string) (match, legacy bool) {
if subtle.ConstantTimeCompare([]byte(Hash(reqCode)), []byte(storedHash)) == 1 {
return true, false
}
if subtle.ConstantTimeCompare([]byte(LegacyHash(reqCode)), []byte(storedHash)) == 1 {
return true, true
}
return false, false
}
// Result classifies Check's outcome so callers can map it to the correct HTTP
// status (or error, in VerifyForUser's case).
type Result int
const (
OK Result = iota
Incorrect
LockedOut
MissingOrExpired
)
// Check verifies the submitted code against the user's stored pending code
// under the per-user brute-force lockout. The caller must hold st.Mu (from
// StateFor) so concurrent attempts from the same user cannot race the limit
// check. A correct code resets the attempt counter and returns OK. An incorrect
// code increments the counter and, on the 5th consecutive failure, invalidates
// the pending code (lockout). A missing or expired pending code returns
// MissingOrExpired. consume makes a correct code single-use IMMEDIATELY: the
// stored digest and its expiry are NULLed right here, so one code cannot
// authorize a second operation within its lifetime. The interactive
// setup/disable flows pass DeferredConsume (false) and clear the pending fields
// themselves on success. The payments saved-card charge gates pass
// ConsumeOnVerify (true) for FRESH charges (finding 1): the code is burned at
// the gate, and a failed Square charge re-mints a fresh one. The returned
// error is non-nil only for DB failures (callers return 500); a lockout's
// pending-code invalidation failure is logged here and still reported as a
// lockout.
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
st.Count.Store(0)
st.SetLastActive(now)
}
if st.Count.Load() >= MaxAttempts {
return LockedOut, nil
}
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(ctx, `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil {
return LockedOut, err
}
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
return MissingOrExpired, nil
}
// Constant-time compare (subtle) so a wrong code's match position cannot be
// inferred from response timing. Both digests are fixed-length hex. Legacy
// pre-pepper rows (plain SHA-256, hashed before TWO_FACTOR_PEPPER existed)
// still verify during the transition window.
match, legacy := VerifyHash(reqCode, pendingHash.String)
if !match {
st.Count.Add(1)
st.SetLastActive(clock.Now())
if st.Count.Load() >= MaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop.
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
}
return LockedOut, nil
}
return Incorrect, nil
}
// Success: a legacy (pre-pepper) hash that verified is re-hashed with the
// pepper so the plain digest is retired on the next successful verify. This
// matters only for the interactive paths (consume=false), where the pending
// code stays valid for the rest of the handshake — consume mode destroys
// the digest outright, so there is nothing to upgrade.
if legacy && !consume {
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = $2
WHERE id = $1
`, userID, Hash(reqCode)); err != nil {
log.Printf("failed to upgrade legacy 2FA pending code hash for user %s: %v", userID, err)
}
}
// Success: clear the attempt counter before the caller performs its
// action. The mint-cooldown stamp (LastMintAt) is deliberately NOT cleared
// here (Round 2 Loop A finding 2): a code verified at the saved-card gate
// may still be followed by a FAILED Square charge that re-issues a fresh
// code (payments.reissueTwoFACodeAfterFailedCharge), and that re-issue path
// enforces the per-user mint cooldown against this stamp. Clearing it on a
// gate-verify let a charge-failure loop mint a fresh code on every
// iteration with no 60s cooldown (code churn + dev log flooding). The stamp
// is cleared only at a TERMINAL SUCCESS — the completed-charge consumption
// path (ConsumePendingCode, called by the money agent inside the
// transaction that records the completed charge) — so a customer who just
// completed a charge can immediately request a fresh code.
st.Count.Store(0)
st.SetLastActive(clock.Now())
ResetAttempts(userID)
// LOW 6b: a correct code proves control of the account's second factor, so
// lift any password-guessing login lockout (users.failed_attempts /
// locked_until) — a successful 2FA challenge is a strong auth signal, and
// the only way to reach a 2FA verify is an already-authenticated session.
// Best-effort: a failure only logs; the verify has already succeeded.
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET failed_attempts = 0, locked_until = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to clear login lockout on 2FA verify for user %s: %v", userID, err)
}
if consume {
// Consume mode (the payments saved-card gate, B6/B10): a verified code
// is single-use. NULL the stored digest and its expiry so the same code
// cannot authorize a second saved-card charge within its 10-minute
// lifetime. The interactive setup/disable flows pass consume=false:
// they clear the pending fields themselves on success (enableTwoFA /
// disableTwoFA), so the code must stay valid through the whole
// verification handshake here. The write goes through the same
// context-routed connection as the rest of Check, so verification and
// consumption are one unit.
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to consume 2FA pending code for user %s: %v", userID, err)
}
}
return OK, nil
}
// ConsumePendingCode NULLs the user's pending 2FA code digest and expiry, and
// clears the per-user mint-cooldown stamp (AttemptState.LastMintAt).
// Since finding 1 the saved-card CHARGE gates consume a FRESH charge's code at
// verify time (consume=true — single-use), so this is no longer the gate's
// consumption path: it is used by the PENDING-REUSE retry path, whose gate
// verified WITHOUT consuming (consume=false) so a retry that fails again keeps
// its code for one more attempt — the handlers call this when the retry reaches
// a TERMINAL SUCCESS state, inside the transaction that records the completed
// charge. Idempotent: consuming an already-NULL pending code is a no-op, so a
// code still authorizes exactly one completed charge and can never authorize a
// second after success. Accepts a db.Querier so the write can ride the caller's
// transaction (pgx.Tx) or the pool proxy.
//
// Round 2 Loop A finding 2: this is the ONLY place the mint-cooldown stamp is
// cleared on the charge path. A successful gate VERIFY (twofa.Check) must NOT
// clear it — the charge may still fail and the re-issue path
// (payments.reissueTwoFACodeAfterFailedCharge) enforces its cooldown against
// the stamp. Reaching terminal SUCCESS is what re-arms immediate re-minting,
// so consumption (which runs only at that terminal state) clears it.
func ConsumePendingCode(ctx context.Context, q db.Querier, userID string) error {
if userID == "" {
return nil
}
_, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID)
if err != nil {
return fmt.Errorf("2FA consume pending code: %w", err)
}
// Clear the mint-cooldown stamp. Best-effort and in-memory: a missing or
// evicted entry (e.g. after a process restart) only lets the cooldown
// lapse — it never grants a fresh guessing budget.
ClearMintCooldownForUser(userID)
return nil
}
// ClearMintCooldownForUser zeroes the user's mint-cooldown stamp (LastMintAt)
// under the per-user mutex — LastMintAt is only ever touched under Mu. A no-op
// when the user's state IS the shared saturated singleton (Round 2 Loop B
// finding 3a: the shared stamp must not be cleared for every untracked user by
// one user's terminal success).
//
// Round 2 Loop B finding 6a — COORDINATION CONTRACT (money agent): the FRESH
// saved-card charge path consumes the customer's 2FA code AT THE GATE
// (consume=true), so twofa.ConsumePendingCode is NOT called on its terminal
// success and the mint-cooldown stamp survives — a customer who completes a
// fresh charge within 60s of their last code mint and immediately requests a
// new code gets 429 for the rest of the window. The money agent's fresh-charge
// terminal-success path in handlers/payments/handlers.go should call
// ClearMintCooldownForUser(userID) at the same point a completed charge is
// recorded, so a just-completed charge re-arms immediate re-minting. This
// function is the exported, coordination-actionable entry point for that call.
func ClearMintCooldownForUser(userID string) {
st := StateFor(userID)
if st == saturatedLockedState {
return
}
st.Mu.Lock()
st.LastMintAt = time.Time{}
st.Mu.Unlock()
}
// Classifying errors returned by VerifyForUser.
var (
// ErrIncorrect reports a code that does not match the user's pending code.
ErrIncorrect = errors.New("2FA code is incorrect")
// ErrLockedOut reports that the user has exhausted the failed-attempt
// budget; further attempts must wait for the attempt window to elapse.
ErrLockedOut = errors.New("2FA code locked out: too many failed attempts")
// ErrMissingOrExpired reports that no valid pending code exists for the
// user; a fresh code must be requested first.
ErrMissingOrExpired = errors.New("2FA code is missing or has expired")
)
// VerifyForUser verifies a 2FA code for a user outside the HTTP handler layer,
// under the same per-user brute-force lockout as the interactive endpoints.
// It returns nil on a correct code, or one of ErrIncorrect / ErrLockedOut /
// ErrMissingOrExpired (or a DB error, wrapped). This is the entry point for
// the payments card-access gate (B6/B10): a saved-card charge must present a
// real, freshly-verified challenge. consume makes a correct code single-use
// IMMEDIATELY (the pending-code digest and expiry are NULLed in the same
// critical section as the successful check — see Check). The payments saved-
// card CHARGE gate passes ConsumeOnVerify for FRESH charges (finding 1: a code
// authorizes exactly one charge, and a failed charge re-mints); the save-card
// SAVE gate passes ConsumeOnVerify too; the interactive setup/disable flows
// pass DeferredConsume and clear the pending fields themselves on success
// (enableTwoFA / disableTwoFA).
func VerifyForUser(ctx context.Context, userID, code string, consume bool) error {
st := StateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
result, err := Check(ctx, userID, st, code, consume)
if err != nil {
return fmt.Errorf("2FA verify: %w", err)
}
switch result {
case OK:
return nil
case Incorrect:
return ErrIncorrect
case LockedOut:
return ErrLockedOut
case MissingOrExpired:
return ErrMissingOrExpired
}
return nil
}