diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 54e10cd..bcb19ef 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1709,6 +1709,153 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/auth/passkey/login/discoverable/begin: + post: + tags: [auth] + operationId: passkeyLoginDiscoverableBegin + summary: Begin a usernameless (discoverable) passkey login (spec §14, §B, task #40). + 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 (that volumetric limiting + is delegated to the edge), so the server-side brake is 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. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: false + description: >- + No body is read — the whole point is that the caller supplies no identifier — + but the application/json Content-Type is required (415 otherwise). + content: + application/json: + schema: { type: object } + responses: + '200': + description: >- + The WebAuthn assertion options (PublicKeyCredentialRequestOptions) with an + empty allowCredentials, passed through verbatim for the browser to consume, + plus an opaque login_id the caller echoes at finish. The publicKey member is + the WebAuthn standard shape and is not modelled here. + content: + application/json: + schema: + type: object + required: [publicKey, login_id] + properties: + publicKey: { type: object, additionalProperties: true } + login_id: { type: string } + '400': + description: The authenticator library could not start the ceremony (passkey_login_failed). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request Content-Type was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: >- + Too many discoverable logins are in flight server-wide; the global cap is hit + (too_many_challenges). No per-recipient signal is leaked — the cap is global. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + description: No passkey verifier is wired on this deployment (passkey_unavailable). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/passkey/login/discoverable/finish: + post: + tags: [auth] + operationId: passkeyLoginDiscoverableFinish + summary: Complete a usernameless (discoverable) passkey login and mint a session (spec §14, §B, task #40). + description: >- + Second leg of the from-zero door: the caller returns the opaque login_id from + begin (the only link to the stashed challenge, since it is not user-keyed) and + the raw navigator.credentials.get() assertion — and NOTHING that names an + account. The stashed challenge is consumed atomically and the assertion is + verified against it; the account is resolved from the authenticator-revealed + userHandle (the account's stable id), never from anything the client supplied, + and the session is minted for the account the assertion actually resolved AND + verified to. Both players and staff may log in this way. Every failure mode — a + missing/expired/consumed login_id, a bad assertion, AND a userHandle that + resolves to no account — collapses into one uniform passkey_login_invalid, so + the door reveals nothing (not even whether the handle was well-formed). + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [login_id, assertion] + properties: + login_id: + type: string + description: The opaque handle returned by discoverable/begin. + assertion: + type: object + additionalProperties: true + description: >- + The raw PublicKeyCredential from navigator.credentials.get(), + passed to the verifier verbatim (WebAuthn standard shape). Its + userHandle selects the account server-side. + responses: + '200': + description: Assertion verified; a host-only session cookie is set on the response. + content: + application/json: + schema: + type: object + required: [user_id, role] + properties: + user_id: { type: string } + role: { type: string, enum: [user, admin] } + '400': + description: >- + Missing login_id or assertion (bad_request); or the login could not be + completed — no live/expired/consumed challenge, a failed assertion, or a + userHandle that resolves to no account, all uniform (passkey_login_invalid). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + description: No passkey verifier is wired on this deployment (passkey_unavailable). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /api/v1/auth/email/start: post: tags: [auth] diff --git a/internal/api/api.go b/internal/api/api.go index 5c5ed06..c67edb8 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -282,6 +282,11 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus}, {Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin}, {Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish}, + // Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the + // email-first pair above — no identifier typed, the account is resolved from the + // userHandle inside the signed assertion (handlers_passkey_discoverable.go). + {Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/begin", Public: true, h: a.handlePasskeyLoginDiscoverableBegin}, + {Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/finish", Public: true, h: a.handlePasskeyLoginDiscoverableFinish}, {Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart}, {Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify}, {Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart}, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index c14f8de..dbe3afc 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -81,6 +81,12 @@ type fakeRepo struct { // just as the PG query does. passkeyCreds map[string]PasskeyCredential passkeyChallenges map[string]*fakePasskeyChallenge + // discoverable ("usernameless") login challenge store (task #40), keyed by opaque handle id + // with no user key, mirroring migration 0013. discoverableFull forces the capped-out path + // (ErrTooManyDiscoverableChallenges) so the begin 429 branch is reachable without inserting + // thousands of rows. + discoverableChallenges map[string]*fakeDiscoverableChallenge + discoverableFull bool // user admin fakes seededUsers []seededUser fakeQuotas map[string]*QuotaView @@ -99,6 +105,15 @@ type fakePasskeyChallenge struct { createdAt time.Time } +// fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user +// or purpose (a from-zero begin has neither), just the opaque stashed SessionData, its expiry, +// and single-use via consumed. Keyed by the opaque handle in the map, like the real table's id. +type fakeDiscoverableChallenge struct { + sessionData []byte + expiresAt time.Time + consumed bool +} + // fakeDataHold mirrors a player_data_holds row at the granularity the verifiable // (write-only) layer exercises: which name/data was stashed for the squatter UUID // and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake @@ -191,7 +206,9 @@ func newFakeRepo() *fakeRepo { holds: map[string]fakeDataHold{}, passkeyCreds: map[string]PasskeyCredential{}, passkeyChallenges: map[string]*fakePasskeyChallenge{}, - fakeQuotas: map[string]*QuotaView{}, + + discoverableChallenges: map[string]*fakeDiscoverableChallenge{}, + fakeQuotas: map[string]*QuotaView{}, } } @@ -366,6 +383,31 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp return live.sessionData, nil } +// 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 { + if f.discoverableFull { + return ErrTooManyDiscoverableChallenges + } + 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) + } + } + f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt} + return nil +} +func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) { + c, ok := f.discoverableChallenges[id] + if !ok || c.consumed || !c.expiresAt.After(now) { + return nil, ErrPasskeyChallengeInvalid + } + c.consumed = true + return c.sessionData, nil +} + // CreatePasskeyCredential mirrors PGRepo: a credential_id already bound to ANY account // → ErrConflict (the UNIQUE guard), never a silent rebind. func (f *fakeRepo) CreatePasskeyCredential(_ context.Context, c PasskeyCredential) error { @@ -436,6 +478,10 @@ type fakePasskeyVerifier struct { // stashed SessionData round-trips and the existing credentials reach the verifier. lastUser PasskeyUser lastSession []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). + discoverableUserHandle []byte } func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) { @@ -477,6 +523,33 @@ func (v *fakePasskeyVerifier) FinishLogin(user PasskeyUser, sessionData []byte, return v.assertion, nil } +func (v *fakePasskeyVerifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) { + if v.beginLoginErr != nil { + return nil, nil, v.beginLoginErr + } + opts := v.options + if opts == nil { + opts = json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`) + } + return opts, []byte("disc-session"), nil +} + +func (v *fakePasskeyVerifier) FinishDiscoverableLogin(resolveUser func([]byte) (PasskeyUser, error), sessionData []byte, _ io.Reader) (VerifiedAssertion, error) { + v.lastSession = sessionData + if v.failErr != nil { + return VerifiedAssertion{}, v.failErr + } + // Drive the resolver with the configured user handle so the handler's userHandle → UserByID + // → session-mint wiring runs end to end; a resolve error (unknown handle) fails the ceremony + // exactly as the real ValidateDiscoverableLogin would when the handler cannot be resolved. + u, err := resolveUser(v.discoverableUserHandle) + if err != nil { + return VerifiedAssertion{}, err + } + v.lastUser = u + return v.assertion, nil +} + func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) { return f.allowlist[n][u], nil } diff --git a/internal/api/errors.go b/internal/api/errors.go index 40b4ed2..c5d60d1 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -58,6 +58,14 @@ 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") ) // apiError is a handler-level error carrying an HTTP status and a stable, diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index db62bcc..652bb19 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -40,15 +40,20 @@ import ( // (0010_verified_email_unique.sql) plus UserByEmail gave the door the typable handle // it keys on: begin resolves email → account → its bound passkeys. // -// This is an EMAIL-first assertion, not a usernameless one. The system's returning-player -// root of trust is still re-link (control of the in-game identity — handlers_onboard.go -// re-mints a session through the bind-code flow even after passkey/OTP are bound); the -// email and passkey login doors are convenience layered on top, never the root. The real -// enabler for a TRULY from-zero passkey login (no identifier typed at all) is discoverable -// ("usernameless") credentials, which sidestep even the email handle but reshape enrollment -// (residentKey) and need a non-user-keyed challenge store — a future migration and its own -// checkpoint, task #40 (that door partly bypasses the in-game-identity root of trust). The -// adapter crypto is verified now so that slice inherits correct crypto. +// That EMAIL-first assertion is one of TWO login doors this subsystem now offers. The other, +// the TRULY from-zero door, is discoverable ("usernameless") login (handlers_passkey_discoverable.go, +// task #40): the browser calls navigator.credentials.get() with an EMPTY allowCredentials, the +// authenticator offers a resident credential it holds, and the account is resolved from the +// userHandle inside the signed assertion — no identifier typed at all. It reshaped enrollment +// (ResidentKey=Preferred in the verifier) and added a non-user-keyed challenge store (migration +// 0013). Two honest limits frame it: (1) the from-zero door partly bypasses the returning-player +// root of trust — control of the in-game identity, which handlers_onboard.go re-mints a session +// through even after passkey/OTP are bound — but it stands on the same footing as the email door +// (#72): a passkey is a possession+UV two-factor authenticator strong enough to stand alone; and +// (2) whether an authenticator actually STORES a resident key is a device property no server +// request compels, so a credential enrolled before this slice, or on hardware that declines +// residency, stays username-first (BeginLogin) — the from-zero door is inert for it until its +// owner enrolls a new passkey. The assertion crypto for both doors is Oracle-verified. // // The cryptographic half is a seam (PasskeyVerifier) so this package never imports // go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation @@ -102,6 +107,21 @@ type PasskeyVerifier interface { // the browser posts back; sessionData is the blob BeginLogin returned. A failed // verification returns a non-nil error; the handler maps it to 400. FinishLogin(user PasskeyUser, sessionData []byte, assertion io.Reader) (VerifiedAssertion, error) + // BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): there is no + // user yet, so no allowCredentials — the authenticator offers a resident (discoverable) + // credential it holds for this RP and reveals the account only in the signed response. It + // returns the {"publicKey": {...}} request options for navigator.credentials.get() and the + // opaque SessionData the handler stashes under an opaque handle (not a user id) and replays + // at finish. + BeginDiscoverableLogin() (options json.RawMessage, sessionData []byte, err error) + // FinishDiscoverableLogin verifies a usernameless assertion. resolveUser is called with the + // authenticator-revealed user handle so the caller loads the account and its bound + // credentials WITHOUT any client-supplied identifier; the verifier then checks the asserted + // credential id is one that user holds and verifies the signature. A resolveUser error + // (unknown handle) fails the ceremony closed; the handle is the account's stable user id, so + // resolveUser is a direct id lookup. A failed verification returns a non-nil error the + // handler maps to 400. + FinishDiscoverableLogin(resolveUser func(userHandle []byte) (PasskeyUser, error), sessionData []byte, assertion io.Reader) (VerifiedAssertion, error) } // PasskeyUser is the relying-party view of the enrolling principal the verifier needs: diff --git a/internal/api/handlers_passkey_discoverable.go b/internal/api/handlers_passkey_discoverable.go new file mode 100644 index 0000000..6e18a56 --- /dev/null +++ b/internal/api/handlers_passkey_discoverable.go @@ -0,0 +1,209 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" +) + +// Discoverable ("usernameless") passkey login (spec §14, task #40) — the TRULY from-zero +// console. door. Its email-first sibling (handlers_passkey.go) still needs a typed +// email to resolve the account before offering its passkeys; this door needs nothing typed at +// all. The browser calls navigator.credentials.get() with an EMPTY allowCredentials, the +// authenticator offers a resident credential it holds for this RP, and the account is revealed +// only by the userHandle inside the signed assertion. Because there is no identifier at begin, +// the challenge cannot be user-keyed: it is stashed under an opaque server-minted handle +// (login_id) in the non-user-keyed store (migration 0013) and echoed back at finish. Email-OTP +// 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 — matching the stance in handlers_auth_email.go (behind +// Cloudflare RemoteAddr is the proxy; CGNAT false-positives) — volumetric per-source limiting is +// left to the edge, and the server-side bound is a hard global cap on live challenges enforced +// atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges → 429). + +// handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public, +// pre-session). It has no request body — the whole point is that the caller supplies no +// identifier — but requires the JSON Content-Type as the same cross-origin CSRF guard the other +// pre-session doors use. It asks the verifier for assertion options with an empty +// allowCredentials + opaque SessionData, stashes the SessionData under a fresh opaque handle in +// the capped non-user-keyed store, and returns the options with that handle merged in as +// login_id for the browser to echo at finish. +func (a *API) handlePasskeyLoginDiscoverableBegin(w http.ResponseWriter, r *http.Request) { + if !localAuthEnabled(r.Context(), a.Repo) { + writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled", + "session login is disabled")) + return + } + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + if err := requireJSONContentType(r); err != nil { + writeError(w, r, err) + return + } + options, sessionData, err := a.Passkey.BeginDiscoverableLogin() + if err != nil { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed", + "could not start passkey login")) + return + } + id, err := newPasskeyID() + if err != nil { + writeError(w, r, err) + 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")) + return + } + writeError(w, r, err) + return + } + // Merge the opaque login handle into the options envelope so the response is a single + // {"publicKey": {...}, "login_id": "..."} document. The browser passes publicKey to + // navigator.credentials.get() and echoes login_id back at finish (the challenge is never + // user-keyed, so this handle is the only link between begin and finish). + envelope, err := mergeLoginID(options, id) + if err != nil { + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, envelope) +} + +// passkeyDiscoverableFinishRequest is the finish body: the opaque login_id that begin returned +// (the only link to the stashed challenge, since it is not user-keyed) and the raw +// navigator.credentials.get() assertion. Assertion is RawMessage so the exact bytes the browser +// produced reach the verifier without a re-encode that could perturb the signed payload. +type passkeyDiscoverableFinishRequest struct { + LoginID string `json:"login_id"` + Assertion json.RawMessage `json:"assertion"` +} + +// handlePasskeyLoginDiscoverableFinish verifies a usernameless assertion and mints a session +// (Public, pre-session). It consumes the stashed challenge under login_id (a missing/expired/ +// consumed handle → 400), then verifies the assertion — the verifier resolves the account from +// the authenticator-revealed userHandle via the resolve callback below, WITHOUT any +// client-supplied identifier. On success the session is minted for the account the assertion +// actually resolved AND verified to (the resolved user is hoisted out of the callback), never +// anything the client named. All rejection branches collapse to one passkey_login_invalid +// envelope so finish is never an existence/state oracle. +func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *http.Request) { + if !localAuthEnabled(r.Context(), a.Repo) { + writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled", + "session login is disabled")) + return + } + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + if err := requireJSONContentType(r); err != nil { + writeError(w, r, err) + return + } + var req passkeyDiscoverableFinishRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + if strings.TrimSpace(req.LoginID) == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "login_id is required")) + return + } + if len(req.Assertion) == 0 { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required")) + return + } + + sessionData, err := a.Repo.ConsumeDiscoverableChallenge(r.Context(), req.LoginID, a.now()) + if err != nil { + if errors.Is(err, ErrPasskeyChallengeInvalid) { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey login could not be completed; begin again")) + return + } + writeError(w, r, err) + return + } + + // The verifier hands the authenticator-revealed userHandle to this resolver; it loads the + // account and its bound credentials so ValidateDiscoverableLogin can check the asserted + // credential belongs to that user and verify the signature. The userHandle IS the account's + // stable id (WebAuthnID), so this is a direct id lookup. The resolved user is hoisted here so + // the session below is minted for the account the assertion actually resolved AND verified to + // — not anything the client supplied (the body carries only a challenge handle). + var resolved *StaffUser + resolve := func(userHandle []byte) (PasskeyUser, error) { + u, err := a.Repo.UserByID(r.Context(), string(userHandle)) + if err != nil { + return PasskeyUser{}, err + } + creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID) + if err != nil { + return PasskeyUser{}, err + } + resolved = u + // Name/DisplayName are cosmetic at assertion time (nothing is shown to the user); use the + // stable username so a nil email never matters. + return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil + } + if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey login could not be completed; begin again")) + return + } + // A verified assertion guarantees resolve ran and set resolved: go-webauthn calls the handler + // to obtain the user BEFORE checking the signature, and a resolve error would have failed + // FinishDiscoverableLogin above. Guard anyway so a future verifier that could return success + // without invoking the resolver fails closed rather than nil-dereferencing. + if resolved == nil { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey login could not be completed; begin again")) + return + } + + token, err := newSessionToken() + if err != nil { + writeError(w, r, err) + return + } + expires := a.now().Add(sessionTTL) + if err := a.Repo.CreateSession(r.Context(), hashCookie(token), resolved.ID, expires); err != nil { + writeError(w, r, err) + return + } + setSessionCookie(w, token, expires) + a.audit(r, resolved.Username, "auth.passkey_login_discoverable", "") + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": resolved.ID, + "role": resolved.Role, + }) +} + +// mergeLoginID returns options with an added top-level "login_id" member, so a discoverable +// begin can hand the browser one {"publicKey": {...}, "login_id": "..."} document. It parses the +// options into a generic envelope (they are already a JSON object with a publicKey member) and +// re-marshals with the handle added; a malformed options blob surfaces as an error rather than a +// silently unmergeable response. +func mergeLoginID(options json.RawMessage, id string) (json.RawMessage, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(options, &envelope); err != nil { + return nil, err + } + idJSON, err := json.Marshal(id) + if err != nil { + return nil, err + } + envelope["login_id"] = idJSON + return json.Marshal(envelope) +} diff --git a/internal/api/handlers_passkey_discoverable_test.go b/internal/api/handlers_passkey_discoverable_test.go new file mode 100644 index 0000000..08bd78d --- /dev/null +++ b/internal/api/handlers_passkey_discoverable_test.go @@ -0,0 +1,319 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Pre-session DISCOVERABLE ("usernameless") passkey login tests (spec §14, task #40 — the +// truly from-zero console. door). These drive the two Public routes against the +// fakeRepo's non-user-keyed challenge store and a fake PasskeyVerifier, so what they PROVE is +// the handler + login state machine (opaque-handle stash → consume → userHandle-resolve → +// session mint), NOT the pgrepo SQL nor the cryptographic assertion verification (the latter is +// the parity subject of internal/passkey/verifier_test.go's TestDiscoverableLoginRoundTrip). +// The load-bearing properties, in flow order: +// +// - No identifier crosses the wire: begin has no request body and finish carries only the +// opaque login_id + the assertion. The account is revealed solely by the userHandle the +// verifier surfaces, resolved server-side via UserByID — never anything the client names. +// - Session-data round-trip: the stashed SessionData reaches FinishDiscoverableLogin only via +// store-stash → consume (the finish body has no session data), so it is never client-echoed. +// - Fail-closed anti-enumeration on finish: a bogus/expired/consumed handle, a failed +// assertion, AND a userHandle that resolves to no account all collapse to ONE +// passkey_login_invalid envelope — finish is never an existence/state oracle. +// - Volumetric bound: begin has no per-caller identity to rate-limit (that is delegated to the +// edge), so the server-side guard is the hard global cap → 429 too_many_challenges. + +// seedDiscoverableLoginAPI wires the public from-zero door: local sessions enabled, a single +// verified player "player" (id u1) with one bound passkey, and a verifier primed with fixed +// options, a verified assertion, and a discoverableUserHandle of "u1" — so the default resolve +// path surfaces that account exactly as a real resident credential's userHandle would. Both +// routes are Public (no External principal), proving they are truly pre-session. +func seedDiscoverableLoginAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) { + t.Helper() + repo := newFakeRepo() + repo.settings[LocalAuthEnabledKey] = []byte("true") + repo.staff["player"] = &StaffUser{ + ID: "u1", Username: "player", Email: "Player@Example.NET", + Role: "user", EmailVerified: true, + } + repo.passkeyCreds["row1"] = PasskeyCredential{ + ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow, + } + v := &fakePasskeyVerifier{ + options: json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`), + assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true}, + discoverableUserHandle: []byte("u1"), + } + api := newTestAPI(repo, newFakeCluster()) + api.Passkey = v + return api, repo, v +} + +// TestPasskeyDiscoverableLoginVertical walks the whole from-zero slice across the external face: +// begin stashes one challenge under an opaque login_id and returns the assertion options with +// that handle merged in; finish consumes the handle, verifies the assertion against the +// SERVER-STASHED session data, resolves the account from the authenticator-revealed userHandle +// (NOT from anything typed), and mints the same host-only felis_session as the other doors. The +// decisive assertion is the session-data round-trip: the finish body carries only login_id + +// assertion, so the only path for the stashed blob into FinishDiscoverableLogin is store-stash → +// consume — the challenge is never client-echoed. +func TestPasskeyDiscoverableLoginVertical(t *testing.T) { + api, repo, v := seedDiscoverableLoginAPI(t) + eh := api.ExternalHandler() + + // 1) begin: usernameless — no request body by design (the caller supplies no identifier), + // only the JSON Content-Type CSRF guard. The response is one {"publicKey":{...},"login_id": + // "..."} document, and exactly one challenge is stashed, keyed by the returned handle. + w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + body := acctBody(t, w) + if body["publicKey"] == nil { + t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String()) + } + loginID, _ := body["login_id"].(string) + if loginID == "" { + t.Fatalf("begin must return a non-empty login_id, got %s", w.Body.String()) + } + if len(repo.discoverableChallenges) != 1 { + t.Fatalf("begin must stash exactly one discoverable challenge, got %d", len(repo.discoverableChallenges)) + } + if _, ok := repo.discoverableChallenges[loginID]; !ok { + t.Errorf("the stashed challenge must be keyed by the returned login_id %q", loginID) + } + + // 2) finish: the body carries ONLY the login_id and the assertion — no identifier and no + // session data. The account is revealed by the userHandle the verifier surfaces (u1). + w = do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish", + `{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + // THE security assertion: the session data the verifier saw is exactly what begin stashed — + // it travelled store-stash → consume, never the client (the finish body has no session data). + if !bytes.Equal(v.lastSession, []byte("disc-session")) { + t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)", + v.lastSession, "disc-session") + } + vb := acctBody(t, w) + if vb["user_id"] != "u1" || vb["role"] != "user" { + t.Fatalf("finish body = %v, want user_id:u1 role:user", vb) + } + // The host-only HttpOnly cookie is the whole point — same contract as the other doors. + cookies := w.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { + t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies) + } + s, ok := repo.sessions[hashCookie(cookies[0].Value)] + if !ok { + t.Fatal("no session row for the issued cookie (must be stored hashed)") + } + if s.userID != "u1" { + t.Errorf("session userID = %q, want u1", s.userID) + } + if want := frozenNow.Add(sessionTTL); !s.expiresAt.Equal(want) { + t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want) + } + // Audited once, by the RESOLVED account's username (there is no principal yet); begin is silent. + if n := len(repo.audits); n != 1 { + t.Fatalf("want exactly 1 audit (passkey_login_discoverable), got %d: %+v", n, repo.audits) + } + if repo.audits[0].Action != "auth.passkey_login_discoverable" || repo.audits[0].Actor != "player" { + t.Errorf("audit = %+v, want auth.passkey_login_discoverable by player", repo.audits[0]) + } + + // 3) single-use: the consumed login_id buys nothing a second time. + if w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish", + `{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { + t.Fatalf("replay of consumed login_id: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String()) + } +} + +// 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. +func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) { + api, repo, _ := seedDiscoverableLoginAPI(t) + repo.discoverableFull = true + eh := api.ExternalHandler() + + w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader) + if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" { + t.Fatalf("capped begin: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String()) + } + if len(repo.discoverableChallenges) != 0 { + t.Errorf("a capped begin must stash nothing, got %d", len(repo.discoverableChallenges)) + } +} + +// 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). +func TestPasskeyDiscoverableLoginBeginVerifierError(t *testing.T) { + api, repo, v := seedDiscoverableLoginAPI(t) + v.beginLoginErr = errors.New("cannot begin discoverable") + eh := api.ExternalHandler() + + w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" { + t.Fatalf("verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String()) + } + if len(repo.discoverableChallenges) != 0 { + t.Errorf("a failed begin must stash nothing, got %d", len(repo.discoverableChallenges)) + } +} + +// TestPasskeyDiscoverableLoginGates covers the shared front doors of both halves: the +// fail-closed local-auth toggle, graceful degradation when no verifier is wired, the CSRF +// Content-Type guard (these are Public, credential-minting routes), and the finish input gates +// that must reject before any consume or resolve. +func TestPasskeyDiscoverableLoginGates(t *testing.T) { + const beginPath = "/api/v1/auth/passkey/login/discoverable/begin" + const finishPath = "/api/v1/auth/passkey/login/discoverable/finish" + const goodFinish = `{"login_id":"x","assertion":{"id":"cred-1"}}` + + t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) { + api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed + api.Passkey = &fakePasskeyVerifier{} + eh := api.ExternalHandler() + if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" { + t.Errorf("begin: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String()) + } + if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" { + t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String()) + } + }) + + t.Run("no verifier wired -> 503 passkey_unavailable on both halves", func(t *testing.T) { + api, _, _ := seedDiscoverableLoginAPI(t) + api.Passkey = nil // unwire it: the degraded path must be a clean 503, not a panic + eh := api.ExternalHandler() + if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" { + t.Errorf("begin: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String()) + } + if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" { + t.Errorf("finish: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String()) + } + }) + + t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) { + api, _, _ := seedDiscoverableLoginAPI(t) + eh := api.ExternalHandler() + for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} { + if w := do(eh, "POST", beginPath, `{}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType { + t.Errorf("begin with Content-Type %q: code = %d, want 415", ct, w.Code) + } + if w := do(eh, "POST", finishPath, goodFinish, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType { + t.Errorf("finish with Content-Type %q: code = %d, want 415", ct, w.Code) + } + } + }) + + t.Run("finish missing inputs -> 400 bad_request, nothing minted", func(t *testing.T) { + cases := map[string]string{ + "missing login_id": `{"assertion":{"id":"cred-1"}}`, + "empty login_id": `{"login_id":"","assertion":{"id":"cred-1"}}`, + "missing assertion": `{"login_id":"x"}`, + } + for name, body := range cases { + api, repo, _ := seedDiscoverableLoginAPI(t) + w := do(api.ExternalHandler(), "POST", finishPath, body, jsonHeader) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" { + t.Errorf("%s: code = %d body %s, want 400 bad_request", name, w.Code, w.Body.String()) + } + if len(repo.sessions) != 0 { + t.Errorf("%s: a rejected finish must mint no session (%d)", name, len(repo.sessions)) + } + } + }) +} + +// TestPasskeyDiscoverableLoginFinishRejections is the redeem-side failure matrix and the anchor +// for from-zero anti-enumeration: a bogus handle, an expired challenge, an assertion that fails +// verification, AND a userHandle that resolves to no account must ALL answer the byte-identical +// passkey_login_invalid envelope (code AND message) and mint no session. The last case is the +// one unique to this door — the account is chosen by the authenticator, so an unresolvable +// handle must fail exactly like a bad signature, never leaking that the handle was well-formed. +func TestPasskeyDiscoverableLoginFinishRejections(t *testing.T) { + const finishPath = "/api/v1/auth/passkey/login/discoverable/finish" + finish := func(eh http.Handler, loginID string) *httptest.ResponseRecorder { + return do(eh, "POST", finishPath, + `{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + } + + cases := []struct { + name string + loginID string + setup func(repo *fakeRepo, v *fakePasskeyVerifier) + }{ + {"no live challenge", "ghost", func(repo *fakeRepo, v *fakePasskeyVerifier) {}}, + {"expired challenge", "ex", func(repo *fakeRepo, v *fakePasskeyVerifier) { + repo.discoverableChallenges["ex"] = &fakeDiscoverableChallenge{ + sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(-time.Second), + } + }}, + {"assertion fails verification", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) { + repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{ + sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL), + } + v.failErr = errors.New("bad assertion") + }}, + {"userHandle resolves to no account", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) { + repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{ + sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL), + } + v.discoverableUserHandle = []byte("nonexistent") + }}, + } + + var envelopes [][2]string + for _, c := range cases { + api, repo, v := seedDiscoverableLoginAPI(t) + c.setup(repo, v) + w := finish(api.ExternalHandler(), c.loginID) + if w.Code != http.StatusBadRequest { + t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String()) + } + code, msg := errEnvelope(t, w) + if code != "passkey_login_invalid" { + t.Errorf("%s: error code = %q, want passkey_login_invalid", c.name, code) + } + if len(repo.sessions) != 0 { + t.Errorf("%s: a rejected finish must mint no session (got %d)", c.name, len(repo.sessions)) + } + if len(w.Result().Cookies()) != 0 { + t.Errorf("%s: a rejected finish must set no cookie", c.name) + } + envelopes = append(envelopes, [2]string{code, msg}) + } + // The anchor: every envelope is identical (code AND message), so no branch — including the + // unresolvable-handle branch — is distinguishable from another. + for i := 1; i < len(envelopes); i++ { + if envelopes[i] != envelopes[0] { + t.Errorf("envelope for %q %v differs from %q %v — all rejections must be identical", + cases[i].name, envelopes[i], cases[0].name, envelopes[0]) + } + } +} + +// TestPasskeyDiscoverableLoginFaceSeparation enforces that both halves are web-only: the +// internal (service-token) face must 404 them, never serve them. +func TestPasskeyDiscoverableLoginFaceSeparation(t *testing.T) { + api, _, _ := seedDiscoverableLoginAPI(t) + ih := api.InternalHandler() + if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader); w.Code != http.StatusNotFound { + t.Errorf("begin on internal face: code = %d, want 404", w.Code) + } + if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/finish", `{"login_id":"x","assertion":{"id":"y"}}`, jsonHeader); w.Code != http.StatusNotFound { + t.Errorf("finish on internal face: code = %d, want 404", w.Code) + } +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 21b4115..2525afe 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -1004,6 +1004,91 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp 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. Volumetric per-IP limiting is the edge's job (handlers_auth_email.go): +// behind Cloudflare RemoteAddr is the proxy, and a usernameless door has no recipient to key a +// fair per-caller limit on. +const maxLiveDiscoverableChallenges = 4096 + +// 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 { + 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_discoverable_challenges WHERE expires_at <= $1 OR consumed_at IS NOT NULL`, + now); err != nil { + return fmt.Errorf("reap discoverable challenges: %w", err) + } + var live int + if err := tx.QueryRowContext(ctx, + `SELECT count(*) FROM webauthn_discoverable_challenges`).Scan(&live); err != nil { + return fmt.Errorf("count discoverable challenges: %w", err) + } + if live >= maxLiveDiscoverableChallenges { + return ErrTooManyDiscoverableChallenges + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at) + VALUES ($1, $2, $3)`, + id, sessionData, expiresAt); err != nil { + return fmt.Errorf("insert discoverable challenge: %w", err) + } + return tx.Commit() +} + +// ConsumeDiscoverableChallenge redeems the challenge under handle id, single-use (see the Repo +// interface for the contract). The row is taken FOR UPDATE so a concurrent finish cannot +// double-spend it; expiry is checked before consuming. No live row → ErrPasskeyChallengeInvalid. +func (p *PGRepo) ConsumeDiscoverableChallenge(ctx context.Context, id 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 ( + sessionData []byte + expiresAt time.Time + ) + switch err := tx.QueryRowContext(ctx, + `SELECT session_data, expires_at FROM webauthn_discoverable_challenges + WHERE id = $1 AND consumed_at IS NULL FOR UPDATE`, + id).Scan(&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_discoverable_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil { + return nil, fmt.Errorf("consume discoverable challenge: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, err + } + return sessionData, nil +} + // CreatePasskeyCredential stores a freshly verified passkey (enrollment). Only public // attestation material is written; a credential_id already bound to ANY account is left // untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero RowsAffected, diff --git a/internal/api/repo.go b/internal/api/repo.go index 4e83ebb..cde1d2b 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -350,9 +350,25 @@ 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 always has a principal, so - // there is no usernameless consume-by-hash variant (login is a deferred slice). + // 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. ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose 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 + // 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 + // the stashed SessionData. An unknown, expired, or already-consumed handle → + // ErrPasskeyChallengeInvalid, so the finish door never doubles as a state oracle. + ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) (sessionData []byte, err error) // CreatePasskeyCredential stores a freshly verified passkey for a user (Phase 6 // enrollment). It writes only public attestation material (credential_id, // public_key, sign_count, aaguid) plus the caller's nickname. A credential_id diff --git a/internal/passkey/verifier.go b/internal/passkey/verifier.go index cc2f642..dd3ed3b 100644 --- a/internal/passkey/verifier.go +++ b/internal/passkey/verifier.go @@ -6,15 +6,19 @@ // this package imports api for the seam types; api never imports this package, which is // what keeps the seam (and the api test suite's fake verifier) honest. // -// Scope: the full WebAuthn ceremony crypto — both the credential-creation (enrollment: -// BeginRegistration/FinishRegistration over go-webauthn's BeginRegistration/CreateCredential) -// and the assertion (login: BeginLogin/FinishLogin over BeginLogin/ValidateLogin) halves. -// Both are Oracle-verified in verifier_test.go against a virtual authenticator. Only the -// enrollment half is wired to HTTP handlers today (handlers_passkey.go); the login -// handlers, session minting, and the panel.* relying-party boundary are a deferred slice, -// so BeginLogin/FinishLogin here have no api-package caller yet. They are added to the -// concrete adapter (not the api.PasskeyVerifier interface) precisely so the crypto is -// built and verified now while the interface grows only when a handler consumes it. +// Scope: the full WebAuthn ceremony crypto across three halves, each Oracle-verified in +// verifier_test.go against a virtual authenticator: +// +// - enrollment (BeginRegistration/FinishRegistration over go-webauthn's +// BeginRegistration/CreateCredential), +// - username-first login (BeginLogin/FinishLogin over BeginLogin/ValidateLogin), where the +// account is known and its bound credentials scope allowCredentials, and +// - discoverable, "usernameless" login (BeginDiscoverableLogin/FinishDiscoverableLogin over +// BeginDiscoverableLogin/ValidateDiscoverableLogin), where the account is unknown at begin +// and revealed only by the userHandle inside the signed assertion (task #40). +// +// All three are in the api.PasskeyVerifier interface and consumed by handlers today (enrollment +// + login in handlers_passkey.go, from-zero login in handlers_passkey_discoverable.go). package passkey import ( @@ -68,6 +72,17 @@ func New(rpID, displayName string, origins []string) (*Verifier, error) { // email-OTP factor (migration 0004); no one is locked out. AuthenticatorSelection: protocol.AuthenticatorSelection{ UserVerification: protocol.VerificationRequired, + // Prefer a discoverable (resident) credential so a passkey can later be asserted + // usernamelessly (task #40 from-zero login): the authenticator stores the credential + // and can present it with no identifier typed. PREFERRED, not Required, keeps the + // no-lockout ethos — an authenticator that cannot make a resident key still binds a + // working username-first passkey (BeginLogin) and falls back to email-OTP; only the + // from-zero convenience is unavailable. This shapes only the creation options a browser + // receives (a server-side request, asserted in TestEnrollmentRequestsResidentKey); + // whether a real authenticator honors it — actually storing a resident key — is a device + // property no unit test can prove, so already-bound non-resident credentials stay + // username-first until their owner enrolls a new passkey. + ResidentKey: protocol.ResidentKeyRequirementPreferred, }, }) if err != nil { @@ -200,6 +215,68 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti }, nil } +// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): the caller is +// not yet identified, so — unlike BeginLogin — there is no user and no allowCredentials. The +// authenticator picks a resident (discoverable) credential it holds for this RP and reveals +// the account only inside the signed response at finish. It returns the {"publicKey": {...}} +// request options for navigator.credentials.get() and the opaque, marshaled SessionData the +// handler stashes under an opaque handle (migration 0013's non-user-keyed store) and replays +// at finish. User verification is required, matching enrollment, so a from-zero login still +// proves possession AND user. +func (v *Verifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) { + assertion, session, err := v.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + return nil, nil, err + } + // CredentialAssertion marshals to {"publicKey": {...}} with an EMPTY allowCredentials — + // exactly the usernameless document the browser hands to navigator.credentials.get(). + options, err := json.Marshal(assertion) + if err != nil { + return nil, nil, err + } + // As with the other ceremonies, we stash the marshaled SessionData verbatim and let the + // challenge row's TTL be the sole authority on liveness (no expiry inside SessionData). + sessionData, err := json.Marshal(session) + if err != nil { + return nil, nil, err + } + return options, sessionData, nil +} + +// FinishDiscoverableLogin verifies a usernameless assertion (task #40). go-webauthn hands the +// authenticator-revealed user handle to resolveUser, which the caller uses to load the account +// and its bound credentials WITHOUT any client-supplied identifier; go-webauthn then checks +// the asserted credential id is one that user holds and verifies the signature against its +// stored COSE public key. The user handle is the account's stable id (webauthnUser.WebAuthnID), +// so resolveUser is a direct id lookup. A resolveUser error (unknown handle) fails the ceremony +// closed. resolveUser is a plain api-typed callback so the api package still never imports +// go-webauthn: the adapter wraps it into go-webauthn's DiscoverableUserHandler here. +func (v *Verifier) FinishDiscoverableLogin(resolveUser func(userHandle []byte) (api.PasskeyUser, error), sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) { + var session webauthn.SessionData + if err := json.Unmarshal(sessionData, &session); err != nil { + return api.VerifiedAssertion{}, err + } + parsed, err := protocol.ParseCredentialRequestResponseBody(assertion) + if err != nil { + return api.VerifiedAssertion{}, err + } + handler := func(_, userHandle []byte) (webauthn.User, error) { + u, err := resolveUser(userHandle) + if err != nil { + return nil, err + } + return webauthnUser{u: u}, nil + } + cred, err := v.wa.ValidateDiscoverableLogin(handler, session, parsed) + if err != nil { + return api.VerifiedAssertion{}, err + } + return api.VerifiedAssertion{ + CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID), + SignCount: cred.Authenticator.SignCount, + }, nil +} + // excludeDescriptors turns the principal's already-bound passkeys into the // excludeCredentials list for a creation ceremony. A stored credential id that does not // decode as base64url is skipped rather than aborting the whole ceremony — a single diff --git a/internal/passkey/verifier_test.go b/internal/passkey/verifier_test.go index ee3d25e..90dd226 100644 --- a/internal/passkey/verifier_test.go +++ b/internal/passkey/verifier_test.go @@ -2,6 +2,7 @@ package passkey import ( "encoding/base64" + "encoding/json" "slices" "strings" "testing" @@ -333,3 +334,179 @@ func TestLoginUnknownCredentialRejected(t *testing.T) { t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection") } } + +// TestDiscoverableLoginRoundTrip is the PARITY check for the usernameless (from-zero) half +// (task #40), and the proof its VERIFY path is real crypto rather than a stub. It differs from +// TestLoginRoundTrip in the two ways that define discoverable login: the begin names no user +// (so the request's allowCredentials must be EMPTY), and the account is revealed only by the +// userHandle the authenticator embeds in the signed assertion — the verifier hands that handle +// to a resolve callback that stands in for the handler's userHandle → UserByID lookup. Chained +// onto a REAL enrollment so the assertion validates against a genuine COSE key, and the +// authenticator's counter is advanced first so the surfaced SignCount is proven real, not a +// hardcoded 0. What this does NOT prove: that a real authenticator actually STORED a resident +// key — that residency is a device property (see TestEnrollmentRequestsResidentKey for the only +// thing a unit test can pin, the request the browser receives). +func TestDiscoverableLoginRoundTrip(t *testing.T) { + v := newTestVerifier(t) + rp := virtualRP() + authenticator := virtualwebauthn.NewAuthenticator() + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + stored := enrollCredential(t, v, rp, authenticator, cred) + + // The authenticator returns the user handle in the assertion — this is what a resident + // credential does and what lets the account be resolved from nothing typed. It is the + // account's stable user id (WebAuthnID), so the resolver must receive exactly these bytes. + authenticator.Options.UserHandle = []byte(testUserID) + // Advance the counter so a real (non-zero, strictly increasing) SignCount must survive. + cred.Counter = 9 + + options, sessionData, err := v.BeginDiscoverableLogin() + if err != nil { + t.Fatalf("BeginDiscoverableLogin: %v", err) + } + assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options) + } + if assertionOpts.RelyingPartyID != testRPID { + t.Fatalf("options RP id = %q, want %q", assertionOpts.RelyingPartyID, testRPID) + } + // The defining property of a usernameless request: no credential is named. If this were + // non-empty the ceremony would be username-first and the test would prove nothing about #40. + if len(assertionOpts.AllowCredentials) != 0 { + t.Fatalf("allowCredentials = %v, want empty (usernameless request names no credential)", assertionOpts.AllowCredentials) + } + + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts) + + // resolve stands in for the handler's userHandle → UserByID lookup: it records the handle + // it was handed (to prove the account is revealed by the authenticator, not the client) and + // returns the stored credential so ValidateDiscoverableLogin can verify the signature. + var gotHandle []byte + resolve := func(userHandle []byte) (api.PasskeyUser, error) { + gotHandle = userHandle + return testUser(stored), nil + } + va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)) + if err != nil { + t.Fatalf("FinishDiscoverableLogin: %v", err) + } + if string(gotHandle) != testUserID { + t.Errorf("resolver received userHandle %q, want %q (the account is revealed by the assertion)", gotHandle, testUserID) + } + if va.CredentialID != stored.CredentialID { + t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID) + } + if va.SignCount != 9 { + t.Errorf("SignCount = %d, want 9 (the authenticator's advanced counter)", va.SignCount) + } +} + +// TestDiscoverableLoginResolveFailsClosed proves the from-zero door fails CLOSED when the +// authenticator-revealed account cannot be resolved: a resolve callback that returns an error +// (the handler's UserByID found nothing — a handle for a deleted/unknown account) must abort +// the ceremony, never mint an assertion. Without this the usernameless path could be coaxed +// into treating an unresolvable handle as success. Pairs with the round-trip above so the +// resolver neither over- nor under-blocks. +func TestDiscoverableLoginResolveFailsClosed(t *testing.T) { + v := newTestVerifier(t) + rp := virtualRP() + authenticator := virtualwebauthn.NewAuthenticator() + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + enrollCredential(t, v, rp, authenticator, cred) + authenticator.Options.UserHandle = []byte("nonexistent-account") + + options, sessionData, err := v.BeginDiscoverableLogin() + if err != nil { + t.Fatalf("BeginDiscoverableLogin: %v", err) + } + assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + t.Fatalf("ParseAssertionOptions: %v", err) + } + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts) + + resolve := func(userHandle []byte) (api.PasskeyUser, error) { + return api.PasskeyUser{}, api.ErrNotFound + } + if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil { + t.Fatal("FinishDiscoverableLogin accepted an assertion whose account could not be resolved; want rejection") + } +} + +// TestDiscoverableLoginUnboundCredentialRejected proves the credential-ownership binding for +// the usernameless door — the defense unique to it. In username-first login the server names +// allowCredentials, so an assertion must match a credential the server itself offered. The +// from-zero door names NOTHING: the authenticator reveals BOTH the userHandle and the signing +// credential, so the ONLY barrier stopping an attacker from signing with their own resident key +// while embedding a victim's userHandle is go-webauthn's check that the asserted credential id +// belongs to the resolved user. Here the resolve callback succeeds (the handle names a REAL +// account, u1, holding credential A) — unlike TestDiscoverableLoginResolveFailsClosed where it +// resolves to nothing — but the assertion is signed by credential B, never bound to u1. +// FinishDiscoverableLogin must reject: a good signature over the right challenge under a valid +// userHandle is still not enough without membership. This is the exact guard the "account is +// revealed by the assertion, never named by the client" claim leans on. +func TestDiscoverableLoginUnboundCredentialRejected(t *testing.T) { + v := newTestVerifier(t) + rp := virtualRP() + authenticator := virtualwebauthn.NewAuthenticator() + credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + stored := enrollCredential(t, v, rp, authenticator, credA) + + // A valid handle: it resolves to the real account u1, which holds credential A. + authenticator.Options.UserHandle = []byte(testUserID) + + options, sessionData, err := v.BeginDiscoverableLogin() + if err != nil { + t.Fatalf("BeginDiscoverableLogin: %v", err) + } + assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + t.Fatalf("ParseAssertionOptions: %v", err) + } + // Sign with a fresh credential never bound to u1. The resolver still returns u1's real + // credential set (credential A) — so the ONLY thing that can reject this is the check that + // the asserted credential (B) is among the resolved user's credentials. + credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts) + + resolve := func(userHandle []byte) (api.PasskeyUser, error) { + return testUser(stored), nil + } + if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil { + t.Fatal("FinishDiscoverableLogin accepted an assertion signed by a credential not bound to the resolved user; want rejection") + } +} + +// TestEnrollmentRequestsResidentKey pins the ONLY server-side half of the from-zero enabler a +// unit test can prove: that enrollment ASKS the browser for a resident (discoverable) key, i.e. +// the creation options carry authenticatorSelection.residentKey = "preferred". Whether a real +// authenticator honors the request — actually persisting a resident key so it can later be +// asserted usernamelessly — is a device property no unit test can reach, which is exactly why +// the from-zero door is inert for a credential until its owner enrolls a NEW passkey against +// these options. "preferred" (not "required") is deliberate: an authenticator that cannot store +// a resident key still binds a working username-first passkey and falls back to email-OTP, so +// no one is locked out — asserting the exact string guards against a silent drop to "" (ask for +// nothing) or a tightening to "required" (which would break the no-lockout ethos). +func TestEnrollmentRequestsResidentKey(t *testing.T) { + v := newTestVerifier(t) + options, _, err := v.BeginRegistration(testUser()) + if err != nil { + t.Fatalf("BeginRegistration: %v", err) + } + var doc struct { + PublicKey struct { + AuthenticatorSelection struct { + ResidentKey string `json:"residentKey"` + } `json:"authenticatorSelection"` + } `json:"publicKey"` + } + if err := json.Unmarshal(options, &doc); err != nil { + t.Fatalf("unmarshal creation options: %v (options=%s)", err, options) + } + if got := doc.PublicKey.AuthenticatorSelection.ResidentKey; got != "preferred" { + t.Errorf("authenticatorSelection.residentKey = %q, want %q", got, "preferred") + } +} diff --git a/internal/store/migrations/0013_webauthn_discoverable_login.sql b/internal/store/migrations/0013_webauthn_discoverable_login.sql new file mode 100644 index 0000000..32c401d --- /dev/null +++ b/internal/store/migrations/0013_webauthn_discoverable_login.sql @@ -0,0 +1,36 @@ +-- Phase 6 passkey — the challenge store for DISCOVERABLE ("usernameless") login (spec §14, +-- task #40). webauthn_challenges (0007) is keyed by (user_id, purpose) because both +-- enrollment and email-first login already know WHO is authenticating before the ceremony +-- starts. A from-zero passkey login does not: the browser calls navigator.credentials.get() +-- with an EMPTY allowCredentials list, the authenticator offers a resident credential it +-- holds for this RP, and the account is revealed only inside the signed assertion at finish. +-- So this challenge cannot be keyed by user — it is keyed by an opaque, server-minted handle +-- (login_id) the browser echoes back at finish, and the marshaled WebAuthn SessionData is the +-- only server-held ceremony state. This is exactly the "non-user-keyed challenge store, a +-- future migration" that 0007's own comment anticipated. +-- +-- Bounding. webauthn_challenges self-bounds via a per-(user,purpose) supersede-DELETE on each +-- begin — one live row per user+purpose. That key does not exist here (there is no user at +-- begin), so this table is bounded two ways instead, both inside CreateDiscoverableChallenge's +-- one transaction: (1) every begin first reaps rows that already expired or were consumed by a +-- prior finish, and (2) a hard cap (maxLiveDiscoverableChallenges) refuses a new begin once the +-- live count is reached, so an abusive begin-flood is bounded to trivial storage rather than +-- growing without limit. A reap alone does NOT bound a burst — freshly inserted rows have a +-- future expiry, so N begins inside the TTL leave N live rows — which is why the cap, not the +-- reap, is the real ceiling. Volumetric per-source (client-IP) limiting is deliberately left to +-- the edge, the same stance handlers_auth_email.go documents (behind Cloudflare RemoteAddr is +-- the proxy, and CGNAT would false-positive) and unavoidable here since a usernameless door has +-- neither a principal NOR a typed recipient to key a fair per-caller limit on. +-- +-- The expires_at index serves the reap's WHERE clause; consumed_at (nullable) makes the row +-- single-use, stamped at finish and swept by a later begin's reap. +CREATE TABLE webauthn_discoverable_challenges ( + id text PRIMARY KEY, -- opaque login handle (login_id): 128-bit hex + session_data bytea NOT NULL, -- marshaled webauthn.SessionData (the challenge lives here) + expires_at timestamptz NOT NULL, + consumed_at timestamptz, -- single-use: NULL until finish stamps it + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX webauthn_discoverable_challenges_expires_idx + ON webauthn_discoverable_challenges (expires_at);