fix(user): lock out repeated failed password logins #29

Merged
nalum merged 1 commit from fix/login-lockout into main 2026-08-13 11:39:57 +00:00
Owner

Login allowed unlimited online password guessing — the bcrypt compare was the only brake. This mirrors the PIN chain's proven lockout pattern onto the password path:

Per-account (durable, on the User record — 3 new proto fields, scrubbed + not client-settable):

  • 5 misses → 15-minute timed lock (timed, not permanent: a permanent lock on an anonymous surface would let anyone shut a member out)
  • Locked refuses before bcrypt — no timing oracle, no increment while locked
  • 2s minimum gap between attempts, biting only from the second consecutive miss (a lone typo + fast correct retype still succeeds)
  • Miss committed in its own transaction before Login answers (commit-before-return, race-safe)
  • Success clears counters; every account-shaped refusal keeps the uniform "invalid credentials" body

Per-peer (in-memory): token bucket on Peer().Addr (burst 10, 0.5/s refill), charged only on failures. X-Forwarded-For deliberately not trusted (nothing vets it); behind the ingress this collapses to one shared budget — accepted at household scale, noted in code.

golang.org/x/time promoted indirect → direct. buf breaking clean (additive fields only). 9 new Ginkgo specs; make check, -race, and the integration suite pass.

Fixes #8

🤖 Generated with Claude Code

`Login` allowed unlimited online password guessing — the bcrypt compare was the only brake. This mirrors the PIN chain's proven lockout pattern onto the password path: **Per-account (durable, on the User record — 3 new proto fields, scrubbed + not client-settable):** - 5 misses → 15-minute timed lock (timed, not permanent: a permanent lock on an anonymous surface would let anyone shut a member out) - Locked refuses **before** bcrypt — no timing oracle, no increment while locked - 2s minimum gap between attempts, biting only from the second consecutive miss (a lone typo + fast correct retype still succeeds) - Miss committed in its own transaction **before** Login answers (commit-before-return, race-safe) - Success clears counters; every account-shaped refusal keeps the uniform "invalid credentials" body **Per-peer (in-memory):** token bucket on `Peer().Addr` (burst 10, 0.5/s refill), charged only on failures. X-Forwarded-For deliberately not trusted (nothing vets it); behind the ingress this collapses to one shared budget — accepted at household scale, noted in code. `golang.org/x/time` promoted indirect → direct. `buf breaking` clean (additive fields only). 9 new Ginkgo specs; `make check`, `-race`, and the integration suite pass. Fixes #8 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(user): lock out repeated failed password logins
All checks were successful
check / go (push) Successful in 2m1s
check / web (push) Successful in 1m33s
android / build (pull_request) Successful in 5m0s
521ac03343
Login answered every guess with a fresh bcrypt compare and no memory:
an internet-reachable install allowed unlimited online password
guessing (issue #8). The password path now mirrors the PIN chain
(ADR-0020): per-account misses are committed in their own ForUpdate
transaction BEFORE the error returns so parallel guesses cannot race
the counter away, the fifth miss buys a timed 15-minute lock, and a
locked account refuses before the compare so it yields no timing
oracle. The lock is timed rather than permanent because the sign-in
page is anonymous — a permanent lock would let anyone shut a member
out of their own account, and no stronger credential exists to re-arm
a password the way a password re-arms a PIN.

Every account-shaped refusal keeps the uniform "invalid credentials"
body: naming the lock or the gap would turn the throttle into an
email-existence and account-state oracle. For the same reason the
2-second attempt gap only bites from the second consecutive miss — a
member who typos once and immediately retypes correctly must not see
"invalid credentials" for a right password. Success clears the
counters, the same success-re-arms rule the PIN chain follows.

The three throttle fields live on the User record (protojson blob, no
DDL), are scrubbed by the SanitizeInterceptor like token_generation,
are nilled on Create, and sit outside the Update mask allowlist — the
state is the server's alone. On top, an in-memory per-peer budget
(golang.org/x/time/rate, promoted to direct) caps cross-account
guessing at 10 burst / one failed attempt per 2s per address; it keys
on the transport peer because nothing in this server vets
X-Forwarded-For, which behind the ingress collapses to one shared
budget — an accepted floor at household scale, with the durable
per-account lock carrying the real weight. Successful sign-ins are
never charged.

Fixes #8
nalum force-pushed fix/login-lockout from 521ac03343
All checks were successful
check / go (push) Successful in 2m1s
check / web (push) Successful in 1m33s
android / build (pull_request) Successful in 5m0s
to 66fb969e99
Some checks failed
tag / tag (push) Has been cancelled
android / build (push) Has been cancelled
check / go (push) Successful in 1m50s
check / web (push) Successful in 1m27s
android / build (pull_request) Successful in 5m26s
2026-08-13 11:33:33 +00:00
Compare
nalum scheduled this pull request to auto merge when all checks succeed 2026-08-13 11:34:05 +00:00
nalum merged commit 66fb969e99 into main 2026-08-13 11:39:57 +00:00
nalum deleted branch fix/login-lockout 2026-08-13 11:39:57 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eagraiclainne/app!29
No description provided.