Skip to content

feat: throttle + lock step-up confirmation, and seven audit fixes - #23

Merged
stsepelin merged 3 commits into
mainfrom
feat/confirm-throttle-and-audit
Aug 21, 2026
Merged

stsepelin merged 3 commits into
mainfrom
feat/confirm-throttle-and-audit

Conversation

@stsepelin

@stsepelin stsepelin commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

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-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 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 get lukk-{guard}-confirm with the same per-user bucket. With features.lockout on, failures count toward the §5.2.2 cap under a new confirm purpose; a successful login or a password reset clears it. confirm-passkey is 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.

ID Sev
SUBJ-1 High An attacker could clear a victim's lockout at will. The subject was transliterate(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 on id:<pk>, falling back to idn:<normalized> so an identifier naming no account still counts — otherwise 423-vs-422 is an existence oracle.
GUARD-1 Medium Step-up bypass under multi-guard. lukk.confirm resolved its verifier from lukk.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: 200 where 423 was required.
TOTP-1 Medium A junk recovery_code bypassed the 2FA lockout — the exemption keyed on the field's presence, and TOTP is checked first.
PK-1 Medium signCount = 0 defeated clone detection and reset the ratchet permanently. Synced passkeys unaffected.
SUBJ-2 Low Transliteration expands ~6× in bytes, so a valid identifier could overflow the column and 500 an unauthenticated route.
RELEASE-1 Low lukk:release lower-cased user-id subjects, breaking ULIDs on Postgres/SQLite.
PG-1 Low The insert-race catch was 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 hasTable probe, so no INSERT collided), and LoginRateLimiter::subject() is removed as dead.

3. AUDIT.md

The 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. 27ed9ee also 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.

  • Keys persistent login lockouts by resolved account identity with a normalized fallback for unknown identifiers.
  • Makes confirmation verification guard-aware and adds guard-scoped confirmation rate limiters.
  • Corrects TOTP recovery-code exemption handling and passkey sign-counter regression detection.
  • Repairs lockout release behavior and PostgreSQL insert-race recovery.
  • Adds regression coverage and a tracked security audit register.

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

Filename Overview
src/Actions/AttemptLogin.php Derives one identity-based lockout subject for gating, failure recording, and release, and clears confirmation locks after successful password login.
src/Actions/ConfirmPassword.php Adds persistent confirmation lock checks, failure counting, and successful-attempt release behavior.
src/Auth/LoginRateLimiter.php Separates persistent identity subjects from normalized throttle identifiers and hashes byte-expanded normalized values.
src/Http/Middleware/RequireConfirmation.php Verifies confirmation tokens using configuration for the guard that authenticated the request.
src/LukkServiceProvider.php Registers default and extra-guard confirmation limiters with both IP and authenticated-user buckets.
src/Lockout/DatabaseLockoutRepository.php Uses a nested transaction/savepoint so PostgreSQL unique-insert races can recover within the outer transaction.
src/Actions/FinishPasskeyLogin.php Rejects counter regression after a positive sign count and prevents persisted counters from moving backward.
src/Actions/VerifyTwoFactorChallenge.php Restricts the lockout exemption to recovery-code-only attempts so an attached junk code cannot bypass TOTP locking.
src/Console/ReleaseLockoutCommand.php Resolves login subjects to account identities while preserving case-sensitive user IDs for two-factor and confirmation releases.
src/routes/api.php Applies the new confirmation throttles to password and passkey confirmation endpoints.

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]
Loading

Reviews (1): Last reviewed commit: "docs: add a tracked security audit regis..." | Re-trigger Greptile

`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.
@stsepelin
stsepelin merged commit 9644759 into main Aug 21, 2026
12 checks passed
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