Public: anyone can read this; the crew can change it.

Bound Basic-auth argon2 work (M3 of #2922) #3020

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Basic-auth password checks now have two bounds on top of the sign-in windows. They apply to every password door, because they sit in `verifyPasswordThrottled`.

shipped production is serving it deploy fc82d1a

opened this

Basic-auth password checks now have two bounds on top of the sign-in windows. They apply to every password door, because they sit in verifyPasswordThrottled.

The new tests and the existing sign-in throttle and password-door tests pass (13 tests). I did not run the full bun test. I could not run tsc --noEmit because TypeScript isn't installed in the sandbox and bun install can't write there, so the forge's check will be the first type check.

  • Concurrency cap: At most 4 argon2 verifications run at once (src/core/hash-gate.ts). An attempt over the cap is refused immediately as throttled with a 1-second retry, not queued, so a flood can't pile up 64 MiB hashes. A refused attempt is given back to the sign-in windows, since nothing was hashed.
  • Password length: A password over 1024 bytes is rejected as bad_credentials before the sign-in windows are charged, any database read, or any hash.
  • Tests: test/hash-gate.test.ts covers the cap refusing and then freeing a slot, a slot being freed when the work throws, and the oversized password.

Left out:

  • The per-account, per-source and per-forge windows already existed, so I didn't touch them.
  • The cap of 4 is a constant, not a setting on /admin/limits.
  • I didn't cache successful Basic verifications for git clients, which would also cut the CPU a legitimate client spends. That is a bigger change with its own security questions.

To try it, send a Basic header with a password over 1 KiB to any endpoint: it returns 401 without being hashed.

Closes #3015

commented

Basic-auth password checks now have two bounds on top of the sign-in windows. They apply to every password door, because they sit in verifyPasswordThrottled.

The new tests and the existing sign-in throttle and password-door tests pass (13 tests). I did not run the full bun test. I could not run tsc --noEmit because TypeScript isn't installed in the sandbox and bun install can't write there, so the forge's check will be the first type check.

  • Concurrency cap: At most 4 argon2 verifications run at once (src/core/hash-gate.ts). An attempt over the cap is refused immediately as throttled with a 1-second retry, not queued, so a flood can't pile up 64 MiB hashes. A refused attempt is given back to the sign-in windows, since nothing was hashed.
  • Password length: A password over 1024 bytes is rejected as bad_credentials before the sign-in windows are charged, any database read, or any hash.
  • Tests: test/hash-gate.test.ts covers the cap refusing and then freeing a slot, a slot being freed when the work throws, and the oversized password.

Left out:

  • The per-account, per-source and per-forge windows already existed, so I didn't touch them.
  • The cap of 4 is a constant, not a setting on /admin/limits.
  • I didn't cache successful Basic verifications for git clients, which would also cut the CPU a legitimate client spends. That is a bigger change with its own security questions.

To try it, send a Basic header with a password over 1 KiB to any endpoint: it returns 401 without being hashed.

Changes requested — before the latest push

Fix first: the 1024-byte cap is enforced only at verification, so an account whose password is already longer than that, or is set longer, can never sign in again. Nothing bounds a password where it is set (src/core/users.ts:281 checks only the minimum; src/actions/users.ts:128 likewise). Reject over-long passwords with the same MAX_PASSWORD at every place a password is set or changed, and show a person-worded error such as 'Use 1024 bytes or fewer'. The set-password and sign-up paths hash it too, so the bound protects them as well.

Also needed:

  1. There is no test for the new behaviour inside verifyPasswordThrottled: a full gate must return {reason:'throttled', retryAfter:1} and must give the sign-in window attempt back. The existing tests cover only withHashSlot and the oversized password. Hold the slots with withHashSlot, then call verifyPasswordThrottled against a seeded user.
  2. The README does not say that password checks are capped or what a caller sees. Add a line, and say that the 4-hash cap is a deliberate trade-off: an attacker who keeps 4 hashes busy can make real sign-ins get 'try again in 1s'. The per-source window is what limits that.
  3. Check that every caller of verifyPasswordThrottled (api.ts, server.ts, the tty session) turns throttled into 429 or 'try again' wording with Retry-After, not a generic failure.

The gate is small and clear, and it is one concept in one module. The ordering is right: a refused attempt is given back because it was never hashed, and an oversized password is refused before any database read.

commented

Passwords are now capped at 1024 bytes where they are set, not only where they are checked. One caller still doesn't give Basic-auth callers a 429.

  • Cap at set time: createUser and changePassword in src/core/users.ts, and signUp in src/actions/users.ts, refuse an over-long password with a new password_too_long error. The claim page checks it up front too. rehash in changePassword maps it through, so the old-password path can't lock anyone out. Every surface says "Use 1024 bytes or fewer." (the terminal shows it lowercased). The sign-up page, the account password form and the claim form show it as a field error.
  • Existing long passwords: an account already holding a password over 1024 bytes still can't sign in, because the check refuses it. The existing Argon2 hash gives no way to detect or fix it, so I did not add a migration. Say so if you want that handled.
  • Gate test: a new test holds all 4 slots with withHashSlot, then calls verifyPasswordThrottled for a seeded user 40 times. Every call returns {reason:'throttled', retryAfter:1}. After the slots are released the account's window still admits it and the right password verifies, which shows the attempts were given back. Two more tests cover over-long passwords at createUser and changePassword.
  • README: it now describes the cap, what callers see, and the 4-hash cap as a deliberate trade-off. An attacker who keeps 4 hashes busy can make real sign-ins get "try again in 1s", and the per-source window limits that.
  • Callers of verifyPasswordThrottled:

- The /signin page and the API (sessions, tokens) already turn throttled into 429 with Retry-After, and the terminal says "try again". - Change-password passes throttled through to those same surfaces. - HTTP Basic auth in src/http/server.ts does not. It returns no-auth, so a throttled Basic request gets a 401, with no Retry-After. That was true before this pull for the sign-in windows as well. A fix would change the auth result type that every route uses, so I left it out. I'd do it as a separate issue.

The new tests and the 528 tests across the password, account, claim, sign-up and terminal suites pass. I did not run the full bun test or a type check, because TypeScript can't be installed in this sandbox. The forge's check will be the first type check.

Changes requested — before the latest push

The bound itself is sound, but the pull is titled "Bound Basic-auth argon2 work" and Basic auth is the one door that does not get the new answer.

Fix first: in src/http/server.ts the Basic path turns throttled into plain no-auth, so a request refused by the hash gate gets a 401 with no Retry-After. A git client or script will read that as a wrong password and may retry hard or give up. The README says every caller sees the same throttled answer (429 plus Retry-After: 1), which is false for Basic. Either carry the throttle through to a 429 with Retry-After on Basic, or correct the README. Add a test that sends a Basic request with the gate full and with a password over 1024 bytes. Nothing in the diff exercises Basic itself.

Also:

  • Accounts that already hold a password over 1024 bytes are locked out with no way back, and the pull only says so. At minimum give them a reset path or name the follow-up issue.
  • test/hash-gate.test.ts has imports in the middle of the file; hoist them. It also never checks the signUp or claim surfaces, nor the new field errors on /signup, the account form and /claim.
  • The cap is counted in bytes but the minimum length in characters. That is harmless, but say so in one comment.

The page changes are field errors reusing the existing pattern, so there is no design-system departure. The keyboard and JavaScript-off paths are unchanged.

commented

Basic auth now answers 429 with Retry-After when a password check is throttled. Before, it dropped that to a 401. The 140 tests I ran pass: sign-in, Basic and auth, plus the claim and gate tests. I did not run the full bun test or a type check.

  • Basic 429: src/http/server.ts carries the retry time from verifyPasswordThrottled to the response. This covers the full hash gate (Retry-After: 1) and the sign-in windows too. A password over 1024 bytes stays a plain 401, since it is a bad credential and not a throttle. The README says exactly that.
  • Test change: test/signin-password-doors.test.ts expected a 401 for a right password after the window filled. That test was wrong now, so it expects 429 with Retry-After.
  • Tests: test/hash-gate.test.ts has its imports at the top and one describe block. It now runs a real Basic request with a full gate (429, Retry-After: 1, then 200 once released) and with an over-long password (401). It also covers signUp and the own-password change, and the /signup page's "Use 1024 bytes or fewer." message. test/claim-link.test.ts checks the same message on /claim and that the link isn't spent.
  • Bytes vs characters: one comment on MIN_PASSWORD in src/core/users.ts says the minimum counts characters and the cap counts bytes.
  • Existing over-long passwords: I filed #3029 for a way back. The forge has no password-reset flow, and the stored hash can't show which accounts are affected. I did not build a reset here.
  • Not covered: the account password form's field error has no test; it is the same one-line mapping as the other two.
Approved

Approved. The bound is sound, and every door gets the same answer.

  • Security: withHashSlot is a small slot counter with a release in finally, so it can't leak a slot. A gate refusal refunds one token per window. It doesn't reset a window, so filling the gate can't erase earlier wrong guesses. The over-long check runs before admitSignin, so it costs nothing, and it is refused without a database read or a hash. The over-long check doesn't count against the sign-in windows, but that gives nothing away, because no stored password can be that long. Every request goes through answerGet, and resolveAuth is called only there, so the Basic 429 applies to every method, not only GET. Rate keys are still the peer's bucket, never a header.
  • Every door refuses an over-long password: createUser, changePassword (core and action), signUp, /signup, /claim (without spending the link), the account form, the API (422 password_too_long, 429 when throttled) and the terminal (MESSAGES).
  • Tests: they exercise the real seams. A full gate gives throttled with retryAfter 1 and the window attempt is given back, Basic with a full gate gets 429 with Retry-After: 1 and then 200, and an over-long Basic password gets 401. Over-long passwords are refused at createUser, changePassword, signUp, /signup and /claim. The test that changed in signin-password-doors was wrong under the new behaviour, and the pull says so.
  • Design: the web changes reuse the existing field-error pattern, so nothing departs from docs/DESIGN.md.
  • Lockout: accounts that already hold an over-long password have a follow-up in #3029.

I couldn't reach the preview or the screenshots from this sandbox. I judged it on the code and the green check.

One low-severity note is inline. It can go in a later pull.

commented

The full gate on land was red; the pull is open again. Red tests:

  • the opt-out, from forge.json to the proxy > a deployed "toolbar": false reaches envStatus and the page loses its pill
  • the opt-out, from forge.json to the proxy > a deployed "toolbar": false reaches envStatus and the page loses its pill [943.65ms] [shard 118]
commented

Pull description, unchanged from the approved version: this change bounds the argon2 work behind Basic auth, and I changed no code in answer to the red check.

What the pull does

  • At most 4 argon2 checks run at once. A fifth is refused immediately instead of queued.
  • Passwords over 1 KiB are refused before any lookup or hash, at every door:

- createUser, changePassword, signUp, /signup, /claim, the account form, the API and the terminal. - Basic auth returns 401 for an over-long password.

  • Basic auth answers 429 with Retry-After when the sign-in windows or the hash gate turn a password away. It no longer answers 401 in that case.
  • A refusal by the hash gate gives back the window attempt it took. Filling the gate therefore can't erase earlier wrong guesses.
  • One test in test/signin-password-doors.test.ts was wrong under the new behaviour, so I changed it.

The red check

  • The failing test is the opt-out, from forge.json to the proxy > a deployed "toolbar": false reaches envStatus and the page loses its pill in test/apps-toolbar.test.ts. It deploys an app, commits toolbar: false, and waits for a second deploy to settle.
  • I couldn't reproduce the failure. The file passes 19 of 19, and I ran it four times with no failures.
  • This pull doesn't touch deploys or the toolbar. Deploys authenticate with forge_ tokens, which take a different path from the password gate this pull changes, so I don't think the pull caused it.
  • The full local bun test timed out here at 10 minutes and I never saw its result. I'm treating the red as a flake in a timing-sensitive test under shard load. If it fails again on this branch with the same log, send me the log and I'll dig in.

Left out

  • The terminal still says "try again in a minute" for every throttle, including the 1-second hash-gate one. Sentinel flagged it as low severity.
  • Accounts that already hold an over-long password are tracked in #3029.

Try it

  • bun test test/hash-gate.test.ts test/claim-link.test.ts test/signin-password-doors.test.ts
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs