security(totp): make recovery-code redemption single-use under concurrency - #761
Merged
Conversation
…rency ValidateRecoveryCode read the recovery-code blob, decided in Go, and wrote it back unconditionally. Concurrent redemptions of the same code all read it unconsumed and all returned true — 3 of 8 racers accepted in a probe — so one leaked recovery code yielded unlimited logins, and racing redemptions of different codes silently lost one another's writes. The write now decides. ConsumeAuthenticatorRecoveryCode swaps the blob only while the row still holds the blob the caller read, implemented as a single atomic operation per backend (SQL WHERE + RowsAffected, Mongo filtered UpdateOne, Cassandra LWT, DynamoDB ConditionExpression, Arango AQL filter+UPDATE, Couchbase KV CAS). A lost race re-reads and finds the code consumed, so the loser rejects. Exhausting the retries is reported as an error, never as an invalid code: the write failed, the credential did not, and answering "invalid" would spend it on a database problem. Retry bound is the recovery-code count, which is the worst case for legitimate contention — every lost swap means another code was spent, and there are only ten.
The screen at /app posts a recovery code into the same field as a TOTP passcode, so verify_otp is the path a real user drives. Single-use was only asserted at the provider; assert it where the UI hits it, with a fresh MFA session so nothing but the spent code can reject the replay. Also covers that spending one code leaves the other nine usable.
lakhansamani
added a commit
that referenced
this pull request
Aug 13, 2026
…ode (#762) Validate read the authenticator row, spent time on it (decrypt, TOTP check, replay reservation), then wrote it back whole via UpdateAuthenticator. The struct still carried the recovery-code blob read at the start, so a code redeemed in that window was restored to unconsumed — silently undoing the single-use guarantee from #761. Add UpdateAuthenticatorSecretAndVerifiedAt, writing only the two columns that changed. The two writers to an authenticator row now touch disjoint columns and commute, so neither ordering loses the other's write. Couchbase uses MutateIn, not Get+Replace: a whole-doc replace from a stale read is the same bug from the other side. Cassandra and DynamoDB UPDATE are upserts, so IF EXISTS / attribute_exists(id) stop a row deleted mid-flight from being resurrected as a ghost authenticator. verify_otp's email/SMS-OTP verified-marking moves to the same narrow write. Those rows hold no recovery codes, but the shape is the bug.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
ValidateRecoveryCoderead the recovery-code blob, decided in Go whether the code was unconsumed, and wrote the mutated blob back with an unconditionalUpdateAuthenticator. Nothing made the write conditional on the state it had just read.Two consequences, both reproduced:
true. A probe measured 3 of 8 concurrent attempts accepted. A recovery code's entire security property is that it works once; without that, one leaked code is an unlimited supply of logins that bypass MFA.The fix
The write decides, not the read.
New storage primitive, modelled on the existing
DeleteSessionTokenByUserIDAndKeycontract (internal/storage/provider.go:177):The swap lands only while the row still holds
oldCodes, decided by one atomic database operation — never a read followed by an unconditional write. Implemented natively per backend rather than emulated:WHERE id = ? AND recovery_codes = ?→RowsAffectedUpdateOnefilter →MatchedCountIF recovery_codes = ?) on the partition keyUpdateItem+ConditionExpressionFILTER … UPDATE … RETURN NEWGet→ compare →Replacewith the document's CASValidateRecoveryCodebecomes a bounded compare-and-swap loop: on a lost race it re-reads, finds the code consumed, and rejects.Two contract details worth review attention:
oldCodesis compared byte for byte, so the caller passes the exact string it read. Re-marshalling the map would reorder keys and match nothing.false. The write failed; the credential did not. Answering "invalid" would spend a user's recovery code on a database problem — the "outage reported as bad input" failureAGENTS.mdwarns about. The existing caller inverify_otp.goalready routes a non-nil error away from the lockout counter, so no caller change was needed.Tests
Both new integration subtests were confirmed to fail against the pre-fix implementation before being kept.
internal/integration_tests— 8 racers × 25 rounds on one code → exactly one success; two distinct codes raced × 25 rounds → both succeed, all ten codes survive, only the two redeemed are marked consumed.internal/authenticators/totp— new unit suite over a miniature correct backend: 16 racers × 50 rounds, retry-after-lost-race (asserts each retry re-reads), the exact interleaving where the code is spent between read and swap, exhaustion-is-a-fault, storage-error propagation, rejections that must not write, only-the-matched-code-changes, legacy plaintext codes, corrupt blob.internal/storage/provider_test.go— parity subtest run against all 7 backends: a stale expectation is refused without writing, a matching one lands and stores the blob verbatim, the same before-blob cannot win twice, and an absent row is(false, nil)rather than an error.Verification
go build ./...,go vet ./...— cleanmake test(SQLite) — passmake test-all-db— exit 0; the parity subtest passes on all seven: couchbase, postgres, sqlite, mongodb, arangodb, scylladb, dynamodbmake lint— 0 issues