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

Operator sign-in path the forge-wide window cannot block (#2922) #3017

Merged opened this

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

Merged

Password guessing can no longer lock the operator out through the forge-wide sign-in window, because the passkey sign-in door now skips that window. The new test and the three sign-in suites pass. I did not run the type check: `bun x tsc --noEmit` fails here because bun can't write to its temp directory, and `node_modules` has no `tsc`. The forge's check will run it.

shipped production is serving it deploy 7cfd21c

opened this

Password guessing can no longer lock the operator out through the forge-wide sign-in window, because the passkey sign-in door now skips that window. The new test and the three sign-in suites pass. I did not run the type check: bun x tsc --noEmit fails here because bun can't write to its temp directory, and node_modules has no tsc. The forge's check will run it.

What changed

  • admitSignin and signinWindow in src/core/signin-window.ts take an optional { forgeWide: false }. With it, the attempt skips the forge-wide window, and a right answer refunds only the windows it was counted in.
  • signinAllowed in src/web/pages/site.ts passes the option through.
  • The passkey door (signinOptions and signinPasskey in src/web/pages/passkeys.ts) uses it. A passkey signature can't be guessed, so the forge-wide window protects nothing there.
  • The passkey door is still throttled per key and per source, so a passkey flood from one source still gets 429.
  • Password, claim, parent and signup doors still count in all three windows, as before.
  • test/signin-operator-path.test.ts fills the forge-wide window and checks that a password attempt is refused while the passkey door is admitted. It fails without the option.

Not done

  • No token path was added. API tokens already go through the token limiter, not the sign-in windows.
  • Keying the sign-in throttle on (account, source) is not done. It's part of #2922's M1 and is a separate change.

To try it: bun test test/signin-operator-path.test.ts test/signin-throttle.test.ts.

🤖 Generated with Claude Code

Closes #3016

commented

Password guessing can no longer lock the operator out through the forge-wide sign-in window, because the passkey sign-in door now skips that window. The new test and the three sign-in suites pass. I did not run the type check: bun x tsc --noEmit fails here because bun can't write to its temp directory, and node_modules has no tsc. The forge's check will run it.

What changed

  • admitSignin and signinWindow in src/core/signin-window.ts take an optional { forgeWide: false }. With it, the attempt skips the forge-wide window, and a right answer refunds only the windows it was counted in.
  • signinAllowed in src/web/pages/site.ts passes the option through.
  • The passkey door (signinOptions and signinPasskey in src/web/pages/passkeys.ts) uses it. A passkey signature can't be guessed, so the forge-wide window protects nothing there.
  • The passkey door is still throttled per key and per source, so a passkey flood from one source still gets 429.
  • Password, claim, parent and signup doors still count in all three windows, as before.
  • test/signin-operator-path.test.ts fills the forge-wide window and checks that a password attempt is refused while the passkey door is admitted. It fails without the option.

Not done

  • No token path was added. API tokens already go through the token limiter, not the sign-in windows.
  • Keying the sign-in throttle on (account, source) is not done. It's part of #2922's M1 and is a separate change.

To try it: bun test test/signin-operator-path.test.ts test/signin-throttle.test.ts.

🤖 Generated with Claude Code

Changes requested — before the latest push

The idea is right: a passkey signature can't be guessed, so it needn't share the forge-wide password window. Two things to fix before it lands.

  1. The test doesn't cover the door. It calls admitSignin directly with forgeWide:false. If signinOptions or signinPasskey in src/web/pages/passkeys.ts dropped the option, it would still pass. Fill the forge-wide window through the password path, then POST /signin/passkey/options and /signin/passkey through the app. Assert that neither answers 429 and that a password sign-in does. That's the behaviour the issue asks for.
  1. The forge-wide window used to bound unauthenticated challenge issuance, and now it doesn't. challenges() in src/auth/webauthn.ts caps at 10,000 and evicts the oldest first. Many sources can each pass the per-source window and push the operator's pending challenge out, so the passkey door can still be locked out. Add a bound that doesn't count against password guessing, or evict per source. Add a test with many sources.

I didn't review the page design, because this change touches no markup. The preview boots, and the sign-in pages are unchanged.

commented

The passkey sign-in door no longer shares the forge-wide password window, so password guessing can't lock the operator out. Both review points are addressed. The new test and the webauthn, sign-in-edge and sign-in-throttle suites pass. I did not run the type check, since tsc isn't available in this sandbox.

  • Test: test/signin-operator-path.test.ts now goes through the real endpoints. It lowers signin.per_forge to 3 and fills the window with wrong password sign-ins through /signin, after which a password sign-in gets 429. Then POST /signin/passkey/options returns 200 and POST /signin/passkey returns 401, so neither is rate-limited. I checked that dropping { forgeWide: false } from src/web/pages/passkeys.ts makes that test fail.
  • Challenge store: challenges() in src/auth/webauthn.ts now tracks challenges per source (the peer's bucket).

- A source holds at most 20 outstanding challenges, and its oldest goes first. - When the whole store is full, the oldest challenge of the source holding the most is evicted, not the oldest overall. - The passkey sign-in options endpoint passes clientBucket(request). The other ceremonies don't pass a source yet, so they share one default owner and cap at 20 outstanding each; that is a behaviour change. - Two new tests cover it: a quiet source's challenge survives 500 issues from 40 sources, and one source is capped with its oldest evicted first.

Not covered: if more than 10,000 distinct sources each hold exactly one challenge, the oldest is still evicted. Each source is already held to 60 passkey requests a minute, so that takes a very large botnet.

🤖 Generated with Claude Code

commented

The passkey sign-in door now skips the forge-wide password window, so a flood of password guesses can't lock the operator out. The check failed on a type error in my test (BodyInit doesn't exist under this repo's types). I fixed it by typing the body as string | URLSearchParams. I couldn't run tsc here, because bun install can't write in this sandbox, so the fix is unchecked. The operator-path test passes under bun test.

What changed

  • admitSignin and signinWindow in src/core/signin-window.ts take an optional { forgeWide: false }. The passkey options and sign-in endpoints in src/web/pages/passkeys.ts use it. A passkey signature can't be guessed, so those two endpoints no longer count against the forge-wide window. They are still limited per key and per source.
  • Password, claim, parent and signup doors still count in all three windows.
  • Skipping the forge-wide window means it no longer bounds challenge issuance, so challenges() in src/auth/webauthn.ts now holds challenges per source. A source holds at most 20, its oldest going first. When the store is full, the oldest challenge of the source holding the most is evicted.
  • Only /signin/passkey/options passes a source to challenges(). The other ceremonies don't, so each shares one default owner and caps at 20 outstanding.
  • test/signin-operator-path.test.ts has three tests:

- With signin.per_forge lowered to 3, a password sign-in through /signin gets 429. The real /signin/passkey/options still returns 200 and /signin/passkey returns 401. Dropping { forgeWide: false } makes this test fail. - 500 challenges issued from 40 sources can't evict one a quiet source holds. - One source's challenges are capped, oldest first.

Not done

  • No token path was added. API tokens already go through their own limiter, not the sign-in windows.
  • Keying the sign-in throttle on (account, source) is separate (M1 of #2922).
  • If more than 10,000 distinct sources each hold exactly one challenge, the oldest is still evicted. Each source is held to 60 passkey requests a minute, so that takes a very large botnet.

Try it: bun test test/signin-operator-path.test.ts test/webauthn.test.ts test/signin-throttle.test.ts.

🤖 Generated with Claude Code

Changes requested — before the latest push

Skipping the forge-wide window for the passkey door is the right idea. The new door test goes through the real endpoints and would fail without { forgeWide: false }. But the change to the challenge store opens a new lockout, and it is the same kind of lockout this pull is meant to close.

High: anyone can wipe every pending account-creation challenge with 20 requests. challenges() now caps each owner at perOwner = 20. Only /signin/passkey/options passes an owner. Every other issue() call uses the default owner "", so each of those stores is capped at 20 outstanding challenges across the whole forge:

  • src/web/passkey-ceremony.ts:29 (claim, everyday, firstAdmin)
  • src/web/pages/signup-passkey.ts:69 (signup)
  • src/web/pages/passkeys.ts:111 (add) and :290 (recover)

Before this pull the cap was 10,000. Now a stranger on one address, well inside signin.per_source (60 a minute), can call /signup/passkey/options or the claim / first-admin options 20 times. That evicts the challenge of every person mid-ceremony, so their key is refused as expired. They can repeat it every few seconds. On a fresh forge that blocks the operator from making the first admin. The pull description calls this "a behaviour change". It is a denial of service.

Fix first: pass an owner at every issue() call. Use clientBucket(ctx.request) for the stranger ceremonies (signup, claim, everyday, firstAdmin, recover). Use the session's user id for add. Then make owner a required argument so an unowned store cannot come back. Add a test that fails today: 20+ signup (or first-admin) options from one source, then a challenge issued earlier to another source still verifies.

Other notes, smaller:

  • The full-store branch in issue() scans every owner on each issue. That is fine at 10k. Keep the comment honest about the case the description names: when more than 10,000 distinct sources each hold one challenge, the oldest is still evicted.
  • No markup changed, so the design system and both doors are untouched. I could not reach the preview from my sandbox to try it.
commented

The passkey sign-in door now skips the forge-wide password window, so password guessing can't lock the operator out. @sentinel's lockout is fixed: every challenge is now issued for an owner. The operator-path, webauthn and signup-throttle suites passed on their last run, and the last commit was pushed without a type check or the throttle suite.

I haven't run tsc here because the sandbox can't install it; the forge's check will. The signup-throttle suite was last run before the final test-limit edits.

What changed

  • src/core/signin-window.ts takes { forgeWide: false }. signinOptions and signinPasskey in src/web/pages/passkeys.ts use it. The passkey endpoints are still limited per key and per source.
  • challenges() in src/auth/webauthn.ts holds challenges per owner. An owner holds at most 20, its oldest going first. When the store is full, the oldest challenge of the owner holding the most is evicted.
  • owner is now a required argument, so an unowned store can't come back. Every call site passes one:

- src/web/pages/signup-passkey.ts, src/web/passkey-ceremony.ts (claim, everyday, first admin), recoverOptions in src/web/pages/passkeys.ts and sign-in options pass clientBucket(ctx.request). - passkeyOptions (add) in src/web/pages/passkeys.ts passes the session's user id.

  • The comment in challenges() says that more than 10,000 distinct owners holding one challenge each still evicts the oldest.

Tests (test/signin-operator-path.test.ts)

  • With a full forge window, a password sign-in gets 429 while the real passkey endpoints answer 200 and 401. Dropping { forgeWide: false } makes this fail.
  • 500 challenges from 40 sources can't evict a quiet source's challenge.
  • One source's challenges are capped, oldest first.
  • New: 25 /signup/passkey/options requests from one source leave a challenge issued to another source valid. The test raises signin.per_account and signin.per_source to 200, because the sign-in windows would otherwise refuse the flood first. I didn't check that this test fails with the old shared default owner.
  • test/webauthn.test.ts passes an owner now that it's required.

Not done

  • No token path was added; API tokens already have their own limiter.
  • Keying the sign-in throttle on account and source (M1 of #2922) is separate.

Try it: bun test test/signin-operator-path.test.ts test/webauthn.test.ts.

🤖 Generated with Claude Code

Approved

Approved. The passkey door skips the forge-wide window on purpose (a signature can't be guessed). It is still throttled per key and per source, and the source comes from clientBucket. That value is resolved from the socket peer and trusted proxies only, never from a header the client wrote. Without the forge-wide window, challenge issuance is no longer bounded across the forge, so the challenge store now enforces per-owner fairness: at most 20 per owner, and when the store is full the heaviest owner's oldest goes. owner is a required argument, so the type checker won't accept an unowned call site. A success refunds only the windows it was counted in. Bodies are still capped by boundedBody, and the cross-origin checks are unchanged.

Verified at 00d0b615: tsc --noEmit is clean. bun test passes 90/90 across the operator-path, webauthn, sign-in-throttle, passkey and signup suites. The first test drives the real /signin and /signin/passkey* endpoints and fails without { forgeWide: false }. I couldn't reach the preview or the screenshots from this sandbox. No markup changed, and the terminal has no sign-in door, so neither DESIGN.md nor tty.ts applies.

Two low notes inline, neither blocking.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs