Make the login-approval attempt cap atomic (LC-608) #565
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/lc587-atomic-attempt-cap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes LC-608. Found while reviewing LC-587 before it ships beyond staging. Same read-then-act shape LC-601 closed on the consume path, one function over.
The defect
login_approval::verifydid three separate steps: read the challenge includingattempts, compare the submitted code againstcode_hash, then bumpattemptsand burn the challenge if the bump crossedMAX_ATTEMPTS. The cap was only consulted against the value read in step one, so concurrent submissions all read the same pre-bump count and every one of them reached the comparison.The 6-digit code space is 1e6, and the entire security argument for a 6-digit code is "5 tries per emailed code". Under concurrency that bound did not exist.
The fix
One statement that is simultaneously the validity check and the counter:
No row means unknown, expired, consumed, or out of attempts. A returned row is that caller's reserved slot, so at most
MAX_ATTEMPTScomparisons ever happen against one challenge. A correct code also spends a slot, which is harmless because success consumes the challenge outright.get_valid_login_approvalandbump_login_approval_attemptsare deleted rather than left available. Both were unused after this change, and leaving them in place leaves the unsafe sequence sitting there for the next caller to reassemble.Verification, including a wrong turn worth recording
The regression test releases 40 wrong-code submissions through a barrier, then reads
attemptsback. Every submission that got compared also bumped, so the stored count is the number of guesses the challenge allowed. 40 of 40 before the fix, 5 of 40 after. Confirmed failing against the unpatched code by stashing the two source files.My first attempt at this test asserted on the wrong observable and reported the opposite conclusion. Counting how many calls return
Wronggives 0 of 40, because by the time each of the 40 read its post-bump counter the value was already past the cap, so they all returnedInvalid. That reads exactly like the cap working perfectly, while 40 comparisons were in fact performed. Anyone re-testing this area should measure the counter, not the outcomes.just checkand the fulljust testsuite are green.Severity
Moderate, not critical. An attacker still needs the challenge token, which means they have already completed SSO as the victim and are guessing a code mailed to the victim's verified address. Concurrency lifted the per-challenge guess count from 5 to however many requests they can land, far below 1e6 in one shot but well above the intended bound, and repeatable across freshly minted challenges.
Noted, not changed
POST /auth/bunyip/approvehas no rate limit of its own beyond the per-challenge cap. Much less pressing now that the cap is real, but it is the remaining lever.==on hex digests rather than constant-time. It compares SHA-256 hashes, not the codes, so the timing signal leaks nothing usable. Recorded so the next reader doesn't have to re-derive it.🤖 Generated with Claude Code
https://claude.ai/code/session_01BE9k4nWNUhPrjte9BcvASg