// 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 verification // core therefore lives here, importing neither, so both sides of the import // boundary can reach it. // // The verification consumers are the INTERACTIVE ACCOUNT FLOWS ONLY. The // saved-card payments gates are now exclusively PSD2 SCA (Square buyer // verification) and no longer call Check/VerifyForUser — the "charge gate" // contract described in earlier revisions is obsolete. Today the callers are: // // - handlers/user: 2FA setup verify (VerifyTwoFAHandler) and 2FA disable // re-verification (DisableTwoFAHandler), both via checkTwoFACode with // DeferredConsume (they clear the pending fields themselves on success); // - handlers/user/account.go: delete-account re-authentication // (DeleteAccountHandler) via twofa.VerifyForUser with ConsumeOnVerify, so // one code authorizes exactly one account erasure. // // The payments package still touches this package on the TERMINAL-SUCCESS path // only: ConsumePendingCode (after an SCA-approved saved-card charge or gift // card issuance, where the pending code left over from the interactive mint // must be retired) and StateFor/Hash for its code re-issue bookkeeping. // // Consume mode: 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 consumers 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). DeleteAccount // is the only current ConsumeOnVerify caller. // // 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 a TERMINAL operation that // must be authorized by exactly one code — today that is the delete-account // re-authentication flow (DeleteAccountHandler); the saved-card charge and // SAVE gates no longer call Check (SCA-only since the PSD2 rework). 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). // clock.Now(), not time.Now(): the rest of the package reads time through // crussell/clock (UTC-normalised, test-controllable) so the pinned stamp // must be expressed in the same clock or a frozen test clock would leave // now.Sub(LastActive) inconsistent with the saturation invariant. st.SetLastActive(clock.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 account // flows are the only consumers: the 2FA setup/disable handshakes pass // DeferredConsume (false) and clear the pending fields themselves on success, // while delete-account re-authentication passes ConsumeOnVerify (true) so one // code authorizes exactly one erasure. The payments gates are SCA-only and no // longer call Check. 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, q db.Querier, 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 := q.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 := q.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 := q.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 an interactive gate // may still be followed by a FAILED money action whose retry mints a fresh // code, and that fresh-code mint path (twoFAMintThrottled in handlers/user) // enforces the per-user mint cooldown against this stamp. Clearing it on a // gate-verify let a 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-operation consumption path // (ConsumePendingCode, called inside the transaction that records the // completed operation) — so a user who just completed a flow 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 := q.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. // // DB-ATOMIC (F5.5): the UPDATE is conditional on the exact digest this // verify just matched (WHERE id=$1 AND two_factor_pending_code_hash=$2) // and reports rows affected. The per-user mutex above only serializes // attempts WITHIN one process; two concurrent verifications of the same // code on different instances both read the same digest and both match // it, but only the first conditional UPDATE can affect a row — the // loser sees 0 rows and must fail (MissingOrExpired), so one code // authorizes exactly ONE operation even across instances. tag, err := q.Exec(ctx, ` UPDATE users SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL WHERE id = $1 AND two_factor_pending_code_hash = $2 `, userID, pendingHash.String) if err != nil { // The DB is erroring — atomicity cannot be proven. Keep the prior // fail-open behaviour (report the verify as OK) so the user is not // stranded; the in-process mutex still serializes same-instance // verifications, and a degraded DB is the only way to reach here. log.Printf("failed to consume 2FA pending code for user %s: %v", userID, err) } else if tag.RowsAffected() == 0 { // A concurrent verification consumed the code between this SELECT // and this UPDATE. Single-use means this one must fail. return MissingOrExpired, nil } } return OK, nil } // ConsumePendingCode NULLs the user's pending 2FA code digest and expiry, and // clears the per-user mint-cooldown stamp (AttemptState.LastMintAt). // The saved-card charge gates are SCA-only and no longer consume codes at a // gate verify (there is no homegrown gate verify to consume at); this is used // on the TERMINAL-SUCCESS paths of the payments package — after an // SCA-approved saved-card charge, a gift card issuance, or a till sale that // used the customer's pending code — inside the transaction that records the // completed operation, so a pending code minted for a flow can never authorize // a second one. 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 flow may still fail and the retry's fresh-code mint // (twoFAMintThrottled in handlers/user) 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 non-HTTTP entry // point for the interactive account flows — today only delete-account // re-authentication (handlers/user/account.go), which passes ConsumeOnVerify so // a code authorizes exactly one erasure. The payments saved-card gates no // longer verify codes (SCA-only); the interactive setup/disable flows reach // Check through handlers/user's checkTwoFACode with DeferredConsume and clear // the pending fields themselves on success. 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, db.Conn, 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 }