feat: throttle + lock step-up confirmation, and seven audit fixes - #23
Merged
Merged
Conversation
`POST /auth/confirm-password` and `/auth/confirm-passkey` had no rate limit at
all — only the guard. Password confirmation re-verifies the SAME secret the
login route throttles two ways and can now lock, so anyone already holding an
access token (a stolen one, an XSS'd one, a shared device) had an unmetered
password oracle sitting behind the sudo gate.
Both routes now run a `lukk-confirm` limiter (`rate_limits.confirm`, 5/60s),
keyed on the user AND the IP. The per-user bucket is the load-bearing one: a
caller with a stolen token is a single identity behind however many addresses
they care to use, so per-IP alone bounds nothing. The additional guards get the
same treatment via `lukk-{guard}-confirm` — those are the higher-privilege
audiences multi-guard exists for, and metering them per-IP only would have left
5 guesses per source /64 per minute against an admin password.
With `features.lockout` on, failed password confirmations also count toward the
NIST SP 800-63B §5.2.2 cap under a new `confirm` purpose, keyed on the user id
(the caller is already authenticated, so unlike the login lock there is a
resolved account and no enumeration concern).
`confirm-passkey` is metered but deliberately NOT locked: an assertion is a
signature, not a guessable secret, so the throttle there is DoS protection
rather than brute-force defence — the same reasoning that exempts recovery
codes.
A three-pass white-box audit against the release candidate. Each fix is pinned by a regression test; AUDIT.md (next commit) carries the full register, including what is knowingly still open. SUBJ-1 (High) — an attacker could clear a victim's account lockout at will. The subject was `transliterate(lower(trim(...)))`, which is many-to-one across distinct accounts: `аdmin@` with a Cyrillic а, or plain `ADMIN@` on any engine whose unique index compares binary. Two real accounts shared one counter, and since a password reset releases on that subject, whoever controlled a look-alike could lock the victim, reset their own password to clear the shared lock, and repeat — reducing §5.2.2 to the decaying throttle it exists to replace. Subjects are now `id:<pk>` when the identifier names an account and `idn:<normalized>` when it doesn't. That fallback is load-bearing: an identifier naming no account must still accumulate a counter, or 423-vs-422 answers "does this account exist?" for free. GUARD-1 (Medium) — step-up confirmation was not guard-scoped. `lukk.confirm` resolved its verifier from `lukk.set-guard`, which never runs on a consumer's own route, so an admin route verified step-up against the USERS guard's key and audience. With ids colliding across providers, a confirmation earned on the users guard opened the admin gate. The mirror was broken too: an admin's own confirmation could never satisfy it. TOTP-1 (Medium) — a junk `recovery_code` bypassed the two-factor lockout. The exemption keyed on the field being present, but the challenge action checks the TOTP code first, so any junk recovery code resumed brute-forcing the 6-digit space against a locked account. It now requires a recovery-code-only attempt. PK-1 (Medium) — a passkey `signCount` of 0 defeated clone detection. The check also required the incoming counter to be non-zero, so a clone presenting 0 against a credential at 10 was accepted — and the 0 was written back, disarming detection for the genuine authenticator too. Synced passkeys are unaffected: their stored count is 0 forever, which is what keeps them from being flagged. SUBJ-2 (Low) — a transliteration blow-up could 500 an unauthenticated route. `max:255` bounds characters; transliteration expands ~6x in bytes, overflowing `subject` and the database cache store's `key`. Long subjects are hashed rather than truncated, so the fix cannot itself fold two identifiers together. RELEASE-1 (Low) — `lukk:release` could not release a `two_factor` or `confirm` lock: it lower-cased user-id subjects, breaking ULIDs wherever comparison is binary. PG-1 (Low) — the insert-race recovery was itself a 500 on PostgreSQL, where a failed INSERT aborts the whole transaction. The insert is nested now, so Laravel emits a SAVEPOINT. Two notes on the tests. The existing insert-race test was passing without exercising the race — its hook fired on the `hasTable` probe rather than the row lookup, so no INSERT ever collided; it now matches the lookup specifically. And `LoginRateLimiter::subject()` is removed: the lockout keys on identity now, so nothing called it.
The previous audit's findings lived only in an untracked directory and were lost — only the finding IDs survived, in a notes file. So the register goes in git this time. AUDIT.md records what was fixed in 0.5.0, what is knowingly open (with severity and a note on why it wasn't fixed), and — deliberately — what was checked and found sound, so the next audit doesn't re-derive it and a regression shows up as a change rather than as a new discovery. Also documents the residual accepted from SUBJ-1: the decaying throttle still keys on the normalized identifier, so two look-alike accounts share a bucket and can throttle each other. That bucket decays and grants no release primitive, and keying it on identity would put a user-provider lookup in front of every login attempt, including unauthenticated floods.
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.
Closes the largest remaining §5.2.2 hole, then folds in the findings of a full white-box audit run against the 0.5.0 release candidate.
1. Step-up confirmation was unthrottled
POST /auth/confirm-passwordand/auth/confirm-passkeyhad no rate limit at all — only the guard. Password confirmation re-verifies the same secret the login route throttles two ways and can now lock, so anyone already holding an access token had an unmetered password oracle behind the sudo gate.Both routes now run
lukk-confirm(rate_limits.confirm, 5/60s) keyed on the user and the IP — the per-user bucket is load-bearing, since a stolen token is one identity behind any number of addresses. The extra guards getlukk-{guard}-confirmwith the same per-user bucket. Withfeatures.lockouton, failures count toward the §5.2.2 cap under a newconfirmpurpose; a successful login or a password reset clears it.confirm-passkeyis metered but not locked — an assertion is a signature, not a guessable secret.2. Seven audit fixes
Three parallel white-box passes (auth/rate-limiting/lockout, token crypto/rotation/revocation, optional features/data-at-rest), each asked to prove findings by reproducing them. Each fix is pinned by a regression test.
SUBJ-1transliterate(lower(trim(...)))— many-to-one across real accounts (аdmin@with a Cyrillic а;ADMIN@on binary-collation engines). Two accounts shared one counter, and a password reset releases on that subject, so whoever controlled a look-alike could lock the victim, reset their own password, clear the lock, repeat. Now keyed onid:<pk>, falling back toidn:<normalized>so an identifier naming no account still counts — otherwise423-vs-422is an existence oracle.GUARD-1lukk.confirmresolved its verifier fromlukk.set-guard, which never runs on a consumer's own route — so an admin route verified against the users guard's key and audience. Reproduced:200where423was required.TOTP-1recovery_codebypassed the 2FA lockout — the exemption keyed on the field's presence, and TOTP is checked first.PK-1signCount = 0defeated clone detection and reset the ratchet permanently. Synced passkeys unaffected.SUBJ-2RELEASE-1lukk:releaselower-cased user-id subjects, breaking ULIDs on Postgres/SQLite.PG-1catchwas itself a 500 on Postgres. Now uses a savepoint.Two test notes: the existing insert-race test was passing without exercising the race (its hook fired on the
hasTableprobe, so no INSERT collided), andLoginRateLimiter::subject()is removed as dead.3.
AUDIT.mdThe previous audit's findings lived in an untracked directory and were lost — only the IDs survived. The register is in git now: fixed items, ~22 open ones with severity and rationale, and a verified sound section so the next audit doesn't re-derive it and a regression reads as a change.
Accepted residual, documented: the decaying throttle still keys on the normalized identifier, so look-alike accounts share a bucket and can throttle each other. It decays, grants no release primitive, and keying it on identity would put a provider lookup in front of every login attempt including unauthenticated floods.
Verification
305 passed (797 assertions), 100.0% coverage, Pint clean.27ed9eealso verified to pass standalone (290 tests).Docs in stsepelin/lukk-docs#8.
Greptile Summary
This PR adds per-user and per-IP throttling plus persistent lockout protection to step-up password confirmation, while correcting seven audited authentication and lockout defects.
Confidence Score: 5/5
The PR appears safe to merge, with its authentication and lockout changes covered by focused regression tests and no actionable changed-code defect identified.
The new lockout subjects, guard-aware confirmation verification, confirmation throttles, counter handling, and PostgreSQL race recovery remain internally consistent across their routes, bindings, actions, and tests.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Authenticated confirmation request] --> B{Route throttle permits?} B -- No --> C[429 response] B -- Yes --> D{Password confirmation?} D -- Yes --> E{Confirm lock held?} E -- Yes --> F[423 response] E -- No --> G{Password valid?} G -- No --> H[Record confirm failure] H --> I[422 response] G -- Yes --> J[Release confirm lock] D -- No, passkey --> K[Verify passkey assertion] J --> L[Issue guard-scoped confirmation token] K --> L L --> M[Protected route verifies token with active guard configuration]Reviews (1): Last reviewed commit: "docs: add a tracked security audit regis..." | Re-trigger Greptile