fix(api): 登录验证码与 passkey 挑战不再被他人的 start 作废,冷却内重复 start 照常 202,登录挑战按来源限量

This commit is contained in:
Lemon-miaow committed 2026-09-25 16:37:03 +08:00
1 parent 084ba1ed9e
commit 4757353324
22 files changed
+1290 -314

No files matched your search

+26 -18
View File
@@ -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:
+3 -2
View File
@@ -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
+126 -28
View File
@@ -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
}
+7 -8
View File
@@ -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.
+17 -8
View File
@@ -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
}
+121 -17
View File
@@ -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":"[email protected]"}`, 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":"[email protected]"}`, 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, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if w := start(eh, "[email protected]"); 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, "[email protected]")
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, "[email protected]"); 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":"[email protected]","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, "[email protected]"); 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, "[email protected]"); 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, "[email protected]"); w.Code != http.StatusTooManyRequests {
t.Fatalf("recased resend: code = %d, want 429 (key must be lowercased)", w.Code)
if w := start(eh, "[email protected]"); 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{"[email protected]", "[email protected]"} {
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{"[email protected]", "[email protected]"} {
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":"[email protected]"}`, 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":"[email protected]","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
+9
View File
@@ -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
+34 -20
View File
@@ -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
}
+68 -5
View File
@@ -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, "[email protected]"))
inboxCode := mailer.code
clock = clock.Add(30 * time.Second)
w := startOp(eh, "[email protected]")
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, "[email protected]"))
firstCode := mailer.code
clock = clock.Add(otpResendCooldown)
requestID(startOp(eh, "[email protected]"))
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
+89 -32
View File
@@ -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)
+14 -10
View File
@@ -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)
@@ -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).
+147 -74
View File
@@ -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":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
w = do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "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":"[email protected]","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("[email protected]", "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, "[email protected]"); 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, "[email protected]"); 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":"[email protected]"}`, 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", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"known account, no live challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
{"unknown email", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"known account, no live challenge", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "ex", frozenNow.Add(-time.Second))
}},
{"assertion fails verification", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
{"assertion fails verification", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
v.failErr = errors.New("bad assertion")
}},
{"assertion signs a challenge nobody issued", "[email protected]", loginAssertion("cred-1", "bm9ib2R5"), func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
}},
{"assertion without clientDataJSON", "[email protected]", `{"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":"[email protected]"}`, 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("[email protected]", 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, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := begin(eh, "[email protected]"); 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, "[email protected]"); 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, "[email protected]"); 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":"[email protected]"}`,
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, "[email protected]"); 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":"[email protected]","assertion":{"id":"cred-a1","type":"public-key"}}`, jsonHeader)
`{"email":"[email protected]","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":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "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())
+196 -55
View File
@@ -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 (
+17
View File
@@ -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()
}
}
+20
View File
@@ -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}
+37 -14
View File
@@ -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
+287
View File
@@ -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)
}
}
@@ -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);
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "重新开始",
+14 -14
View File
@@ -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;