diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3814206..cddacdd 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2128,8 +2128,11 @@ paths: matching challenge is stashed server-side and redeemed by finish. Mounted Public (no prior principal) and gated on local_auth_enabled. An unknown address and a known account with no enrolled passkey both return the SAME 400 - no_passkey, so the door is not an existence oracle; a per-recipient cooldown - (shared shape with the email-OTP and op-login doors) throttles probing. + no_passkey, so the door is not an existence oracle; the per-address sign-in + rate limit bounds probing. Each begin stashes a ceremony of its own beside the + account's other live ones, so a begin by anyone who knows the address never + cancels its owner's. One network (an IPv4 address or IPv6 /48) holds at most 32 + live login challenges (429 too_many_challenges past that). x-felis-face: [external] x-felis-tier: public security: [] @@ -2171,7 +2174,7 @@ paths: schema: { $ref: '#/components/schemas/Error' } '429': description: >- - A passkey login for this recipient was started too recently (otp_resend_cooldown); + This network already holds 32 live passkey login challenges (too_many_challenges); or this client address called the sign-in doors too often (rate_limited, with Retry-After). content: application/json: @@ -2190,12 +2193,12 @@ paths: description: >- Second leg of the public passkey door: the caller returns the email (to re-select the account) and the raw navigator.credentials.get() assertion. The - stashed login challenge is consumed atomically and the assertion is verified - against it; on success a host-only felis_session cookie is minted. Both players + live login challenge whose value the assertion signed (response.clientDataJSON) + is consumed atomically and the assertion is verified against it; on success a host-only felis_session cookie is minted. Both players and staff may log in this way — a passkey is a two-factor authenticator (possession + user verification), strong enough to stand alone without the in-game approval op-login requires. Every failure mode (unknown address, no - live challenge, expired challenge, bad assertion) collapses into one uniform + live challenge for the signed value, expired challenge, bad assertion) collapses into one uniform passkey_login_invalid, so the door reveals nothing. x-felis-face: [external] x-felis-tier: public @@ -2266,10 +2269,10 @@ paths: credential it holds for this RP and the account is revealed only by the userHandle inside the signed assertion at finish. The challenge cannot be user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed - back at finish. Mounted Public and gated on local_auth_enabled. There is no - recipient or principal to key a per-caller cooldown on, so one client is bounded - by the per-address sign-in rate limit (429 rate_limited) and the table by a hard - global cap on live challenges (429 too_many_challenges). Inert for a credential until its owner + back at finish. Mounted Public and gated on local_auth_enabled. One client is + bounded by the per-address sign-in rate limit (429 rate_limited), one network + (an IPv4 address or IPv6 /48) to 32 live challenges, and the table by a hard + global cap of 16384 (both 429 too_many_challenges). Inert for a credential until its owner enrolls a resident passkey; email-OTP and username-first passkey remain the fallbacks, so no authenticator is ever locked out. x-felis-face: [external] @@ -2315,9 +2318,9 @@ paths: schema: { $ref: '#/components/schemas/Error' } '429': description: >- - Too many discoverable logins are in flight server-wide (too_many_challenges; - the cap is global, so no per-recipient signal leaks); or this client address - called the sign-in doors too often (rate_limited, with Retry-After). + This network already holds 32 live discoverable challenges, or the store is at + its global cap (too_many_challenges); or this client address called the + sign-in doors too often (rate_limited, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } @@ -2413,6 +2416,10 @@ paths: purpose. An address with no account returns the SAME 202 with no code minted, and the per-recipient cooldown is kept on that path too, so probing reveals nothing (existence is learnt only at the sanctioned /auth/options oracle). + One code is mailed per recipient per minute: a start inside that window gets + the same 202 (expires_at of the live code) and mails nothing. A start never + cancels the codes already mailed; the three newest live codes all work, and + signing in with one spends the rest. An account that spent its daily wrong-code budget (10 per 24h, across every code) also gets the same 202 and no mail until the window ends. Gated on local_auth_enabled. @@ -2458,8 +2465,7 @@ paths: schema: { $ref: '#/components/schemas/Error' } '429': description: >- - A code for this recipient was requested too recently (otp_resend_cooldown); - or this client address called the sign-in doors too often (rate_limited, with Retry-After); + This client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). content: application/json: @@ -2539,7 +2545,10 @@ paths: op_login purpose, returning the request handle the browser polls. A non-staff or unknown address gets the SAME 202 with a random, non-persisted handle and no mail, so this never becomes a staff-enumeration oracle. A staff account that - spent its daily wrong-code budget gets the same neutral 202. Gated on + spent its daily wrong-code budget gets the same neutral 202. One code is mailed + per recipient per minute: a staff start inside that window opens a real request + but mails nothing, and the code already in the inbox finishes it. A start never + cancels the codes already mailed (the three newest live codes all work). Gated on local_auth_enabled. x-felis-face: [external] x-felis-tier: public @@ -2583,8 +2592,7 @@ paths: schema: { $ref: '#/components/schemas/Error' } '429': description: >- - A code for this recipient was requested too recently (otp_resend_cooldown); - or this client address called the sign-in doors too often (rate_limited, with Retry-After); + This client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). content: application/json: diff --git a/internal/api/api.go b/internal/api/api.go index 14a86a6..af014c9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -962,7 +962,8 @@ func (c *cooldownLimiter) record(name string) { } // reserve atomically checks name's cooldown AND, if the window is open, records it -// in the same critical section, returning the reservation time and true. Unlike +// in the same critical section, returning the reservation time and true; inside the +// window it returns the standing reservation's time and false. Unlike // allowed→record there is no gap between the check and the commit, so a burst of // truly concurrent callers yields exactly one winner. Use it where the throttle is // the SOLE defense and each admitted call has a non-idempotent side effect (an OTP @@ -979,7 +980,7 @@ func (c *cooldownLimiter) reserve(name string, window time.Duration) (time.Time, t := c.now() c.noteWindow(window, t) if last, ok := c.last[name]; ok && t.Sub(last) < window { - return time.Time{}, false + return last, false } c.last[name] = t return t, true diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 870ddc2..8c1d00b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -83,6 +83,9 @@ type fakeRepo struct { // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP + // otpSeq orders codes by insertion (the PG created_at): a frozen test clock mints + // several codes at one instant, so time cannot tell the oldest apart. + otpSeq int // otpBudget mirrors otp_failure_windows, keyed user|purpose. otpBudget map[string]*fakeOTPBudget // op-login requests (spec §B op-login). opLogins mirrors op_login_requests keyed @@ -140,6 +143,9 @@ type fakePasskeyChallenge struct { expiresAt time.Time consumed bool createdAt time.Time + // challenge and source are set on email-first login rows only (migration 0029). + challenge string + source string } // fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user @@ -149,6 +155,7 @@ type fakeDiscoverableChallenge struct { sessionData []byte expiresAt time.Time consumed bool + source string } // fakeDataHold mirrors a player_data_holds row at the granularity the verifiable @@ -177,10 +184,13 @@ func (f *fakeRepo) OTPLockedUntil(_ context.Context, userID, purpose string, now return otpLockEnd(b.windowStart, b.failures, now), nil } -// chargeOTP mirrors chargeOTPMismatch: one wrong guess on the code and the budget. -func (f *fakeRepo) chargeOTP(live *fakeEmailOTP, now time.Time) error { - live.attempts++ - key := live.userID + "|" + live.purpose +// chargeOTP mirrors chargeOTPMismatch: one wrong guess on each open code and one on +// the (user, purpose) budget. +func (f *fakeRepo) chargeOTP(open []*fakeEmailOTP, userID, purpose string, now time.Time) error { + for _, o := range open { + o.attempts++ + } + key := userID + "|" + purpose b := f.otpBudget[key] if b == nil || !b.windowStart.Add(otpFailureWindow).After(now) { b = &fakeOTPBudget{windowStart: now} @@ -206,6 +216,7 @@ type fakeEmailOTP struct { expiresAt time.Time consumed bool createdAt time.Time + seq int } // fakeSession mirrors a sessions row: its owner, its expiry, and whether it has @@ -403,12 +414,42 @@ func (f *fakeRepo) CreateEmailOTP(_ context.Context, id, userID, email, codeHash delete(f.otps, k) } } + f.otpSeq++ f.otps[id] = &fakeEmailOTP{ id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose, expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation + seq: f.otpSeq, } return nil } + +// AddLoginEmailOTP mirrors PGRepo.AddLoginEmailOTP: the live codes of (user, purpose) +// that expired at now go, then all but the newest otpLiveLoginCodes-1, and the new +// code joins the rest. +func (f *fakeRepo) AddLoginEmailOTP(_ context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error { + var live []*fakeEmailOTP + for k, o := range f.otps { + if o.userID != userID || o.purpose != purpose || o.consumed { + continue + } + if !o.expiresAt.After(now) { + delete(f.otps, k) + continue + } + live = append(live, o) + } + sort.Slice(live, func(i, j int) bool { return live[i].seq > live[j].seq }) + for _, o := range live[min(len(live), otpLiveLoginCodes-1):] { + delete(f.otps, o.id) + } + f.otpSeq++ + f.otps[id] = &fakeEmailOTP{ + id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose, + expiresAt: expiresAt, createdAt: now, seq: f.otpSeq, + } + return nil +} + func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) (string, error) { var live *fakeEmailOTP for _, o := range f.otps { // newest live (user, purpose) @@ -432,7 +473,7 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s return "", ErrOTPLocked } if live.codeHash != codeHash { - return "", f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code + return "", f.chargeOTP([]*fakeEmailOTP{live}, userID, purpose, now) // a typo costs an attempt but does not consume the code } // A DIFFERENT verified holder of the same address → ErrEmailTaken, code left // live — mirrors PGRepo's guard + the users_verified_email_unique index. @@ -478,6 +519,44 @@ func (f *fakeRepo) CreatePasskeyChallenge(_ context.Context, id, userID, purpose } return nil } + +// AddPasskeyLoginChallenge / ConsumePasskeyLoginChallenge mirror PGRepo's email-first +// login pair: begin reaps the account's spent login rows, refuses once source holds +// maxLiveChallengesPerSource live login rows, and stores the challenge beside the +// others; consume redeems the live row whose challenge the browser signed. +func (f *fakeRepo) AddPasskeyLoginChallenge(_ context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error { + fromSource := 0 + for k, c := range f.passkeyChallenges { + if c.userID == userID && c.purpose == purpose && (c.consumed || !c.expiresAt.After(now)) { + delete(f.passkeyChallenges, k) + continue + } + if c.source == source && !c.consumed && c.expiresAt.After(now) { + fromSource++ + } + } + if fromSource >= maxLiveChallengesPerSource { + return ErrTooManyPasskeyChallenges + } + f.passkeyChallenges[id] = &fakePasskeyChallenge{ + id: id, userID: userID, purpose: purpose, sessionData: sessionData, + expiresAt: expiresAt, createdAt: now, challenge: challenge, source: source, + } + return nil +} +func (f *fakeRepo) ConsumePasskeyLoginChallenge(_ context.Context, userID, purpose, challenge string, now time.Time) ([]byte, error) { + for _, c := range f.passkeyChallenges { + if c.userID != userID || c.purpose != purpose || c.challenge != challenge || c.consumed { + continue + } + if !c.expiresAt.After(now) { + return nil, ErrPasskeyChallengeInvalid + } + c.consumed = true + return c.sessionData, nil + } + return nil, ErrPasskeyChallengeInvalid +} func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purpose string, now time.Time) ([]byte, error) { var live *fakePasskeyChallenge for _, c := range f.passkeyChallenges { // newest live (user, purpose) @@ -496,19 +575,28 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp } // CreateDiscoverableChallenge / ConsumeDiscoverableChallenge mirror PGRepo's non-user-keyed -// contract (task #40): begin reaps expired/consumed rows then stashes under the opaque handle, -// and consume redeems by handle, single-use, expiry checked. discoverableFull forces the capped -// path so the begin 429 branch is reachable without inserting thousands of rows. -func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id string, sessionData []byte, now, expiresAt time.Time) error { +// contract (task #40): begin reaps expired/consumed rows, refuses once source holds +// maxLiveChallengesPerSource live rows, then stashes under the opaque handle; consume redeems +// by handle, single-use, expiry checked. discoverableFull forces the global cap so the begin +// 429 branch is reachable without inserting thousands of rows. +func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error { if f.discoverableFull { - return ErrTooManyDiscoverableChallenges + return ErrTooManyPasskeyChallenges } + fromSource := 0 for k, c := range f.discoverableChallenges { // reap (DELETE ... expires_at<=now OR consumed_at NOT NULL) if c.consumed || !c.expiresAt.After(now) { delete(f.discoverableChallenges, k) + continue + } + if c.source == source { + fromSource++ } } - f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt} + if fromSource >= maxLiveChallengesPerSource { + return ErrTooManyPasskeyChallenges + } + f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt, source: source} return nil } func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) { @@ -624,6 +712,9 @@ type fakePasskeyVerifier struct { // stashed SessionData round-trips and the existing credentials reach the verifier. lastUser PasskeyUser lastSession []byte + // loginSession, when set, is the SessionData BeginLogin hands out in place of the + // per-user marker, so a test can tell two live login ceremonies apart at finish. + loginSession []byte // discoverableUserHandle is the userHandle the fake feeds to FinishDiscoverableLogin's // resolver, so a handler test drives the userHandle → UserByID → session-mint wiring for a // chosen account (or an unknown handle, to exercise the resolve-fails branch). @@ -657,6 +748,9 @@ func (v *fakePasskeyVerifier) BeginLogin(user PasskeyUser) (json.RawMessage, []b if opts == nil { opts = json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`) } + if v.loginSession != nil { + return opts, v.loginSession, nil + } return opts, []byte("login-session:" + user.ID), nil } @@ -1473,36 +1567,40 @@ func (f *fakeRepo) UserByEmail(_ context.Context, email string) (*StaffUser, err return nil, ErrNotFound } -// ConsumeLoginEmailOTP mirrors PGRepo.ConsumeLoginEmailOTP: it redeems the newest -// live code for (user, purpose) WITHOUT the identity side-effect (login already -// resolved the userID via UserByEmail, so the address is settled). It charges an -// attempt on a hash mismatch (exactly like VerifyEmailOTP) but never writes -// users.email or runs the verified-email guard. A missing/expired/consumed code → -// ErrOTPInvalid; a mismatch → ErrOTPInvalid too (and costs an attempt without -// consuming); a locked code → ErrOTPLocked; a match → consumed, nil. +// ConsumeLoginEmailOTP mirrors PGRepo.ConsumeLoginEmailOTP: every live (unconsumed, +// unexpired) code of (user, purpose) is a candidate. Nothing live → ErrOTPInvalid; +// the account lock next; every live code out of attempts → ErrOTPLocked; no match → +// one attempt on each open code plus one budget failure, nothing consumed; a match +// spends every live code. No identity side-effect (login already resolved the +// userID via UserByEmail, so the address is settled). func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) error { - var live *fakeEmailOTP - for _, o := range f.otps { // newest live (user, purpose), mirroring VerifyEmailOTP - if o.userID != userID || o.purpose != purpose || o.consumed { + var live, open []*fakeEmailOTP + matched := false + for _, o := range f.otps { + if o.userID != userID || o.purpose != purpose || o.consumed || !o.expiresAt.After(now) { continue } - if live == nil || o.createdAt.After(live.createdAt) { - live = o + live = append(live, o) + if o.attempts < otpMaxAttempts { + open = append(open, o) + matched = matched || o.codeHash == codeHash } } - if live == nil || !live.expiresAt.After(now) { + if len(live) == 0 { return ErrOTPInvalid } if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() { return &OTPAccountLockedError{Until: until} } - if live.attempts >= otpMaxAttempts { + if len(open) == 0 { return ErrOTPLocked } - if live.codeHash != codeHash { - return f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code + if !matched { + return f.chargeOTP(open, userID, purpose, now) // a typo costs an attempt but consumes nothing + } + for _, o := range live { + o.consumed = true } - live.consumed = true return nil } diff --git a/internal/api/errors.go b/internal/api/errors.go index 09b6953..5974dcb 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -87,14 +87,13 @@ var ( // guard and gets a 409 instead of a raw unique-violation 500. Distinct from // ErrConflict so the message can name the cause (the email is spoken for). ErrEmailTaken = errors.New("email already verified on another account") - // ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless") - // login challenge store is at its hard cap of live rows (task #40, migration 0013). - // Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user - // supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the - // table is capped globally and a begin over the cap is refused. Distinct from the other - // sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears - // as challenges expire), never a 400 that invites an immediate retry. - ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight") + // ErrTooManyPasskeyChallenges means a passkey login begin was refused because too many + // login challenges are live: the caller's source already holds its allowance + // (maxLiveChallengesPerSource), or the discoverable store is at its global cap + // (maxLiveDiscoverableChallenges). Distinct from the other sentinels so the handler + // answers 429 (a transient "too busy, retry" — both bounds clear as challenges expire), + // never a 400 that invites an immediate retry. + ErrTooManyPasskeyChallenges = errors.New("too many passkey login challenges in flight") // ErrNotStopped means a world-volume operation was refused because the server is // not fully stopped: desiredState is not Stopped, or its pod is still shutting // down (phase Stopping) and holds the volume while it saves. diff --git a/internal/api/handlers_auth_email.go b/internal/api/handlers_auth_email.go index 8c19b08..6322464 100644 --- a/internal/api/handlers_auth_email.go +++ b/internal/api/handlers_auth_email.go @@ -60,6 +60,11 @@ type loginEmailStartRequest struct { // no code minted: the response never distinguishes the two, and the reservation is // kept on that path too so repeated probing of one address is throttled identically // to repeated sends. +// +// A start never cancels the codes already mailed (AddLoginEmailOTP keeps the newest +// otpLiveLoginCodes live), and a start inside the cooldown answers the same 202 without +// minting: the code mailed moments ago is still good. So anyone who knows an address +// can only add codes to its owner's inbox, never keep the owner from signing in. func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) { if !localAuthEnabled(r.Context(), a.Repo) { writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled", @@ -98,8 +103,11 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) { lim := a.otpLimiter() emailAt, ok := lim.reserve(emailKey, otpResendCooldown) if !ok { - writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", - "a code was sent recently; wait a moment before requesting another")) + // A start for this address went through less than a cooldown ago, and the code + // it mailed (if the address has an account) is still live. Answer as that start + // did, expiry included, and mail nothing: the owner — or whoever typed the + // address — lands on the code screen and the code already in the inbox works. + writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": emailAt.Add(otpTTL).UTC()}) return } committed := false @@ -109,16 +117,17 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) { } }() - // Compute the expiry once so the neutral (no-account) branch and the real-send - // branch return byte-identical bodies. - expiresAt := a.now().Add(otpTTL) + // Compute the expiry once, from the reservation, so the neutral (no-account) + // branch, the real-send branch and a start inside the window all return + // byte-identical bodies. + expiresAt := emailAt.Add(otpTTL) u, err := a.Repo.UserByEmail(r.Context(), email) switch { case errors.Is(err, ErrNotFound): // No verified account for this address. Return the same 202 as a real send - // (no code minted) and KEEP the reservation, so probing an unknown address is - // throttled exactly like resending to a known one — the throttle reveals + // (no code minted) and KEEP the reservation, so a probe of an unknown address + // holds the window exactly like a send to a known one — the window reveals // nothing, and the accepted /auth/options oracle is where existence is learnt. committed = true writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()}) @@ -158,7 +167,7 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) { // record. The login redeem (ConsumeLoginEmailOTP) never reads or writes this // address, so the stored casing is authoritative and the row's email snapshot is // purely for the audit trail. - if err := a.Repo.CreateEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, expiresAt); err != nil { + if err := a.Repo.AddLoginEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, a.now(), expiresAt); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_auth_email_test.go b/internal/api/handlers_auth_email_test.go index 58ac23f..d922d43 100644 --- a/internal/api/handlers_auth_email_test.go +++ b/internal/api/handlers_auth_email_test.go @@ -152,8 +152,7 @@ func TestLoginEmailVertical(t *testing.T) { // TestLoginEmailStartNeutralOnUnknownAddress pins the start-side anti-enumeration // contract: an address with no verified account yields a 202 BYTE-IDENTICAL to a // real send (frozen clock ⇒ same expires_at), mints and mails nothing, audits -// nothing — and still burns the cooldown window, so probing is throttled exactly -// like sending. +// nothing — and a re-probe inside the cooldown answers the same 202 a resend does. func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) { // A real send for comparison. apiK, _, _ := seedLoginEmailAPI(t) @@ -183,12 +182,14 @@ func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) { t.Errorf("neutral path must mint/mail/audit nothing, got otps=%d mails=%d audits=%d", len(repoU.otps), mailerU.calls, len(repoU.audits)) } - // The reservation is KEPT on the neutral path: re-probing the same unknown - // address inside the window is throttled identically to a resend. - if w := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"ghost@example.net"}`, jsonHeader); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" { - t.Fatalf("re-probe of unknown address: code = %d body %s, want 429 otp_resend_cooldown", + // Re-probing inside the window answers exactly as the first probe did. + if w := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"ghost@example.net"}`, jsonHeader); w.Code != http.StatusAccepted || w.Body.String() != wK.Body.String() { + t.Fatalf("re-probe of unknown address: code = %d body %s, want the same 202 as a real send", w.Code, w.Body.String()) } + if len(repoU.otps) != 0 || mailerU.calls != 0 { + t.Errorf("re-probe must mint/mail nothing, got otps=%d mails=%d", len(repoU.otps), mailerU.calls) + } // An UNVERIFIED account is indistinguishable from no account: UserByEmail only // resolves proven addresses, so the door never mails one nobody controls. @@ -278,13 +279,14 @@ func TestLoginEmailGates(t *testing.T) { // TestLoginEmailStartRateLimited closes the unauthenticated email-bomb vector on the // public door: one send per recipient per window, keyed case-insensitively, and // namespaced apart from the authenticated onboarding throttle so neither door can -// starve the other. +// starve the other. A start inside the window is answered like the one that sent, +// so whoever asks lands on the code screen with the mail already in the inbox. func TestLoginEmailStartRateLimited(t *testing.T) { start := func(eh http.Handler, email string) *httptest.ResponseRecorder { return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader) } - t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) { + t.Run("a start inside the window mails nothing and keeps the first code", func(t *testing.T) { api, repo, mailer := seedLoginEmailAPI(t) clock := time.Unix(1_700_000_000, 0) api.Now = func() time.Time { return clock } @@ -293,28 +295,41 @@ func TestLoginEmailStartRateLimited(t *testing.T) { if w := start(eh, "player@example.net"); w.Code != http.StatusAccepted { t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String()) } - if w := start(eh, "player@example.net"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" { - t.Fatalf("immediate resend: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String()) + code := mailer.code + clock = clock.Add(30 * time.Second) + w := start(eh, "player@example.net") + if w.Code != http.StatusAccepted { + t.Fatalf("resend inside the window: code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + // The expiry is the first code's, the one the inbox holds. + if b := acctBody(t, w); b["sent"] != true || b["expires_at"] != "2023-11-14T22:23:20Z" { + t.Errorf("resend body = %v, want sent:true expires_at:2023-11-14T22:23:20Z", b) } if mailer.calls != 1 || len(repo.otps) != 1 { - t.Errorf("throttled resend must not mint or mail: mails=%d otps=%d, want 1/1", + t.Errorf("resend inside the window must not mint or mail: mails=%d otps=%d, want 1/1", mailer.calls, len(repo.otps)) } - clock = clock.Add(otpResendCooldown + time.Second) - if w := start(eh, "player@example.net"); w.Code != http.StatusAccepted { - t.Fatalf("post-cooldown send: code = %d, want 202 (%s)", w.Code, w.Body.String()) + if w := do(eh, "POST", "/api/v1/auth/email/verify", + `{"email":"player@example.net","code":"`+code+`"}`, jsonHeader); w.Code != http.StatusOK { + t.Fatalf("first code after a resend: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + clock = clock.Add(otpResendCooldown) + if w := start(eh, "player@example.net"); w.Code != http.StatusAccepted || mailer.calls != 2 { + t.Fatalf("post-cooldown send: code = %d mails = %d, want 202 and a second mail (%s)", + w.Code, mailer.calls, w.Body.String()) } }) t.Run("throttle key is case-insensitive", func(t *testing.T) { - api, _, _ := seedLoginEmailAPI(t) + api, _, mailer := seedLoginEmailAPI(t) eh := api.ExternalHandler() if w := start(eh, "Player@Example.NET"); w.Code != http.StatusAccepted { t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String()) } // A recased retype is the same mailbox: it must hit the same window. - if w := start(eh, "player@example.net"); w.Code != http.StatusTooManyRequests { - t.Fatalf("recased resend: code = %d, want 429 (key must be lowercased)", w.Code) + if w := start(eh, "player@example.net"); w.Code != http.StatusAccepted || mailer.calls != 1 { + t.Fatalf("recased resend: code = %d mails = %d, want 202 and no second mail (key must be lowercased)", + w.Code, mailer.calls) } }) @@ -339,6 +354,95 @@ func TestLoginEmailStartRateLimited(t *testing.T) { }) } +// TestLoginStartsInsideTheWindowMatchExactly: with a clock that moves on every read, +// a start inside the cooldown still answers with the very expires_at the first start +// returned, on both email doors and for known and unknown addresses alike, so a +// repeat start is indistinguishable from the first down to the nanosecond. +func TestLoginStartsInsideTheWindowMatchExactly(t *testing.T) { + ticking := func(api *API) { + clock := time.Unix(1_700_000_000, 0) + api.Now = func() time.Time { + clock = clock.Add(time.Millisecond) + return clock + } + } + expiry := func(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + if w.Code != http.StatusAccepted { + t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + e, _ := acctBody(t, w)["expires_at"].(string) + return e + } + for _, email := range []string{"player@example.net", "ghost@example.net"} { + api, _, _ := seedLoginEmailAPI(t) + ticking(api) + eh := api.ExternalHandler() + start := func() *httptest.ResponseRecorder { + return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader) + } + first, again := expiry(t, start()), expiry(t, start()) + if first != "2023-11-14T22:23:20.001Z" || again != first { + t.Errorf("email door %s: expires_at %q then %q, want 2023-11-14T22:23:20.001Z twice", email, first, again) + } + } + for _, email := range []string{"op@example.net", "ghost@example.net"} { + api, _, _ := seedOpLoginAPI(t) + ticking(api) + eh := api.ExternalHandler() + first, again := expiry(t, startOp(eh, email)), expiry(t, startOp(eh, email)) + if first != "2023-11-14T22:23:20.001Z" || again != first { + t.Errorf("op door %s: expires_at %q then %q, want 2023-11-14T22:23:20.001Z twice", email, first, again) + } + } +} + +// TestLoginEmailStartKeepsEarlierCodes is the stranger-keeps-starting case: someone +// who knows the address starts a login every cooldown. Each start adds a code to the +// owner's inbox and never cancels one, so the owner's own code keeps working until +// otpLiveLoginCodes newer ones exist; signing in spends every code still out. +func TestLoginEmailStartKeepsEarlierCodes(t *testing.T) { + api, repo, mailer := seedLoginEmailAPI(t) + clock := time.Unix(1_700_000_000, 0) + api.Now = func() time.Time { return clock } + eh := api.ExternalHandler() + start := func() string { + t.Helper() + if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"player@example.net"}`, jsonHeader); w.Code != http.StatusAccepted { + t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + code := mailer.code + clock = clock.Add(otpResendCooldown) + return code + } + verify := func(code string) *httptest.ResponseRecorder { + return do(eh, "POST", "/api/v1/auth/email/verify", + `{"email":"player@example.net","code":"`+code+`"}`, jsonHeader) + } + + owner := start() + second := start() + third := start() + if mailer.calls != 3 || len(repo.otps) != 3 { + t.Fatalf("mails=%d otps=%d, want 3/3 (a start must not cancel earlier codes)", mailer.calls, len(repo.otps)) + } + fourth := start() + if len(repo.otps) != 3 { + t.Fatalf("otps = %d after a fourth start, want 3 (the oldest goes)", len(repo.otps)) + } + if w := verify(owner); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" { + t.Fatalf("code with three newer ones: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String()) + } + if w := verify(second); w.Code != http.StatusOK { + t.Fatalf("second code while two newer are live: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + for name, code := range map[string]string{"third": third, "fourth": fourth} { + if w := verify(code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" { + t.Errorf("%s code after the sign-in: code = %d body %s, want 400 invalid_code", name, w.Code, w.Body.String()) + } + } +} + // TestLoginEmailVerifyRejections is the redeem-side failure matrix. The anchor case // is uniformity: an unknown address and a wrong code for a known address answer with // the same (code, message) envelope, so the verify half never doubles as an diff --git a/internal/api/handlers_email_otp.go b/internal/api/handlers_email_otp.go index 9a600a3..b2148ae 100644 --- a/internal/api/handlers_email_otp.go +++ b/internal/api/handlers_email_otp.go @@ -57,6 +57,15 @@ const ( // use a passkey. otpFailureBudget = 10 otpFailureWindow = 24 * time.Hour + // otpLiveLoginCodes is how many codes a pre-session login door (email login, + // op-login) keeps redeemable per (account, purpose). Anyone who knows an address + // can start a login for it, so a start never cancels the codes already mailed: + // with one mail per otpResendCooldown, every code stays good for at least + // otpLiveLoginCodes cooldowns (or its TTL), and a stranger's starts only add + // codes to the owner's inbox. A wrong guess is compared with every live code, so + // the daily blind-hit chance rises to otpFailureBudget*otpLiveLoginCodes/1e6 + // (3e-5). Enforced in the Repo so the fake and PG agree. + otpLiveLoginCodes = 3 ) // OTPMailer delivers a one-time code to an email address. It is a seam, not a diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go index 62c1eb3..a356f04 100644 --- a/internal/api/handlers_op_login.go +++ b/internal/api/handlers_op_login.go @@ -65,6 +65,12 @@ type opLoginStartRequest struct { // unknown address yields the SAME 202 with a random, non-persisted handle and no mail, // so this never doubles as a staff-enumeration oracle (op.console's own Zero-Trust is // the edge gate; this app-layer neutrality covers the hostname-agnostic route). +// +// Anyone who knows a staff address can start a login for it, so a start never cancels +// the codes already mailed (AddLoginEmailOTP), and a start inside the per-recipient +// cooldown still gets a request of its own but mails nothing: the code mailed moments +// ago is live and finishes it. A stranger's starts therefore only add codes to the +// staff inbox and never keep its owner from signing in. func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) { if !localAuthEnabled(r.Context(), a.Repo) { writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled", @@ -94,33 +100,32 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) { } // Per-recipient cooldown reserved BEFORE any work, identical to the console email - // door: one winner per window, and the neutral (non-staff) branch keeps the - // reservation too so probing an address is throttled exactly like a real send. The - // key is namespaced apart from the console door's "login:email:" so the two + // door: one mail per window, and the neutral (non-staff) branch keeps the + // reservation too so a probe holds the window exactly like a real send. The key is + // namespaced apart from the console door's "login:email:" so the two // unauthenticated doors never perturb each other's throttle. emailKey := "oplogin:email:" + strings.ToLower(email) lim := a.otpLimiter() - emailAt, ok := lim.reserve(emailKey, otpResendCooldown) - if !ok { - writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", - "a code was sent recently; wait a moment before requesting another")) - return - } + emailAt, fresh := lim.reserve(emailKey, otpResendCooldown) committed := false - defer func() { - if !committed { - lim.release(emailKey, emailAt) - } - }() + if fresh { + defer func() { + if !committed { + lim.release(emailKey, emailAt) + } + }() + } - // Compute expiry once so the neutral and real branches return identical-shaped - // bodies and (real branch) the request row and its OTP are coterminous. - expiresAt := a.now().Add(otpTTL) + // Compute expiry once, from the reservation, so the neutral and real branches + // return identical bodies and the request row and its OTP are coterminous. Inside + // the cooldown the live code is the one the standing reservation mailed, so the + // request ends with it. + expiresAt := emailAt.Add(otpTTL) // neutral returns the indistinguishable no-op success: a plausible but non-persisted // handle that status(id) reads approved:false forever (no row, never approvable). It - // mints nothing and mails nothing, and KEEPS the reservation so probing is throttled - // exactly like a real send. + // mints nothing and mails nothing, and KEEPS the reservation so a probe holds the + // window exactly like a real send. neutral := func() { fakeID, err := newOTPID() if err != nil { @@ -172,6 +177,15 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } + if !fresh { + // Inside the cooldown: the code mailed with the standing reservation finishes + // this request too, and the mailbox still sees one code per window. + a.auditAccount(r, u, "auth.op_login.requested", "") + writeJSON(w, http.StatusAccepted, map[string]any{ + "request_id": id, "expires_at": expiresAt.UTC(), + }) + return + } code, err := newEmailOTP() if err != nil { writeError(w, r, err) @@ -184,7 +198,7 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) { } // Mint+mail against the STORED staff address (UserByEmail matched case-insensitively); // the request row snapshots the same address for its audit trail. - if err := a.Repo.CreateEmailOTP(r.Context(), otpID, u.ID, u.Email, otpCodeHash(code), otpPurposeOpLogin, expiresAt); err != nil { + if err := a.Repo.AddLoginEmailOTP(r.Context(), otpID, u.ID, u.Email, otpCodeHash(code), otpPurposeOpLogin, a.now(), expiresAt); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_op_login_test.go b/internal/api/handlers_op_login_test.go index a27f87d..3535c19 100644 --- a/internal/api/handlers_op_login_test.go +++ b/internal/api/handlers_op_login_test.go @@ -212,8 +212,8 @@ func TestOpLoginOwnerAdmitted(t *testing.T) { // TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is // the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying -// a request_id + expires_at, mint/mail nothing, and still burn the per-recipient -// cooldown — so neither the response nor the throttle tells a caller who is staff. +// a request_id + expires_at and mint/mail nothing, and a re-probe inside the cooldown +// does the same — so neither the response nor the throttle tells a caller who is staff. func TestOpLoginStartNeutral(t *testing.T) { check := func(t *testing.T, seed func(*fakeRepo), email, wantReason, wantUser string) { t.Helper() @@ -248,9 +248,14 @@ func TestOpLoginStartNeutral(t *testing.T) { repo.audits[0].ActorUserID != wantUser { t.Errorf("neutral start audits = %+v, want one auth.op_login.failed %s by %q", repo.audits, wantReason, wantUser) } - // The reservation is KEPT: re-probing the same address is throttled like a resend. - if w := startOp(eh, email); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" { - t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String()) + // Re-probing inside the window: the same 202 shape, still nothing behind it. + w = startOp(eh, email) + if b := acctBody(t, w); w.Code != http.StatusAccepted || b["request_id"] == "" || b["expires_at"] != "2023-11-14T22:23:20Z" { + t.Fatalf("re-probe: code = %d body %s, want 202 with a request_id and the first expiry", w.Code, w.Body.String()) + } + if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 { + t.Errorf("re-probe must mint/mail nothing: reqs=%d otps=%d mails=%d", + len(repo.opLogins), len(repo.otps), mailer.calls) } } @@ -264,6 +269,64 @@ func TestOpLoginStartNeutral(t *testing.T) { }) } +// TestOpLoginStartByAStranger is the case of someone who knows a staff address and +// keeps starting logins for it. Their start mails the code to the staff inbox; the +// staff member's own start inside the cooldown still gets a request of its own +// (nothing new mailed, same expiry as the live code), and that inbox code finishes +// it. A start after the cooldown mails a second code without cancelling the first. +func TestOpLoginStartByAStranger(t *testing.T) { + api, repo, mailer := seedOpLoginAPI(t) + clock := time.Unix(1_700_000_000, 0) + api.Now = func() time.Time { return clock } + eh := api.ExternalHandler() + ih := api.InternalHandler() + requestID := func(w *httptest.ResponseRecorder) string { + t.Helper() + if w.Code != http.StatusAccepted { + t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + id, _ := acctBody(t, w)["request_id"].(string) + return id + } + + strangers := requestID(startOp(eh, "op@example.net")) + inboxCode := mailer.code + clock = clock.Add(30 * time.Second) + w := startOp(eh, "op@example.net") + own := requestID(w) + if own == strangers || repo.opLogins[own] == nil { + t.Fatalf("own request %q must be a new, stored request beside the stranger's %q", own, strangers) + } + if b := acctBody(t, w); b["expires_at"] != "2023-11-14T22:23:20Z" { + t.Errorf("own start expires_at = %v, want 2023-11-14T22:23:20Z (the live code's)", b["expires_at"]) + } + if mailer.calls != 1 || len(repo.otps) != 1 { + t.Fatalf("start inside the cooldown: mails=%d otps=%d, want 1/1", mailer.calls, len(repo.otps)) + } + if w := approveOp(ih, own, opUUID); w.Code != http.StatusOK { + t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String()) + } + if w := finishOp(eh, own, inboxCode); w.Code != http.StatusOK { + t.Fatalf("finish own request with the inbox code: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + + // After the cooldown a start mails a second code; the first one still works. + clock = clock.Add(otpResendCooldown) + first := requestID(startOp(eh, "op@example.net")) + firstCode := mailer.code + clock = clock.Add(otpResendCooldown) + requestID(startOp(eh, "op@example.net")) + if mailer.calls != 3 { + t.Fatalf("mails = %d, want 3", mailer.calls) + } + if w := approveOp(ih, first, opUUID); w.Code != http.StatusOK { + t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String()) + } + if w := finishOp(eh, first, firstCode); w.Code != http.StatusOK { + t.Fatalf("finish with the earlier code after a newer start: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } +} + // TestOpLoginStatusNeutral proves status is never an enumeration oracle: it returns // approved:true ONLY for a genuinely approved, live, unconsumed request, and // approved:false (never 404) for an unknown, expired, denied, or consumed handle — all diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index dbaa9cb..61718fb 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -4,9 +4,11 @@ import ( "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "errors" + "fmt" "io" "net/http" "strings" @@ -241,6 +243,61 @@ func unwrapPublicKey(options json.RawMessage) json.RawMessage { return env.PublicKey } +// optionsChallenge returns the challenge in go-webauthn's request options +// ({"publicKey": {"challenge": ...}}) in canonical form: the value the browser will +// sign into the assertion's clientDataJSON. +func optionsChallenge(options json.RawMessage) (string, error) { + var env struct { + PublicKey struct { + Challenge string `json:"challenge"` + } `json:"publicKey"` + } + if err := json.Unmarshal(options, &env); err != nil { + return "", err + } + return canonicalChallenge(env.PublicKey.Challenge) +} + +// assertionChallenge returns, in canonical form, the challenge a +// navigator.credentials.get() result signed: response.clientDataJSON is base64url +// JSON whose challenge member is that value. It only picks the ceremony to verify +// against; the verifier checks the signature over the same bytes. +func assertionChallenge(assertion json.RawMessage) (string, error) { + var body struct { + Response struct { + ClientDataJSON string `json:"clientDataJSON"` + } `json:"response"` + } + if err := json.Unmarshal(assertion, &body); err != nil { + return "", err + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(body.Response.ClientDataJSON, "=")) + if err != nil { + return "", err + } + var clientData struct { + Challenge string `json:"challenge"` + } + if err := json.Unmarshal(raw, &clientData); err != nil { + return "", err + } + return canonicalChallenge(clientData.Challenge) +} + +// canonicalChallenge decodes a base64url challenge, padded or not (go-webauthn +// accepts both), and re-encodes it unpadded, so the stored and the signed forms +// compare equal. An empty or undecodable challenge is an error. +func canonicalChallenge(s string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")) + if err != nil { + return "", err + } + if len(b) == 0 { + return "", errors.New("empty challenge") + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + // handlePasskeyRegisterBegin mints a credential-creation challenge for the caller // (spec §14, external app face). It loads the passkeys the caller has already bound so // the ceremony excludes them (one authenticator binds once), asks the verifier for the @@ -461,12 +518,16 @@ type passkeyLoginBeginRequest struct { // (Public, pre-session). It resolves the typed email to an account, loads the // passkeys that account has bound, and asks the verifier for the assertion options // + opaque SessionData the browser needs for navigator.credentials.get(). The -// SessionData is stashed under a short TTL, keyed to the user so the finish step -// can consume it. Requires local sessions to be enabled (like the other pre-session -// doors). A user with no bound passkey, an unknown email, and a real account with -// passkeys are distinguished by status code (400 vs 200) — this is an accepted -// enumeration trade-off (the /auth/options oracle is the sanctioned place to learn -// existence), but the per-recipient cooldown below makes probing impractical. +// SessionData is stashed under a short TTL beside the account's other live login +// ceremonies, tagged with the challenge the browser will sign, so finish consumes +// exactly the ceremony it answers: anyone who knows the address can begin a login for +// it, and a begin never cancels the owner's. The store holds each network to +// maxLiveChallengesPerSource live login challenges (429 too_many_challenges past it). +// Requires local sessions to be enabled (like the other pre-session doors). A user +// with no bound passkey, an unknown email, and a real account with passkeys are +// distinguished by status code (400 vs 200) — this is an accepted enumeration +// trade-off (the /auth/options oracle is the sanctioned place to learn existence); +// volume per caller is bounded by the auth-door bucket. func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { if !localAuthEnabled(r.Context(), a.Repo) { writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled", @@ -492,29 +553,9 @@ func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { return } - // Per-recipient cooldown reserved BEFORE any work, identical to the email-OTP and - // op-login doors: one winner per window, so a burst of probes is throttled. The - // key is namespaced apart from the other pre-session doors so they never perturb - // each other's throttle. - emailKey := "passkey:login:" + strings.ToLower(email) - lim := a.otpLimiter() - emailAt, ok := lim.reserve(emailKey, otpResendCooldown) - if !ok { - writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", - "a passkey login was started recently; wait a moment before requesting another")) - return - } - committed := false - defer func() { - if !committed { - lim.release(emailKey, emailAt) - } - }() - u, err := a.Repo.UserByEmail(r.Context(), email) if err != nil { if errors.Is(err, ErrNotFound) { - committed = true // keep the reservation so probing is throttled writeError(w, r, newError(http.StatusBadRequest, "no_passkey", "no passkey enrolled for this account; use email or operator login")) return @@ -529,7 +570,6 @@ func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { return } if len(creds) == 0 { - committed = true writeError(w, r, newError(http.StatusBadRequest, "no_passkey", "no passkey enrolled for this account; use email or operator login")) return @@ -547,17 +587,26 @@ func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { "could not start passkey login")) return } + challenge, err := optionsChallenge(options) + if err != nil { + writeError(w, r, fmt.Errorf("passkey login options carry no challenge: %w", err)) + return + } id, err := newPasskeyID() if err != nil { writeError(w, r, err) return } - expiresAt := a.now().Add(passkeyChallengeTTL) - if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, u.ID, passkeyPurposeLogin, sessionData, expiresAt); err != nil { + now := a.now() + if err := a.Repo.AddPasskeyLoginChallenge(r.Context(), id, u.ID, passkeyPurposeLogin, + challengeSource(a.clientIP(r)), challenge, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil { + if errors.Is(err, ErrTooManyPasskeyChallenges) { + writeError(w, r, errTooManyChallenges) + return + } writeError(w, r, err) return } - committed = true // go-webauthn wraps the assertion options as {"publicKey": {...}}; the panel's // username-login flow reads them flat (options.challenge, options.allowCredentials), // so strip the envelope. (Discoverable login keeps the envelope — see its handler.) @@ -575,7 +624,8 @@ type passkeyLoginFinishRequest struct { // handlePasskeyLoginFinish verifies a passkey assertion and mints a session (Public, // pre-session). It resolves the email to the account, atomically consumes the -// stashed login challenge (a missing or expired one → 400), verifies the assertion +// stashed login challenge the assertion signed (clientDataJSON names it; a missing, +// expired, or unnamed one → 400), verifies the assertion // against the SessionData, and mints a felis_session. Both players and staff may // log in this way — the passkey is a two-factor authenticator (possession + // biometric/PIN), strong enough to stand alone without the in-game approval the @@ -623,7 +673,14 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) { return } - sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), u.ID, passkeyPurposeLogin, a.now()) + challenge, err := assertionChallenge(req.Assertion) + if err != nil { + a.authFailure(r, "passkey", "bad_assertion", u) + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey login could not be completed; begin again")) + return + } + sessionData, err := a.Repo.ConsumePasskeyLoginChallenge(r.Context(), u.ID, passkeyPurposeLogin, challenge, a.now()) if err != nil { if errors.Is(err, ErrPasskeyChallengeInvalid) { a.authFailure(r, "passkey", "challenge_invalid", u) diff --git a/internal/api/handlers_passkey_discoverable.go b/internal/api/handlers_passkey_discoverable.go index 8b19cae..b29ecea 100644 --- a/internal/api/handlers_passkey_discoverable.go +++ b/internal/api/handlers_passkey_discoverable.go @@ -19,12 +19,17 @@ import ( // and username-first passkey remain the fallbacks, so an authenticator that stored no resident // key is never locked out — only its from-zero convenience is unavailable. // -// Anti-abuse divergence from the email-first door: that door reserves a per-recipient cooldown -// (a.otpLimiter) keyed on the typed email. A usernameless begin has no recipient OR principal to -// key a fair per-caller limit on, so one client is bounded by the per-address token bucket every -// public auth door sits behind (throttleAuthDoor), and the table by a hard global cap on live -// challenges enforced atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges -// → 429). +// Anti-abuse: a usernameless begin has no recipient OR principal to key a limit on, so one client +// is bounded by the per-address token bucket every public auth door sits behind +// (throttleAuthDoor), each network (IPv4 host or IPv6 /48, challengeSource) by +// maxLiveChallengesPerSource live challenges, and the table by a global cap on live challenges; +// CreateDiscoverableChallenge enforces both bounds atomically (ErrTooManyPasskeyChallenges → +// 429). A flood from one network fills its own allowance and leaves every other network its +// sign-ins. + +// errTooManyChallenges answers a passkey login begin over a challenge bound. +var errTooManyChallenges = newError(http.StatusTooManyRequests, "too_many_challenges", + "too many passkey logins in progress from this network; try again in a few minutes") // handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public, // pre-session). It has no request body — the whole point is that the caller supplies no @@ -59,10 +64,9 @@ func (a *API) handlePasskeyLoginDiscoverableBegin(w http.ResponseWriter, r *http return } now := a.now() - if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil { - if errors.Is(err, ErrTooManyDiscoverableChallenges) { - writeError(w, r, newError(http.StatusTooManyRequests, "too_many_challenges", - "too many passkey logins in progress; try again shortly")) + if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, challengeSource(a.clientIP(r)), sessionData, now, now.Add(passkeyChallengeTTL)); err != nil { + if errors.Is(err, ErrTooManyPasskeyChallenges) { + writeError(w, r, errTooManyChallenges) return } writeError(w, r, err) diff --git a/internal/api/handlers_passkey_discoverable_test.go b/internal/api/handlers_passkey_discoverable_test.go index 5f4833c..4057747 100644 --- a/internal/api/handlers_passkey_discoverable_test.go +++ b/internal/api/handlers_passkey_discoverable_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -136,11 +137,10 @@ func TestPasskeyDiscoverableLoginVertical(t *testing.T) { } } -// TestPasskeyDiscoverableLoginBeginCapped pins the server-side volumetric bound: with the store -// at its hard cap, begin answers 429 too_many_challenges and stashes nothing. This is the only -// per-server brake on the usernameless begin (there is no recipient/principal to key a per-caller -// cooldown on, so volumetric per-source limiting is delegated to the edge) — a reap alone cannot -// bound a burst, since freshly-inserted rows are not yet expired. +// TestPasskeyDiscoverableLoginBeginCapped pins the store-wide bound: with the store at its hard +// cap, begin answers 429 too_many_challenges and stashes nothing. A reap alone cannot bound a +// burst, since freshly-inserted rows are not yet expired; the per-source bound below keeps one +// network from reaching this cap. func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) { api, repo, _ := seedDiscoverableLoginAPI(t) repo.discoverableFull = true @@ -155,6 +155,35 @@ func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) { } } +// TestPasskeyDiscoverableLoginBeginPerSourceCap: the usernameless begin has no account to key +// on, so the store holds each network (IPv4 address, IPv6 /48) to 32 live challenges. The +// begins come from 33 different /64s inside one /48, so the per-/64 auth-door bucket never +// trips and only the /48 bound refuses the last; another network still begins. +func TestPasskeyDiscoverableLoginBeginPerSourceCap(t *testing.T) { + api, repo, _ := seedDiscoverableLoginAPI(t) + api.ClientIPHeader = "CF-Connecting-IP" + eh := api.ExternalHandler() + from := func(ip string) *httptest.ResponseRecorder { + return do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, + map[string]string{"Content-Type": "application/json", "CF-Connecting-IP": ip}) + } + for i := 1; i <= 32; i++ { + if w := from(fmt.Sprintf("2001:db8:7:%x::1", i)); w.Code != http.StatusOK { + t.Fatalf("begin %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String()) + } + } + w := from("2001:db8:7:ff::1") + if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" { + t.Fatalf("33rd begin from the /48: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String()) + } + if len(repo.discoverableChallenges) != 32 { + t.Errorf("stashed = %d, want 32", len(repo.discoverableChallenges)) + } + if w := from("2001:db8:8::1"); w.Code != http.StatusOK { + t.Fatalf("begin from another /48: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } +} + // TestPasskeyDiscoverableLoginBeginVerifierError pins the verifier-fault path: a // BeginDiscoverableLogin failure is a server-side fault, answered passkey_login_failed, and // stashes no challenge (there is nothing to stash — the ceremony never started). diff --git a/internal/api/handlers_passkey_login_test.go b/internal/api/handlers_passkey_login_test.go index 8007a38..fd5fb23 100644 --- a/internal/api/handlers_passkey_login_test.go +++ b/internal/api/handlers_passkey_login_test.go @@ -2,8 +2,10 @@ package api import ( "bytes" + "encoding/base64" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -23,8 +25,9 @@ import ( // - Anti-enumeration on finish: unknown email, no live challenge, expired challenge and // a bad assertion all collapse to ONE passkey_login_invalid envelope, so the finish // half is never an existence/state oracle. -// - Cooldown seals the accepted begin-side trade-off: has-passkey (200) vs no_passkey -// (400) is a status oracle, but one begin per recipient per window throttles probing. +// - Ceremonies coexist: finish picks the live challenge the browser signed, so a +// begin by someone else who knows the address never cancels the owner's, and each +// network holds a bounded number of live login challenges. // - Staff admitted: unlike the email door's staff_account refusal, a passkey stands // alone (possession + user-verification), so role=admin mints a session here. @@ -57,14 +60,30 @@ func seedLoginPasskeyAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) { // plantLoginChallenge seeds a stashed LOGIN-purpose challenge for u1 directly, so the // finish-side branches (expired, verification failure) are reachable under the frozen // clock without running begin first. Mirrors plantPasskeyChallenge, but scoped to -// passkeyPurposeLogin so it is only ever consumed by the login door. +// passkeyPurposeLogin so it is only ever consumed by the login door. Its challenge is +// the one the fake verifier hands out by default, so loginAssertion("cred-1", +// "YXNzZXJ0") answers it. func plantLoginChallenge(repo *fakeRepo, id string, expiresAt time.Time) { repo.passkeyChallenges[id] = &fakePasskeyChallenge{ - id: id, userID: "u1", purpose: passkeyPurposeLogin, + id: id, userID: "u1", purpose: passkeyPurposeLogin, challenge: "YXNzZXJ0", source: "192.0.2.1", sessionData: []byte("login-session:u1"), expiresAt: expiresAt, createdAt: expiresAt, } } +// loginAssertion is the JSON a browser posts back from navigator.credentials.get(): the +// credential id plus response.clientDataJSON, the base64url JSON that carries the signed +// challenge. +func loginAssertion(credID, challenge string) string { + clientData := base64.RawURLEncoding.EncodeToString( + []byte(`{"type":"webauthn.get","challenge":"` + challenge + `","origin":"https://console.example.net"}`)) + return `{"id":"` + credID + `","type":"public-key","response":{"clientDataJSON":"` + clientData + `"}}` +} + +// finishBody is a username-first finish body answering challenge with cred-1. +func finishBody(email, challenge string) string { + return `{"email":"` + email + `","assertion":` + loginAssertion("cred-1", challenge) + `}` +} + // TestPasskeyLoginVertical walks the whole returning-player slice across the external // face: begin resolves the mixed-case account from a lowercase-typed email, hands its // bound credential to the verifier, returns the assertion options verbatim and stashes @@ -105,8 +124,7 @@ func TestPasskeyLoginVertical(t *testing.T) { // 2) finish — typed in yet another casing, proving the finish-side resolver is // case-insensitive too — verifies the assertion and mints the session. The body // carries NO challenge, only email+assertion. - w = do(eh, "POST", "/api/v1/auth/passkey/login/finish", - `{"email":"PLAYER@example.NET","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + w = do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("PLAYER@example.NET", "YXNzZXJ0"), jsonHeader) if w.Code != http.StatusOK { t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String()) } @@ -144,8 +162,7 @@ func TestPasskeyLoginVertical(t *testing.T) { } // 3) single-use: the consumed challenge buys nothing a second time. - if w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", - `{"email":"player@example.net","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { + if w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("player@example.net", "YXNzZXJ0"), jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { t.Fatalf("replay of consumed challenge: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String()) } } @@ -153,8 +170,8 @@ func TestPasskeyLoginVertical(t *testing.T) { // TestPasskeyLoginBeginNoPasskey pins the begin-side anti-enumeration floor: an unknown // email and a KNOWN verified account that has enrolled no passkey answer the SAME // no_passkey envelope, so the two are indistinguishable. (The remaining has-passkey-vs-not -// status split is the documented, accepted trade-off; the cooldown below makes probing -// it impractical.) Neither path stashes a challenge, and both KEEP the reservation. +// status split is the documented, accepted trade-off; the auth-door bucket bounds how +// fast one address can probe it.) Neither path stashes a challenge. func TestPasskeyLoginBeginNoPasskey(t *testing.T) { begin := func(eh http.Handler, email string) *httptest.ResponseRecorder { return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader) @@ -184,26 +201,11 @@ func TestPasskeyLoginBeginNoPasskey(t *testing.T) { len(repoU.passkeyChallenges), len(repoN.passkeyChallenges)) } }) - - t.Run("the no_passkey path KEEPS the reservation so probing is throttled", func(t *testing.T) { - api, _, _ := seedLoginPasskeyAPI(t) - eh := api.ExternalHandler() - if w := begin(eh, "ghost@example.net"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "no_passkey" { - t.Fatalf("first probe: code = %d body %s, want 400 no_passkey", w.Code, w.Body.String()) - } - // Re-probing the same unknown address inside the window is throttled identically to - // a real begin — the response is not the only channel; the throttle is sealed too. - if w := begin(eh, "ghost@example.net"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" { - t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String()) - } - }) } -// TestPasskeyLoginBeginVerifierError pins the reserve→rollback path: a BeginLogin failure -// is a server-side fault, not a probe signal, so it answers passkey_login_failed AND -// RELEASES the reservation — the immediate retry is admitted, not 429'd. Distinguishing -// 400-not-429 on the retry is what proves the release: a kept reservation would 429 before -// ever reaching BeginLogin. +// TestPasskeyLoginBeginVerifierError pins the verifier-fault path: a BeginLogin failure is +// a server-side fault, answered passkey_login_failed, stashing nothing, and the immediate +// retry reaches the verifier again. func TestPasskeyLoginBeginVerifierError(t *testing.T) { api, repo, v := seedLoginPasskeyAPI(t) v.beginLoginErr = errors.New("no assertable credential") @@ -216,7 +218,7 @@ func TestPasskeyLoginBeginVerifierError(t *testing.T) { t.Errorf("a failed begin must stash no challenge, got %d", len(repo.passkeyChallenges)) } if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"player@example.net"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" { - t.Fatalf("retry after verifier error: code = %d body %s, want 400 passkey_login_failed (reservation must be released, not 429)", w.Code, w.Body.String()) + t.Fatalf("retry after verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String()) } } @@ -317,32 +319,41 @@ func TestPasskeyLoginGates(t *testing.T) { // directly: the frozen clock makes that the only deterministic route to that branch. func TestPasskeyLoginFinishRejections(t *testing.T) { const finishPath = "/api/v1/auth/passkey/login/finish" - finish := func(eh http.Handler, email string) *httptest.ResponseRecorder { - return do(eh, "POST", finishPath, - `{"email":"`+email+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + finish := func(eh http.Handler, email, assertion string) *httptest.ResponseRecorder { + if assertion == "" { + assertion = loginAssertion("cred-1", "YXNzZXJ0") + } + return do(eh, "POST", finishPath, `{"email":"`+email+`","assertion":`+assertion+`}`, jsonHeader) } cases := []struct { - name string - email string - setup func(repo *fakeRepo, v *fakePasskeyVerifier) + name string + email string + assertion string // empty: cred-1 over the planted challenge + setup func(repo *fakeRepo, v *fakePasskeyVerifier) }{ - {"unknown email", "ghost@example.net", func(repo *fakeRepo, v *fakePasskeyVerifier) {}}, - {"known account, no live challenge", "player@example.net", func(repo *fakeRepo, v *fakePasskeyVerifier) {}}, - {"expired challenge", "player@example.net", func(repo *fakeRepo, v *fakePasskeyVerifier) { + {"unknown email", "ghost@example.net", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {}}, + {"known account, no live challenge", "player@example.net", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {}}, + {"expired challenge", "player@example.net", "", func(repo *fakeRepo, v *fakePasskeyVerifier) { plantLoginChallenge(repo, "ex", frozenNow.Add(-time.Second)) }}, - {"assertion fails verification", "player@example.net", func(repo *fakeRepo, v *fakePasskeyVerifier) { + {"assertion fails verification", "player@example.net", "", func(repo *fakeRepo, v *fakePasskeyVerifier) { plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL)) v.failErr = errors.New("bad assertion") }}, + {"assertion signs a challenge nobody issued", "player@example.net", loginAssertion("cred-1", "bm9ib2R5"), func(repo *fakeRepo, v *fakePasskeyVerifier) { + plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL)) + }}, + {"assertion without clientDataJSON", "player@example.net", `{"id":"cred-1","type":"public-key"}`, func(repo *fakeRepo, v *fakePasskeyVerifier) { + plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL)) + }}, } var envelopes [][2]string for _, c := range cases { api, repo, v := seedLoginPasskeyAPI(t) c.setup(repo, v) - w := finish(api.ExternalHandler(), c.email) + w := finish(api.ExternalHandler(), c.email, c.assertion) if w.Code != http.StatusBadRequest { t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String()) } @@ -368,43 +379,106 @@ func TestPasskeyLoginFinishRejections(t *testing.T) { } } -// TestPasskeyLoginBeginRateLimited closes the unauthenticated probing/DoS vector on the -// public door: one begin per recipient per window, keyed case-insensitively (a recased -// retype is the same mailbox), recovering after the window elapses. This is what makes the -// accepted has-passkey-vs-not status oracle impractical to farm. -func TestPasskeyLoginBeginRateLimited(t *testing.T) { - begin := func(eh http.Handler, email string) *httptest.ResponseRecorder { - return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader) +// TestPasskeyLoginCeremoniesCoexist is the case of someone who knows the address and +// begins logins for it while its owner signs in. Every begin stashes a ceremony of its +// own; finish verifies against the one whose challenge the browser signed, so the +// owner's earlier ceremony survives any number of later begins, and a stranger's +// assertion over a challenge nobody issued consumes nothing. +func TestPasskeyLoginCeremoniesCoexist(t *testing.T) { + api, repo, v := seedLoginPasskeyAPI(t) + eh := api.ExternalHandler() + begin := func(challenge, session string) { + t.Helper() + v.options = json.RawMessage(`{"publicKey":{"challenge":"` + challenge + `"}}`) + v.loginSession = []byte(session) + if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"player@example.net"}`, jsonHeader); w.Code != http.StatusOK { + t.Fatalf("begin %s: code = %d, want 200 (%s)", challenge, w.Code, w.Body.String()) + } + } + finish := func(challenge string) *httptest.ResponseRecorder { + return do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("player@example.net", challenge), jsonHeader) } - t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) { - api, _, _ := seedLoginPasskeyAPI(t) - clock := frozenNow - api.Now = func() time.Time { return clock } - eh := api.ExternalHandler() + begin("b3duZXI", "owner-session") // the owner's ceremony + begin("c3RyYW5nZXI", "later-begin") // someone else's begin right after + if len(repo.passkeyChallenges) != 2 { + t.Fatalf("live login challenges = %d, want 2 (a begin must not cancel another)", len(repo.passkeyChallenges)) + } - if w := begin(eh, "player@example.net"); w.Code != http.StatusOK { - t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) - } - if w := begin(eh, "player@example.net"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" { - t.Fatalf("immediate re-begin: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String()) - } - clock = clock.Add(otpResendCooldown + time.Second) - if w := begin(eh, "player@example.net"); w.Code != http.StatusOK { - t.Fatalf("post-cooldown begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) - } - }) + if w := finish("bm9ib2R5"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { + t.Fatalf("finish over an unissued challenge: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String()) + } + if w := finish("b3duZXI"); w.Code != http.StatusOK { + t.Fatalf("owner's finish after a later begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if string(v.lastSession) != "owner-session" { + t.Errorf("owner's finish verified against %q, want owner-session", v.lastSession) + } + if w := finish("c3RyYW5nZXI"); w.Code != http.StatusOK || string(v.lastSession) != "later-begin" { + t.Fatalf("later ceremony: code = %d session %q, want 200 later-begin", w.Code, v.lastSession) + } + if w := finish("b3duZXI"); w.Code != http.StatusBadRequest { + t.Fatalf("replay of the owner's challenge: code = %d, want 400", w.Code) + } +} - t.Run("throttle key is case-insensitive", func(t *testing.T) { - api, _, _ := seedLoginPasskeyAPI(t) - eh := api.ExternalHandler() - if w := begin(eh, "Player@Example.NET"); w.Code != http.StatusOK { - t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) +// TestPasskeyLoginBeginPerSourceCap bounds the challenge store by network: 32 live login +// challenges begun from one IPv6 /48 fill that network's allowance (429 +// too_many_challenges, nothing stashed), while a begin from another network still gets +// its ceremony. The begins come from 33 different /64s inside the /48, so the per-/64 +// auth-door bucket never trips and only the /48 bound can refuse the last one. +func TestPasskeyLoginBeginPerSourceCap(t *testing.T) { + api, repo, _ := seedLoginPasskeyAPI(t) + api.ClientIPHeader = "CF-Connecting-IP" + eh := api.ExternalHandler() + from := func(ip string) *httptest.ResponseRecorder { + return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"player@example.net"}`, + map[string]string{"Content-Type": "application/json", "CF-Connecting-IP": ip}) + } + for i := 1; i <= 32; i++ { + if w := from(fmt.Sprintf("2001:db8:7:%x::1", i)); w.Code != http.StatusOK { + t.Fatalf("begin %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String()) } - if w := begin(eh, "player@example.net"); w.Code != http.StatusTooManyRequests { - t.Fatalf("recased re-begin: code = %d, want 429 (key must be lowercased)", w.Code) + } + w := from("2001:db8:7:ff::1") + if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" { + t.Fatalf("33rd begin from the /48: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String()) + } + if len(repo.passkeyChallenges) != 32 { + t.Errorf("stashed = %d, want 32", len(repo.passkeyChallenges)) + } + if w := from("2001:db8:8::1"); w.Code != http.StatusOK { + t.Fatalf("begin from another /48: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if w := from("203.0.113.9"); w.Code != http.StatusOK { + t.Fatalf("begin from an IPv4 address: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } +} + +// TestPasskeyChallengeCanonicalForm pins that the stored and the signed challenge compare +// equal whatever padding each side used: go-webauthn accepts padded and unpadded +// base64url, and a browser's clientDataJSON may arrive either way. +func TestPasskeyChallengeCanonicalForm(t *testing.T) { + if got, err := optionsChallenge(json.RawMessage(`{"publicKey":{"challenge":"ZmFrZQ=="}}`)); err != nil || got != "ZmFrZQ" { + t.Errorf("optionsChallenge(padded) = %q, %v; want ZmFrZQ", got, err) + } + padded := base64.URLEncoding.EncodeToString([]byte(`{"challenge":"ZmFrZQ"}`)) // 22 bytes: ends in "==" + unpadded := base64.RawURLEncoding.EncodeToString([]byte(`{"challenge":"ZmFrZQ=="}`)) + for name, cd := range map[string]string{"padded clientDataJSON": padded, "padded challenge": unpadded} { + got, err := assertionChallenge(json.RawMessage(`{"response":{"clientDataJSON":"` + cd + `"}}`)) + if err != nil || got != "ZmFrZQ" { + t.Errorf("%s: assertionChallenge = %q, %v; want ZmFrZQ", name, got, err) } - }) + } + for name, a := range map[string]string{ + "no response": `{"id":"cred-1"}`, + "clientDataJSON junk": `{"response":{"clientDataJSON":"!!"}}`, + "empty challenge": `{"response":{"clientDataJSON":"` + base64.RawURLEncoding.EncodeToString([]byte(`{"challenge":""}`)) + `"}}`, + } { + if got, err := assertionChallenge(json.RawMessage(a)); err == nil { + t.Errorf("%s: assertionChallenge = %q, want an error", name, got) + } + } } // TestPasskeyLoginAllowsStaff pins the deliberate contrast with the email door: that door @@ -434,7 +508,7 @@ func TestPasskeyLoginAllowsStaff(t *testing.T) { t.Fatalf("begin for staff: code = %d, want 200 (%s)", w.Code, w.Body.String()) } w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", - `{"email":"boss@example.net","assertion":{"id":"cred-a1","type":"public-key"}}`, jsonHeader) + `{"email":"boss@example.net","assertion":`+loginAssertion("cred-a1", "YXNzZXJ0")+`}`, jsonHeader) if w.Code != http.StatusOK { t.Fatalf("finish for staff: code = %d, want 200 (passkey admits staff) (%s)", w.Code, w.Body.String()) } @@ -472,8 +546,7 @@ func TestPasskeyLoginFinishCloneRejected(t *testing.T) { plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL)) eh := api.ExternalHandler() - w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", - `{"email":"player@example.net","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("player@example.net", "YXNzZXJ0"), jsonHeader) if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { t.Fatalf("clone finish: code = %d body %s, want 400 passkey_login_invalid (opaque refusal)", w.Code, w.Body.String()) diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index c8c65ca..09bea45 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -823,6 +823,38 @@ func (p *PGRepo) CreateEmailOTP(ctx context.Context, id, userID, email, codeHash return tx.Commit() } +// AddLoginEmailOTP stores a code for a pre-session login door beside the codes +// already mailed for (user, purpose), keeping the newest otpLiveLoginCodes live: +// it drops every unconsumed code outside the newest otpLiveLoginCodes-1 unexpired +// ones (so expired codes go too), then inserts. It never cancels a code just because another start came in, so knowing +// an address is not enough to keep its owner from holding a working code +// (ConsumeLoginEmailOTP accepts any live one). +func (p *PGRepo) AddLoginEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + if _, err := tx.ExecContext(ctx, + `DELETE FROM email_otps + WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL + AND id NOT IN ( + SELECT id FROM email_otps + WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > $3 + ORDER BY created_at DESC, id DESC LIMIT $4)`, + userID, purpose, now, otpLiveLoginCodes-1); err != nil { + return fmt.Errorf("trim live login codes: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO email_otps (id, user_id, email, code_hash, purpose, expires_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + id, userID, email, codeHash, purpose, expiresAt); err != nil { + return fmt.Errorf("insert otp: %w", err) + } + return tx.Commit() +} + // VerifyEmailOTP redeems the newest live code for (user, purpose) in one // transaction (spec §B2). The row is taken FOR UPDATE so a concurrent verify of the // same code cannot double-spend it. The branch order is deliberate: expiry and the @@ -870,7 +902,7 @@ func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash s return "", ErrOTPLocked } if storedHash != codeHash { - return "", chargeOTPMismatch(ctx, tx, id, userID, purpose, now) + return "", chargeOTPMismatch(ctx, tx, userID, purpose, now) } // A DIFFERENT account may not also prove this address: the pre-session login @@ -1280,25 +1312,109 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp return sessionData, nil } +// maxLiveChallengesPerSource bounds the live login challenges one source (an IPv4 +// address or IPv6 /48, see challengeSource) holds in each login store. A ceremony +// takes seconds and a challenge lives passkeyChallengeTTL, so a network with a few +// dozen people signing in at once stays well inside it, while a flood of begins +// fills its own allowance and leaves the other networks their sign-ins. The count +// and the insert are not serialised, so a burst of truly concurrent begins can pass +// it together; the per-source token bucket in front of the door caps that burst. +const maxLiveChallengesPerSource = 32 + +// AddPasskeyLoginChallenge stores an email-first login ceremony beside the ones +// already live for (user, purpose); finish finds it by the challenge the browser +// signed (ConsumePasskeyLoginChallenge), so a begin by anyone who knows the address +// never cancels its owner's ceremony. It reaps the account's spent login rows, +// refuses with ErrTooManyPasskeyChallenges once source holds +// maxLiveChallengesPerSource live login challenges, then inserts. +func (p *PGRepo) AddPasskeyLoginChallenge(ctx context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + if _, err := tx.ExecContext(ctx, + `DELETE FROM webauthn_challenges + WHERE user_id = $1 AND purpose = $2 AND (expires_at <= $3 OR consumed_at IS NOT NULL)`, + userID, purpose, now); err != nil { + return fmt.Errorf("reap login challenges: %w", err) + } + var fromSource int + if err := tx.QueryRowContext(ctx, + `SELECT count(*) FROM webauthn_challenges + WHERE source = $1 AND consumed_at IS NULL AND expires_at > $2`, + source, now).Scan(&fromSource); err != nil { + return fmt.Errorf("count login challenges: %w", err) + } + if fromSource >= maxLiveChallengesPerSource { + return ErrTooManyPasskeyChallenges + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO webauthn_challenges (id, user_id, purpose, session_data, expires_at, challenge, source) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + id, userID, purpose, sessionData, expiresAt, challenge, source); err != nil { + return fmt.Errorf("insert login challenge: %w", err) + } + return tx.Commit() +} + +// ConsumePasskeyLoginChallenge redeems the live login challenge of (user, purpose) +// whose challenge is the one the browser signed, single-use: the row is taken FOR +// UPDATE, expiry is checked against now, consumed_at is stamped, and the stashed +// SessionData is returned. No such live row → ErrPasskeyChallengeInvalid. +func (p *PGRepo) ConsumePasskeyLoginChallenge(ctx context.Context, userID, purpose, challenge string, now time.Time) ([]byte, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + var ( + id string + sessionData []byte + expiresAt time.Time + ) + switch err := tx.QueryRowContext(ctx, + `SELECT id, session_data, expires_at FROM webauthn_challenges + WHERE user_id = $1 AND purpose = $2 AND challenge = $3 AND consumed_at IS NULL + FOR UPDATE`, + userID, purpose, challenge).Scan(&id, &sessionData, &expiresAt); { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrPasskeyChallengeInvalid + case err != nil: + return nil, err + } + if !expiresAt.After(now) { + return nil, ErrPasskeyChallengeInvalid + } + if _, err := tx.ExecContext(ctx, + `UPDATE webauthn_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil { + return nil, fmt.Errorf("consume login challenge: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, err + } + return sessionData, nil +} + // ---- discoverable ("usernameless") passkey login (task #40, migration 0013) ---- // maxLiveDiscoverableChallenges hard-bounds the non-user-keyed discoverable-login challenge -// store. webauthn_challenges self-bounds via a per-(user,purpose) supersede; a from-zero begin -// has no such key, so the table is capped: once this many LIVE (unexpired, unconsumed) rows -// exist, a new begin is refused (ErrTooManyDiscoverableChallenges → 429). The cap is generous — -// a login challenge lives only passkeyChallengeTTL (5 min) and each row is ~1 KB — so real -// concurrency never approaches it, while an abusive begin-flood is bounded to a few MB instead -// of growing without limit. One client's volume is bounded before it gets here, by the -// per-address token bucket in front of every public auth door (ratelimit.go). -const maxLiveDiscoverableChallenges = 4096 +// store: once this many LIVE (unexpired, unconsumed) rows exist, a new begin is refused +// (ErrTooManyPasskeyChallenges → 429). Each source is held to maxLiveChallengesPerSource +// first, so filling the store takes this many / 32 distinct networks; a row is a few hundred +// bytes, so the ceiling is a few MB. +const maxLiveDiscoverableChallenges = 16384 // CreateDiscoverableChallenge stashes a discoverable-login ceremony under an opaque handle, // bounding the table in one transaction (see the Repo interface for the full contract). It -// reaps expired/consumed rows first — the non-user-keyed analog of CreatePasskeyChallenge's -// supersede — then refuses over the cap rather than inserting. Because the reap ran first, the -// COUNT is exactly the live-row count, so the cap bounds an adversarial begin-flood (which a -// reap alone cannot: a burst inside the TTL leaves every fresh row live). -func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error { +// reaps expired/consumed rows first, then refuses when source already holds +// maxLiveChallengesPerSource live rows or the table holds maxLiveDiscoverableChallenges. +// Because the reap ran first, the counts are exactly the live rows, so the bounds hold under +// an adversarial begin-flood (which a reap alone cannot: a burst inside the TTL leaves every +// fresh row live). +func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error { tx, err := p.db.BeginTx(ctx, nil) if err != nil { return err @@ -1310,18 +1426,19 @@ func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id string, ses now); err != nil { return fmt.Errorf("reap discoverable challenges: %w", err) } - var live int + var live, fromSource int if err := tx.QueryRowContext(ctx, - `SELECT count(*) FROM webauthn_discoverable_challenges`).Scan(&live); err != nil { + `SELECT count(*), count(*) FILTER (WHERE source = $1) FROM webauthn_discoverable_challenges`, + source).Scan(&live, &fromSource); err != nil { return fmt.Errorf("count discoverable challenges: %w", err) } - if live >= maxLiveDiscoverableChallenges { - return ErrTooManyDiscoverableChallenges + if fromSource >= maxLiveChallengesPerSource || live >= maxLiveDiscoverableChallenges { + return ErrTooManyPasskeyChallenges } if _, err := tx.ExecContext(ctx, - `INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at) - VALUES ($1, $2, $3)`, - id, sessionData, expiresAt); err != nil { + `INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at, source) + VALUES ($1, $2, $3, $4)`, + id, sessionData, expiresAt, source); err != nil { return fmt.Errorf("insert discoverable challenge: %w", err) } return tx.Commit() @@ -2230,14 +2347,19 @@ func (p *PGRepo) UserByEmail(ctx context.Context, email string) (*StaffUser, err return &u, nil } -// ConsumeLoginEmailOTP redeems the live code for the PRE-SESSION email login door -// with the SAME lifecycle as VerifyEmailOTP (FOR UPDATE, expiry + attempt cap -// before the hash compare, a mismatch charges one attempt without consuming) but -// with NO identity side-effects: it neither writes users.email nor runs the -// verified-email uniqueness guard — login already resolved the userID via -// UserByEmail, which requires email_verified, so the address is settled. Errors -// are exactly ErrOTPInvalid / ErrOTPLocked (ErrEmailTaken is structurally -// impossible here). +// ConsumeLoginEmailOTP redeems a live code for the PRE-SESSION login doors (and +// the step-up doors, which keep one code live) in one transaction. Every live, +// unexpired code for (user, purpose) is taken FOR UPDATE, since a login door keeps +// several (AddLoginEmailOTP). The branch order matches VerifyEmailOTP: nothing live +// is ErrOTPInvalid; the account lock comes next; when every live code has used up +// its attempts the answer is ErrOTPLocked; a code matching none charges one attempt +// to each code still open and one failure to the account, and consumes nothing. A +// match consumes that code and every other live one for (user, purpose): the +// sign-in they were mailed for has happened. There are no identity side-effects: +// it neither writes users.email nor runs the verified-email uniqueness guard — +// login already resolved the userID via UserByEmail, which requires +// email_verified, so the address is settled. Errors are exactly ErrOTPInvalid / +// ErrOTPLocked / *OTPAccountLockedError. func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) error { tx, err := p.db.BeginTx(ctx, nil) if err != nil { @@ -2245,25 +2367,37 @@ func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, code } defer tx.Rollback() //nolint:errcheck // no-op after commit - var ( - id string - storedHash string - attempts int - expiresAt time.Time - ) - switch err := tx.QueryRowContext(ctx, - `SELECT id, code_hash, attempts, expires_at FROM email_otps - WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL - ORDER BY created_at DESC LIMIT 1 FOR UPDATE`, - userID, purpose).Scan(&id, &storedHash, &attempts, &expiresAt); { - case errors.Is(err, sql.ErrNoRows): - // Nothing live: never minted, already consumed, or superseded. - return ErrOTPInvalid - case err != nil: + rows, err := tx.QueryContext(ctx, + `SELECT code_hash, attempts FROM email_otps + WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > $3 + FOR UPDATE`, + userID, purpose, now) + if err != nil { return err } - - if !expiresAt.After(now) { + var live, open int + matched := false + for rows.Next() { + var ( + storedHash string + attempts int + ) + if err := rows.Scan(&storedHash, &attempts); err != nil { + rows.Close() + return err + } + live++ + if attempts < otpMaxAttempts { + open++ + matched = matched || storedHash == codeHash + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if live == 0 { + // Nothing live: never minted, already consumed, trimmed, or expired. return ErrOTPInvalid } if until, err := otpLockedUntil(ctx, tx, userID, purpose, now, true); err != nil { @@ -2271,15 +2405,17 @@ func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, code } else if !until.IsZero() { return &OTPAccountLockedError{Until: until} } - if attempts >= otpMaxAttempts { + if open == 0 { return ErrOTPLocked } - if storedHash != codeHash { - return chargeOTPMismatch(ctx, tx, id, userID, purpose, now) + if !matched { + return chargeOTPMismatch(ctx, tx, userID, purpose, now) } if _, err := tx.ExecContext(ctx, - `UPDATE email_otps SET consumed_at = $2 WHERE id = $1`, id, now); err != nil { + `UPDATE email_otps SET consumed_at = $3 + WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`, + userID, purpose, now); err != nil { return fmt.Errorf("consume otp: %w", err) } return tx.Commit() @@ -2325,12 +2461,17 @@ func otpLockEnd(windowStart time.Time, failures int, now time.Time) time.Time { return end } -// chargeOTPMismatch records one wrong guess against the code and the account -// budget, commits, and returns what the caller should answer: ErrOTPInvalid, or -// the lock this guess just tripped. A window that has ended starts over at 1. -func chargeOTPMismatch(ctx context.Context, tx *sql.Tx, codeID, userID, purpose string, now time.Time) error { +// chargeOTPMismatch records one wrong guess against every live code of (user, +// purpose) that still has attempts left and against the account budget, commits, +// and returns what the caller should answer: ErrOTPInvalid, or the lock this guess +// just tripped. The caller holds those codes FOR UPDATE, so the charged set is the +// set it compared. A window that has ended starts over at 1. +func chargeOTPMismatch(ctx context.Context, tx *sql.Tx, userID, purpose string, now time.Time) error { if _, err := tx.ExecContext(ctx, - `UPDATE email_otps SET attempts = attempts + 1 WHERE id = $1`, codeID); err != nil { + `UPDATE email_otps SET attempts = attempts + 1 + WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL + AND expires_at > $3 AND attempts < $4`, + userID, purpose, now, otpMaxAttempts); err != nil { return fmt.Errorf("record otp attempt: %w", err) } var ( diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 5675e13..6f0b601 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -264,3 +264,20 @@ func sourceKey(ip netip.Addr) string { return p.String() } } + +// challengeSource is the network a passkey login challenge is counted against +// (maxLiveChallengesPerSource): IPv4 per host, IPv6 per /48. A /48 is what one site +// is handed, so its holder cannot spread a flood of begins over the 65,536 /64s the +// auth-door bucket would see as separate callers. An unparseable address shares one +// key. +func challengeSource(ip netip.Addr) string { + switch { + case !ip.IsValid(): + return "unknown" + case ip.Is4(): + return ip.String() + default: + p, _ := ip.Prefix(48) + return p.String() + } +} diff --git a/internal/api/ratelimit_test.go b/internal/api/ratelimit_test.go index 82d4b67..7ebc3a5 100644 --- a/internal/api/ratelimit_test.go +++ b/internal/api/ratelimit_test.go @@ -127,6 +127,26 @@ func TestSourceKeyGroupsIPv6By64(t *testing.T) { } } +// TestChallengeSource pins how passkey login challenges are grouped by network: an +// IPv4 address stands alone, an IPv6 address counts toward its /48 (one site's +// allocation, so hopping /64s inside it stays one source). +func TestChallengeSource(t *testing.T) { + cases := []struct{ in, want string }{ + {"203.0.113.9", "203.0.113.9"}, + {"2001:db8:7:1::1", "2001:db8:7::/48"}, + {"2001:db8:7:ffff:1:2:3:4", "2001:db8:7::/48"}, + {"2001:db8:8::1", "2001:db8:8::/48"}, + } + for _, c := range cases { + if got := challengeSource(netip.MustParseAddr(c.in)); got != c.want { + t.Errorf("challengeSource(%s) = %q, want %q", c.in, got, c.want) + } + } + if got := challengeSource(netip.Addr{}); got != "unknown" { + t.Errorf("challengeSource(invalid) = %q, want unknown", got) + } +} + func TestAuthDoorsThrottlePerClientAddress(t *testing.T) { api, _, _ := seedLoginEmailAPI(t) api.AuthDoorLimit = RateLimit{Burst: 3, PerMinute: 3} diff --git a/internal/api/repo.go b/internal/api/repo.go index 0cb3ade..5acfc20 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -375,6 +375,13 @@ type Repo interface { // outstanding code per purpose — a re-request invalidates the earlier mail. // expiresAt is the API clock + TTL so expiry is driven by one authoritative clock. CreateEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, expiresAt time.Time) error + // AddLoginEmailOTP persists a code for a PRE-SESSION login door (email login, + // op-login) beside the codes already mailed for (userID, purpose): it keeps the + // newest otpLiveLoginCodes live, dropping live codes that expired at now and the + // oldest beyond the allowance. Unlike CreateEmailOTP it never cancels a code + // because another start came in — anyone who knows an address can start a login + // for it, and ConsumeLoginEmailOTP accepts any live code. + AddLoginEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error // VerifyEmailOTP redeems the newest live code for (userID, purpose) against // codeHash, atomically (spec §B2). No live code, an expired one, or a consumed // one → ErrOTPInvalid; an exhausted attempt budget → ErrOTPLocked; a spent @@ -398,10 +405,13 @@ type Repo interface { // address is stored unverified for a later Settings/SMTP flow to verify. An unknown // userID returns ErrNotFound. SetUserEmail(ctx context.Context, userID, email string) error - // ConsumeLoginEmailOTP redeems the newest live code for (userID, purpose) against - // codeHash for the PRE-SESSION email LOGIN door, with the SAME code lifecycle as - // VerifyEmailOTP (FOR UPDATE, expiry+lockout before hash compare, mismatch charges - // one attempt without consuming) but with NO identity side-effects: it neither + // ConsumeLoginEmailOTP redeems a code for (userID, purpose) against codeHash for + // the PRE-SESSION login doors and the step-up doors. Every live (unconsumed, + // unexpired at now) code is a candidate, since a login door keeps several. The + // lifecycle matches VerifyEmailOTP (FOR UPDATE, lockout before hash compare; all + // live codes out of attempts → ErrOTPLocked; a mismatch charges one attempt to each + // code still open plus one account failure, consuming nothing), and a match spends + // every live code of (userID, purpose). It has NO identity side-effects: it neither // writes users.email nor runs the verified-email uniqueness guard. Login resolved // userID via UserByEmail, which already requires email_verified, so the address is // settled — re-proving control of a code this session must not re-touch the row. @@ -464,19 +474,32 @@ type Repo interface { // returns the stashed SessionData so finish can validate the attestation against // it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish // for the same ceremony finds nothing live and fails. now is the API clock so - // expiry is testable. Bound to user_id — enrollment and username-first login both - // know the principal at begin; the usernameless from-zero door instead uses the - // non-user-keyed pair below. + // expiry is testable. Bound to user_id — enrollment and step-up know the principal + // at begin and only its holder can begin one; the public login doors use the + // challenge-matched and non-user-keyed pairs below. ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error) + // AddPasskeyLoginChallenge persists an email-first passkey LOGIN ceremony for + // (userID, purpose) beside the ones already live, recording challenge (the + // canonical base64url challenge handed to the browser) and source (the caller's + // network, see challengeSource). Anyone who knows an address can begin a login + // for it, so a begin never cancels another; it reaps the account's expired or + // consumed login rows and refuses with ErrTooManyPasskeyChallenges once source + // holds maxLiveChallengesPerSource live login challenges. + AddPasskeyLoginChallenge(ctx context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error + // ConsumePasskeyLoginChallenge redeems the live login challenge of (userID, + // purpose) whose challenge is the one the browser signed, atomically and + // single-use, returning its SessionData. No such live row → + // ErrPasskeyChallengeInvalid. + ConsumePasskeyLoginChallenge(ctx context.Context, userID, purpose, challenge string, now time.Time) (sessionData []byte, err error) // CreateDiscoverableChallenge persists a DISCOVERABLE ("usernameless") login ceremony // (task #40, migration 0013), keyed by an opaque server-minted handle id — NOT a user, - // since a from-zero begin has no principal. In one transaction it reaps expired/consumed - // rows (the non-user-keyed analog of CreatePasskeyChallenge's supersede) and then, if the - // live count is at the hard cap, refuses with ErrTooManyDiscoverableChallenges rather than - // inserting — the cap, not the reap, bounds an adversarial begin-flood, since a burst - // inside the TTL leaves every fresh row live. now and expiresAt are both the API clock - // (now drives the reap; expiresAt = now + TTL drives liveness). - CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error + // since a from-zero begin has no principal — and tagged with source (the caller's + // network, see challengeSource). In one transaction it reaps expired/consumed rows and + // then refuses with ErrTooManyPasskeyChallenges, rather than inserting, when source + // already holds maxLiveChallengesPerSource live rows or the table holds + // maxLiveDiscoverableChallenges. now and expiresAt are both the API clock (now drives + // the reap; expiresAt = now + TTL drives liveness). + CreateDiscoverableChallenge(ctx context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error // ConsumeDiscoverableChallenge redeems the discoverable challenge under handle id, // atomically and single-use (mirrors ConsumePasskeyChallengeByUser without the user key): // it takes the row FOR UPDATE, checks expiry against now, stamps consumed_at, and returns diff --git a/internal/pgint/challenges_test.go b/internal/pgint/challenges_test.go new file mode 100644 index 0000000..4650359 --- /dev/null +++ b/internal/pgint/challenges_test.go @@ -0,0 +1,287 @@ +//go:build pgint + +package pgint + +import ( + "context" + "errors" + "testing" + "time" + + "felis.lolicon.best/internal/api" +) + +// ---- login codes and ceremonies that coexist (migration 0029) -------------------- + +func liveLoginCodes(t *testing.T, userID, purpose string, now time.Time) int { + t.Helper() + var n int + if err := db.QueryRow(`SELECT count(*) FROM email_otps + WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > $3`, + userID, purpose, now).Scan(&n); err != nil { + t.Fatalf("count live codes: %v", err) + } + return n +} + +// A login start keeps the three newest live codes: a fourth drops the oldest, and +// redeeming any live one spends its siblings too. +func TestAddLoginEmailOTPKeepsNewestThree(t *testing.T) { + ctx := context.Background() + u := newUser(t, "user", "otp-keep") + purpose := "login_email" + addr := "keep-" + suffix(t) + "@example.net" + t0 := mustNow().Truncate(time.Second) + add := func(hash string, at time.Time) { + t.Helper() + if err := repo.AddLoginEmailOTP(ctx, "keep-"+suffix(t), u.ID, addr, hash, purpose, at, at.Add(10*time.Minute)); err != nil { + t.Fatalf("AddLoginEmailOTP(%s): %v", hash, err) + } + } + add("h1", t0) + add("h2", t0.Add(time.Minute)) + add("h3", t0.Add(2*time.Minute)) + if n := liveLoginCodes(t, u.ID, purpose, t0.Add(2*time.Minute)); n != 3 { + t.Fatalf("live codes after three starts = %d, want 3", n) + } + add("h4", t0.Add(3*time.Minute)) + at := t0.Add(3 * time.Minute) + if n := liveLoginCodes(t, u.ID, purpose, at); n != 3 { + t.Fatalf("live codes after four starts = %d, want 3", n) + } + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h1", at); !errors.Is(err, api.ErrOTPInvalid) { + t.Fatalf("oldest code after a fourth start = %v, want ErrOTPInvalid", err) + } + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h2", at); err != nil { + t.Fatalf("second code = %v, want nil", err) + } + for _, h := range []string{"h3", "h4"} { + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, h, at); !errors.Is(err, api.ErrOTPInvalid) { + t.Fatalf("sibling %s after a sign-in = %v, want ErrOTPInvalid", h, err) + } + } + + // Expired codes leave the allowance: a start after they lapse is the only live one. + add("h5", t0.Add(4*time.Minute)) + add("h6", t0.Add(5*time.Minute)) + later := t0.Add(20 * time.Minute) + add("h7", later) + if n := liveLoginCodes(t, u.ID, purpose, later); n != 1 { + t.Fatalf("live codes after the others expired = %d, want 1", n) + } + var unconsumed int + if err := db.QueryRow(`SELECT count(*) FROM email_otps WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`, + u.ID, purpose).Scan(&unconsumed); err != nil { + t.Fatalf("count unconsumed: %v", err) + } + if unconsumed != 1 { + t.Fatalf("unconsumed rows = %d, want 1 (expired codes are deleted, not kept)", unconsumed) + } +} + +// A wrong guess with several live codes costs each open code one attempt and the +// account one failure; when every live code is spent the door answers ErrOTPLocked, +// and a newer code still signs in. +func TestConsumeLoginEmailOTPAcrossLiveCodes(t *testing.T) { + ctx := context.Background() + u := newUser(t, "user", "otp-multi") + purpose := "op_login" + addr := "multi-" + suffix(t) + "@example.net" + t0 := mustNow().Truncate(time.Second) + add := func(hash string, at time.Time) { + t.Helper() + if err := repo.AddLoginEmailOTP(ctx, "multi-"+suffix(t), u.ID, addr, hash, purpose, at, at.Add(10*time.Minute)); err != nil { + t.Fatalf("AddLoginEmailOTP(%s): %v", hash, err) + } + } + add("h-a", t0) + add("h-b", t0.Add(time.Minute)) + at := t0.Add(time.Minute) + attempts := func() map[string]int { + t.Helper() + rows, err := db.Query(`SELECT code_hash, attempts FROM email_otps WHERE user_id = $1 AND purpose = $2`, u.ID, purpose) + if err != nil { + t.Fatalf("read attempts: %v", err) + } + defer rows.Close() + got := map[string]int{} + for rows.Next() { + var h string + var n int + if err := rows.Scan(&h, &n); err != nil { + t.Fatalf("scan: %v", err) + } + got[h] = n + } + return got + } + + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-wrong", at); !errors.Is(err, api.ErrOTPInvalid) { + t.Fatalf("wrong guess = %v, want ErrOTPInvalid", err) + } + if got := attempts(); got["h-a"] != 1 || got["h-b"] != 1 { + t.Fatalf("attempts after one wrong guess = %v, want h-a:1 h-b:1", got) + } + for i := 2; i <= 5; i++ { + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-wrong", at); !errors.Is(err, api.ErrOTPInvalid) { + t.Fatalf("wrong guess %d = %v, want ErrOTPInvalid", i, err) + } + } + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-a", at); !errors.Is(err, api.ErrOTPLocked) { + t.Fatalf("right code once every live code is spent = %v, want ErrOTPLocked", err) + } + var failures int + if err := db.QueryRow(`SELECT failures FROM otp_failure_windows WHERE user_id = $1 AND purpose = $2`, + u.ID, purpose).Scan(&failures); err != nil { + t.Fatalf("read budget row: %v", err) + } + if failures != 5 { + t.Fatalf("account failures = %d, want 5 (one per wrong guess, not one per code)", failures) + } + + add("h-c", t0.Add(2*time.Minute)) + at = t0.Add(2 * time.Minute) + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-b", at); !errors.Is(err, api.ErrOTPInvalid) { + t.Fatalf("spent code beside a fresh one = %v, want ErrOTPInvalid", err) + } + if got := attempts(); got["h-c"] != 1 || got["h-a"] != 5 || got["h-b"] != 5 { + t.Fatalf("attempts after a guess of a spent code = %v, want h-c:1 and the spent codes left at 5", got) + } + if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-c", at); err != nil { + t.Fatalf("fresh code = %v, want nil", err) + } + if n := liveLoginCodes(t, u.ID, purpose, at); n != 0 { + t.Fatalf("live codes after a sign-in = %d, want 0", n) + } +} + +// Email-first passkey ceremonies of one account coexist and are redeemed by the +// challenge the browser signed; one network holds at most 32 live ones. +func TestPasskeyLoginChallengesCoexist(t *testing.T) { + ctx := context.Background() + u := newUser(t, "user", "pk-login") + purpose := "passkey_login" + source := "203.0.113." + suffix(t) + now := mustNow() + add := func(id, source, challenge, session string, expiresAt time.Time) error { + return repo.AddPasskeyLoginChallenge(ctx, id+"-"+suffix(t), u.ID, purpose, source, challenge, []byte(session), now, expiresAt) + } + if err := add("c1", source, "Y2hhbGxlbmdlMQ", "s1", now.Add(5*time.Minute)); err != nil { + t.Fatalf("add c1: %v", err) + } + if err := add("c2", source, "Y2hhbGxlbmdlMg", "s2", now.Add(5*time.Minute)); err != nil { + t.Fatalf("add c2: %v", err) + } + if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "bm9ib2R5", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) { + t.Fatalf("unissued challenge = %v, want ErrPasskeyChallengeInvalid", err) + } + if sd, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMQ", now); err != nil || string(sd) != "s1" { + t.Fatalf("earlier ceremony = %q, %v; want s1 (a later begin must not cancel it)", sd, err) + } + if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMQ", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) { + t.Fatalf("replay = %v, want ErrPasskeyChallengeInvalid", err) + } + if sd, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMg", now); err != nil || string(sd) != "s2" { + t.Fatalf("later ceremony = %q, %v; want s2", sd, err) + } + // The same challenge under another purpose is a different door. + if err := add("c3", source, "Y2hhbGxlbmdlMw", "s3", now.Add(5*time.Minute)); err != nil { + t.Fatalf("add c3: %v", err) + } + if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, "passkey_register", "Y2hhbGxlbmdlMw", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) { + t.Fatalf("other purpose = %v, want ErrPasskeyChallengeInvalid", err) + } + if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMw", now.Add(6*time.Minute)); !errors.Is(err, api.ErrPasskeyChallengeInvalid) { + t.Fatalf("expired ceremony = %v, want ErrPasskeyChallengeInvalid", err) + } + + // Other accounts' spent rows from the same source hold no slot: one expired, one + // redeemed. Two accounts, since an account's own begin reaps its spent rows. + other := newUser(t, "user", "pk-login-other") + if err := repo.AddPasskeyLoginChallenge(ctx, "ox-"+suffix(t), other.ID, purpose, source, "ZXhwaXJlZA", []byte("s"), now, now.Add(-time.Second)); err != nil { + t.Fatalf("other account's expired begin: %v", err) + } + third := newUser(t, "user", "pk-login-third") + if err := repo.AddPasskeyLoginChallenge(ctx, "oc-"+suffix(t), third.ID, purpose, source, "cmVkZWVtZWQ", []byte("s"), now, now.Add(5*time.Minute)); err != nil { + t.Fatalf("third account's begin: %v", err) + } + if _, err := repo.ConsumePasskeyLoginChallenge(ctx, third.ID, purpose, "cmVkZWVtZWQ", now); err != nil { + t.Fatalf("third account's finish: %v", err) + } + + // Per-source bound: c3 is still live at now, so 31 more fill the allowance. + for i := 0; i < 31; i++ { + if err := add("cap", source, "Y2Fw", "s", now.Add(5*time.Minute)); err != nil { + t.Fatalf("add %d from the source: %v", i+2, err) + } + } + if err := add("over", source, "b3Zlcg", "s", now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) { + t.Fatalf("33rd live challenge from one source = %v, want ErrTooManyPasskeyChallenges", err) + } + if err := repo.AddPasskeyLoginChallenge(ctx, "x-"+suffix(t), other.ID, purpose, source, "eA", []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) { + t.Fatalf("another account's begin from the full source = %v, want ErrTooManyPasskeyChallenges", err) + } + if err := add("elsewhere", "198.51.100."+suffix(t), "ZWxzZXdoZXJl", "s", now.Add(5*time.Minute)); err != nil { + t.Fatalf("begin from another source: %v", err) + } + // A redeemed ceremony frees its slot. + if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "b3Zlcg", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) { + t.Fatalf("the refused begin left a row: %v", err) + } + var capID string + if err := db.QueryRow(`SELECT challenge FROM webauthn_challenges WHERE user_id = $1 AND source = $2 AND consumed_at IS NULL LIMIT 1`, + u.ID, source).Scan(&capID); err != nil { + t.Fatalf("pick a live challenge: %v", err) + } + if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, capID, now); err != nil { + t.Fatalf("redeem one from the full source: %v", err) + } + if err := add("after", source, "YWZ0ZXI", "s", now.Add(5*time.Minute)); err != nil { + t.Fatalf("begin after one was redeemed = %v, want nil", err) + } +} + +// The usernameless store holds each source to 32 live challenges and the whole +// table to 16384. +func TestDiscoverableChallengeBounds(t *testing.T) { + ctx := context.Background() + now := mustNow() + source := "2001:db8:" + suffix(t)[:4] + "::/48" + t.Cleanup(func() { + db.Exec(`DELETE FROM webauthn_discoverable_challenges WHERE source LIKE 'pgint-bulk-%' OR source = $1`, source) //nolint:errcheck + }) + var ids []string + for i := 0; i < 32; i++ { + id := "disc-" + suffix(t) + if err := repo.CreateDiscoverableChallenge(ctx, id, source, []byte("s"), now, now.Add(5*time.Minute)); err != nil { + t.Fatalf("create %d: %v", i+1, err) + } + ids = append(ids, id) + } + if err := repo.CreateDiscoverableChallenge(ctx, "disc-over-"+suffix(t), source, []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) { + t.Fatalf("33rd from one source = %v, want ErrTooManyPasskeyChallenges", err) + } + if err := repo.CreateDiscoverableChallenge(ctx, "disc-else-"+suffix(t), "pgint-bulk-else", []byte("s"), now, now.Add(5*time.Minute)); err != nil { + t.Fatalf("another source: %v", err) + } + if _, err := repo.ConsumeDiscoverableChallenge(ctx, ids[0], now); err != nil { + t.Fatalf("consume: %v", err) + } + if err := repo.CreateDiscoverableChallenge(ctx, "disc-after-"+suffix(t), source, []byte("s"), now, now.Add(5*time.Minute)); err != nil { + t.Fatalf("after a redeem freed a slot = %v, want nil", err) + } + + // Fill the table to its store-wide bound from many sources, none of them full. + var live int + if err := db.QueryRow(`SELECT count(*) FROM webauthn_discoverable_challenges WHERE consumed_at IS NULL AND expires_at > $1`, now).Scan(&live); err != nil { + t.Fatalf("count: %v", err) + } + if _, err := db.Exec(`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at, source) + SELECT 'bulk-' || $1 || '-' || i, '\x00'::bytea, $2, 'pgint-bulk-' || (i % 1000) + FROM generate_series(1, $3) AS i`, suffix(t), now.Add(5*time.Minute), 16384-live); err != nil { + t.Fatalf("bulk insert: %v", err) + } + if err := repo.CreateDiscoverableChallenge(ctx, "disc-full-"+suffix(t), "pgint-bulk-fresh", []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) { + t.Fatalf("begin with the table full = %v, want ErrTooManyPasskeyChallenges", err) + } +} diff --git a/internal/store/migrations/0029_challenge_source.sql b/internal/store/migrations/0029_challenge_source.sql new file mode 100644 index 0000000..a683b3a --- /dev/null +++ b/internal/store/migrations/0029_challenge_source.sql @@ -0,0 +1,20 @@ +-- Passkey login challenges stop being one-per-account and are bounded per source. +-- +-- An email-first passkey login used to keep one challenge per account: every begin +-- deleted the previous one, so anyone who knew an address could cancel its owner's +-- ceremony by starting another. A login challenge now lives beside the others and +-- finish picks the one whose challenge the browser signed (clientDataJSON carries +-- it), so a stranger's begin never touches the owner's. +-- +-- Both login stores are then bounded by where the begins come from: source is the +-- caller's IPv4 address or IPv6 /48, and a source holds only so many live login +-- challenges at once (maxLiveChallengesPerSource). One network flooding begins +-- fills its own allowance and leaves every other network able to sign in. +ALTER TABLE webauthn_challenges ADD COLUMN challenge text; +ALTER TABLE webauthn_challenges ADD COLUMN source text; +ALTER TABLE webauthn_discoverable_challenges ADD COLUMN source text; + +CREATE INDEX webauthn_challenges_source_idx + ON webauthn_challenges (source) WHERE source IS NOT NULL; +CREATE INDEX webauthn_discoverable_challenges_source_idx + ON webauthn_discoverable_challenges (source); diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json index 3764e12..eb38c8a 100644 --- a/panel/src/i18n/resources/en-US/auth.json +++ b/panel/src/i18n/resources/en-US/auth.json @@ -13,7 +13,7 @@ "otp_placeholder": "Enter 6-digit code", "send_otp": "Send Code", "sending_otp": "Sending…", - "otp_sent": "Verification code sent to your email.", + "otp_sent": "Verification code sent to your email. If you received several, any code from the last few minutes works.", "otp_btn": "Verify & Sign In", "resend_in": "s", "passkey_btn": "Sign in with Passkey", @@ -25,7 +25,7 @@ "binding": "Verifying…", "op_hint": "Staff only: a code is emailed to you, and an online operator must approve the request in-game before you can sign in.", "op_start_btn": "Request operator sign-in", - "op_approve_hint": "A code has been emailed to you. Ask an online operator to approve this request in-game:", + "op_approve_hint": "A code has been emailed to you (if you received several, any recent one works). Ask an online operator to approve this request in-game:", "op_waiting": "Waiting for in-game approval…", "op_approved": "Approved — enter the code from your email.", "op_restart": "Start over", diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json index 8d7361a..eb7f038 100644 --- a/panel/src/i18n/resources/zh-CN/auth.json +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -13,7 +13,7 @@ "otp_placeholder": "请输入 6 位验证码", "send_otp": "发送验证码", "sending_otp": "发送中…", - "otp_sent": "验证码已发送至您的邮箱,请查收", + "otp_sent": "验证码已发送至您的邮箱,请查收。收到多封时,最近几分钟内的任一验证码都可用。", "otp_btn": "验证并登录", "resend_in": "秒后重试", "passkey_btn": "使用 Passkey 登录", @@ -25,7 +25,7 @@ "binding": "验证中…", "op_hint": "仅限管理员:验证码将发送至您的邮箱,且需要一位在线管理员在游戏内批准此次登录。", "op_start_btn": "发起管理员登录", - "op_approve_hint": "验证码已发送至您的邮箱。请让一位在线管理员在游戏内批准此次请求:", + "op_approve_hint": "验证码已发送至您的邮箱(收到多封时,最近的任一封都可用)。请让一位在线管理员在游戏内批准此次请求:", "op_waiting": "等待游戏内批准…", "op_approved": "已批准——请输入邮件中的验证码。", "op_restart": "重新开始", diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 58c3a22..b0f090f 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -93,7 +93,7 @@ export interface paths { put?: never; /** * Create a server (admin). - * @description Requires the admin Access path; the image must be whitelisted. An image in the platform registry is stored pinned to the digest its tag names at creation (name:tag@sha256:…), so a later push over the tag never moves the server; 400 image_not_in_registry when the registry lacks the tag, 503 registry_unavailable when it cannot be asked. + * @description Requires a staff session on the operator console host; the image must be whitelisted. An image in the platform registry is stored pinned to the digest its tag names at creation (name:tag@sha256:…), so a later push over the tag never moves the server; 400 image_not_in_registry when the registry lacks the tag, 503 registry_unavailable when it cannot be asked. */ post: operations["createServer"]; delete?: never; @@ -677,7 +677,7 @@ export interface paths { put?: never; /** * Begin a passwordless passkey (WebAuthn) login (spec §14, §B). - * @description First leg of the public, pre-session passkey assertion door: the caller supplies the email that selects the account and, on success, receives the raw PublicKeyCredentialRequestOptions to hand to navigator.credentials.get(). The matching challenge is stashed server-side and redeemed by finish. Mounted Public (no prior principal) and gated on local_auth_enabled. An unknown address and a known account with no enrolled passkey both return the SAME 400 no_passkey, so the door is not an existence oracle; a per-recipient cooldown (shared shape with the email-OTP and op-login doors) throttles probing. + * @description First leg of the public, pre-session passkey assertion door: the caller supplies the email that selects the account and, on success, receives the raw PublicKeyCredentialRequestOptions to hand to navigator.credentials.get(). The matching challenge is stashed server-side and redeemed by finish. Mounted Public (no prior principal) and gated on local_auth_enabled. An unknown address and a known account with no enrolled passkey both return the SAME 400 no_passkey, so the door is not an existence oracle; the per-address sign-in rate limit bounds probing. Each begin stashes a ceremony of its own beside the account's other live ones, so a begin by anyone who knows the address never cancels its owner's. One network (an IPv4 address or IPv6 /48) holds at most 32 live login challenges (429 too_many_challenges past that). */ post: operations["passkeyLoginBegin"]; delete?: never; @@ -697,7 +697,7 @@ export interface paths { put?: never; /** * Complete a passkey (WebAuthn) login and mint a session (spec §14, §B). - * @description Second leg of the public passkey door: the caller returns the email (to re-select the account) and the raw navigator.credentials.get() assertion. The stashed login challenge is consumed atomically and the assertion is verified against it; on success a host-only felis_session cookie is minted. Both players and staff may log in this way — a passkey is a two-factor authenticator (possession + user verification), strong enough to stand alone without the in-game approval op-login requires. Every failure mode (unknown address, no live challenge, expired challenge, bad assertion) collapses into one uniform passkey_login_invalid, so the door reveals nothing. + * @description Second leg of the public passkey door: the caller returns the email (to re-select the account) and the raw navigator.credentials.get() assertion. The live login challenge whose value the assertion signed (response.clientDataJSON) is consumed atomically and the assertion is verified against it; on success a host-only felis_session cookie is minted. Both players and staff may log in this way — a passkey is a two-factor authenticator (possession + user verification), strong enough to stand alone without the in-game approval op-login requires. Every failure mode (unknown address, no live challenge for the signed value, expired challenge, bad assertion) collapses into one uniform passkey_login_invalid, so the door reveals nothing. */ post: operations["passkeyLoginFinish"]; delete?: never; @@ -717,7 +717,7 @@ export interface paths { put?: never; /** * Begin a usernameless (discoverable) passkey login (spec §14, §B, task - * @description First leg of the truly from-zero passkey door: unlike the email-first sibling above, the caller supplies NO identifier — the request has no body (only the application/json Content-Type is required as the cross-origin CSRF guard). The response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY allowCredentials, plus an opaque login_id: the authenticator picks a resident credential it holds for this RP and the account is revealed only by the userHandle inside the signed assertion at finish. The challenge cannot be user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed back at finish. Mounted Public and gated on local_auth_enabled. There is no recipient or principal to key a per-caller cooldown on, so one client is bounded by the per-address sign-in rate limit (429 rate_limited) and the table by a hard global cap on live challenges (429 too_many_challenges). Inert for a credential until its owner enrolls a resident passkey; email-OTP and username-first passkey remain the fallbacks, so no authenticator is ever locked out. + * @description First leg of the truly from-zero passkey door: unlike the email-first sibling above, the caller supplies NO identifier — the request has no body (only the application/json Content-Type is required as the cross-origin CSRF guard). The response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY allowCredentials, plus an opaque login_id: the authenticator picks a resident credential it holds for this RP and the account is revealed only by the userHandle inside the signed assertion at finish. The challenge cannot be user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed back at finish. Mounted Public and gated on local_auth_enabled. One client is bounded by the per-address sign-in rate limit (429 rate_limited), one network (an IPv4 address or IPv6 /48) to 32 live challenges, and the table by a hard global cap of 16384 (both 429 too_many_challenges). Inert for a credential until its owner enrolls a resident passkey; email-OTP and username-first passkey remain the fallbacks, so no authenticator is ever locked out. */ post: operations["passkeyLoginDiscoverableBegin"]; delete?: never; @@ -757,7 +757,7 @@ export interface paths { put?: never; /** * Begin a passwordless email-OTP login — mail a one-time code (spec §B). - * @description Public, pre-session console door: the caller supplies an email and, if it resolves to a verified account, a one-time code is mailed under the login purpose. An address with no account returns the SAME 202 with no code minted, and the per-recipient cooldown is kept on that path too, so probing reveals nothing (existence is learnt only at the sanctioned /auth/options oracle). An account that spent its daily wrong-code budget (10 per 24h, across every code) also gets the same 202 and no mail until the window ends. Gated on local_auth_enabled. + * @description Public, pre-session console door: the caller supplies an email and, if it resolves to a verified account, a one-time code is mailed under the login purpose. An address with no account returns the SAME 202 with no code minted, and the per-recipient cooldown is kept on that path too, so probing reveals nothing (existence is learnt only at the sanctioned /auth/options oracle). One code is mailed per recipient per minute: a start inside that window gets the same 202 (expires_at of the live code) and mails nothing. A start never cancels the codes already mailed; the three newest live codes all work, and signing in with one spends the rest. An account that spent its daily wrong-code budget (10 per 24h, across every code) also gets the same 202 and no mail until the window ends. Gated on local_auth_enabled. */ post: operations["loginEmailStart"]; delete?: never; @@ -797,7 +797,7 @@ export interface paths { put?: never; /** * Begin an op.console staff login — mail an OTP, open an approval request (spec §B). - * @description Public, pre-session first leg of the two-factor operator door: resolves the staff address, opens an op_login request, and mails a one-time code under the op_login purpose, returning the request handle the browser polls. A non-staff or unknown address gets the SAME 202 with a random, non-persisted handle and no mail, so this never becomes a staff-enumeration oracle. A staff account that spent its daily wrong-code budget gets the same neutral 202. Gated on local_auth_enabled. + * @description Public, pre-session first leg of the two-factor operator door: resolves the staff address, opens an op_login request, and mails a one-time code under the op_login purpose, returning the request handle the browser polls. A non-staff or unknown address gets the SAME 202 with a random, non-persisted handle and no mail, so this never becomes a staff-enumeration oracle. A staff account that spent its daily wrong-code budget gets the same neutral 202. One code is mailed per recipient per minute: a staff start inside that window opens a real request but mails nothing, and the code already in the inbox finishes it. A start never cancels the codes already mailed (the three newest live codes all work). Gated on local_auth_enabled. */ post: operations["opLoginStart"]; delete?: never; @@ -935,7 +935,7 @@ export interface paths { }; /** * The caller's own identity and tier (drives panel navigation). - * @description Returns the authenticated principal's user id, email, role and the server-computed is_admin (Principal.IsAdmin(): role admin reached via the admin Access path). The panel reads this once at boot to decide which surfaces to render. It is UX truth, not a security control — admin routes are independently gated server-side, so a hidden nav item never widens access. + * @description Returns the authenticated principal's user id, email, role and the server-computed is_admin (Principal.IsAdmin(): role admin reached on the operator console host). The panel reads this once at boot to decide which surfaces to render. It is UX truth, not a security control — admin routes are independently gated server-side, so a hidden nav item never widens access. */ get: operations["me"]; put?: never; @@ -1598,7 +1598,7 @@ export interface paths { }; /** * List the caller's own live sessions, marking the one this request came in on. - * @description Every device signed in to the caller's account, most recently seen first. A caller signed in through Cloudflare Access has no session of its own, so no entry is marked current. + * @description Every device signed in to the caller's account, most recently seen first, with the one this request came in on marked current. */ get: operations["listMySessions"]; put?: never; @@ -3959,7 +3959,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description A passkey login for this recipient was started too recently (otp_resend_cooldown); or this client address called the sign-in doors too often (rate_limited, with Retry-After). */ + /** @description This network already holds 32 live passkey login challenges (too_many_challenges); or this client address called the sign-in doors too often (rate_limited, with Retry-After). */ 429: { headers: { [name: string]: unknown; @@ -4106,7 +4106,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Too many discoverable logins are in flight server-wide (too_many_challenges; the cap is global, so no per-recipient signal leaks); or this client address called the sign-in doors too often (rate_limited, with Retry-After). */ + /** @description This network already holds 32 live discoverable challenges, or the store is at its global cap (too_many_challenges); or this client address called the sign-in doors too often (rate_limited, with Retry-After). */ 429: { headers: { [name: string]: unknown; @@ -4255,7 +4255,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description A code for this recipient was requested too recently (otp_resend_cooldown); or this client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */ + /** @description This client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */ 429: { headers: { [name: string]: unknown; @@ -4383,7 +4383,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description A code for this recipient was requested too recently (otp_resend_cooldown); or this client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */ + /** @description This client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */ 429: { headers: { [name: string]: unknown; @@ -4691,9 +4691,9 @@ export interface operations { * @enum {string} */ role: "user" | "admin" | "owner"; - /** @description True only when role is admin or owner AND the request arrived via the admin Access path (Principal.IsAdmin()). */ + /** @description True only when role is admin or owner AND the request arrived on the operator console host (Principal.IsAdmin()). */ is_admin: boolean; - /** @description True only for the Owner principal on the admin Access path (Principal.IsOwner()); gates owner-only panel surfaces. */ + /** @description True only for the Owner principal on the operator console host (Principal.IsOwner()); gates owner-only panel surfaces. */ is_owner: boolean; /** @description Whether the account's email has been verified; the panel nudges unverified accounts through the email-OTP flow. */ email_verified: boolean;