Skip to content

security(totp): make recovery-code redemption single-use under concurrency - #761

Merged
lakhansamani merged 2 commits into
mainfrom
security/totp-recovery-single-use
Aug 13, 2026
Merged

security(totp): make recovery-code redemption single-use under concurrency#761
lakhansamani merged 2 commits into
mainfrom
security/totp-recovery-single-use

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

The bug

ValidateRecoveryCode read the recovery-code blob, decided in Go whether the code was unconsumed, and wrote the mutated blob back with an unconditional UpdateAuthenticator. Nothing made the write conditional on the state it had just read.

Two consequences, both reproduced:

  1. Double-spend. Concurrent redemptions of the same recovery code all read it unconsumed and all returned 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.
  2. Lost update. Concurrent redemptions of different codes clobbered each other — the last writer's blob won and the other code silently reverted to unconsumed.

The fix

The write decides, not the read.

New storage primitive, modelled on the existing DeleteSessionTokenByUserIDAndKey contract (internal/storage/provider.go:177):

ConsumeAuthenticatorRecoveryCode(ctx, id, oldCodes, newCodes string) (bool, error)

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:

Backend Construct
SQL WHERE id = ? AND recovery_codes = ?RowsAffected
MongoDB expected blob in the UpdateOne filter → MatchedCount
Cassandra/Scylla lightweight transaction (IF recovery_codes = ?) on the partition key
DynamoDB UpdateItem + ConditionExpression
ArangoDB AQL FILTER … UPDATE … RETURN NEW
Couchbase KV Get → compare → Replace with the document's CAS

ValidateRecoveryCode becomes 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:

  • oldCodes is compared byte for byte, so the caller passes the exact string it read. Re-marshalling the map would reorder keys and match nothing.
  • Exhausting the retries returns an error, not 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" failure AGENTS.md warns about. The existing caller in verify_otp.go already routes a non-nil error away from the lockout counter, so no caller change was needed.
  • Retry bound is the recovery-code count (10), not an arbitrary 3. Every lost swap means another code was spent, and a user only has ten — so that count is the worst case for legitimate contention.

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 ./... — clean
  • make test (SQLite) — pass
  • make test-all-dbexit 0; the parity subtest passes on all seven: couchbase, postgres, sqlite, mongodb, arangodb, scylladb, dynamodb
  • make lint — 0 issues

…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
lakhansamani merged commit 721c0e6 into main Aug 13, 2026
4 checks passed
@lakhansamani
lakhansamani deleted the security/totp-recovery-single-use branch August 13, 2026 10:07
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant