diff --git a/docs/openapi.yaml b/docs/openapi.yaml index af63a6f..b9ed352 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -167,6 +167,22 @@ components: type: string description: Correlates the response with server logs (withRequestID middleware). + PasskeyCredential: + type: object + description: > + Display projection of one bound passkey (internal/api/handlers_passkey.go + passkeyCredentialView). Carries no secret — the public key is never returned. + required: [id, name, created_at] + properties: + id: { type: string, description: Opaque passkey row id (used to unbind it). } + name: { type: string, description: Caller-supplied nickname; empty if none. } + aaguid: { type: string, description: Authenticator model id, present only when known. } + created_at: { type: string, format: date-time } + last_used_at: + type: string + format: date-time + description: Present only once an assertion is verified (deferred login path). + Phase: type: string description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1). @@ -1649,6 +1665,142 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/account/passkey/register/begin: + post: + tags: [account] + operationId: passkeyRegisterBegin + summary: Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind). + description: > + Mints a credential-creation challenge bound to the authenticated principal, + stashes the server-side ceremony state under a short TTL, and returns the + WebAuthn publicKey creation options for navigator.credentials.create(). The + challenge is never echoed by the client. Enrollment only — passkey login is a + deferred slice. 503 when the WebAuthn verifier is not configured on this instance. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: "WebAuthn credential-creation options (the publicKey document)." + content: + application/json: + schema: + type: object + description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser. + '401': + $ref: '#/components/responses/Unauthorized' + '503': + description: Passkey subsystem is not configured. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/account/passkey/register/finish: + post: + tags: [account] + operationId: passkeyRegisterFinish + summary: Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind). + description: > + Consumes the caller's live registration challenge (single-use), verifies the + authenticator's attestation against the server-stashed ceremony state, and + persists the public credential. A missing or expired ceremony is a 400; an + attestation that fails verification is a 400; a credential already bound to any + account is a 409. 503 when the WebAuthn verifier is not configured. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [attestation] + properties: + name: { type: string, description: Human nickname for the passkey (e.g. "My phone"). } + attestation: + type: object + description: The raw navigator.credentials.create() result the browser posts back. + responses: + '201': + description: Passkey bound. + content: + application/json: + schema: { $ref: '#/components/schemas/PasskeyCredential' } + '400': + description: No live ceremony, or the attestation could not be verified. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '409': + description: This passkey is already bound to an account. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + description: Passkey subsystem is not configured. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/account/passkey/credentials: + get: + tags: [account] + operationId: passkeyList + summary: List the passkeys the caller has bound (spec §14, Phase 6 bind). + description: > + Returns the authenticated principal's own bound passkeys, newest first, as + display projections (never the public key). Reading the credential list does + not need the WebAuthn verifier, so it succeeds even where begin/finish report 503. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: The caller's bound passkeys. + content: + application/json: + schema: + type: object + required: [credentials] + properties: + credentials: + type: array + items: { $ref: '#/components/schemas/PasskeyCredential' } + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/account/passkey/credentials/{id}: + delete: + tags: [account] + operationId: passkeyDelete + summary: Unbind one of the caller's passkeys (spec §14, Phase 6 bind). + description: > + Removes a passkey scoped to the authenticated principal, so a caller can only + unbind their OWN credential. An unknown or cross-user id is a 404; it never + silently no-ops as success. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string } + description: The passkey row id (from the credential list). + responses: + '204': + description: Passkey unbound. + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: No such passkey for this caller. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /api/v1/me/submissions: post: tags: [submissions] diff --git a/internal/api/api.go b/internal/api/api.go index 85dfe0c..b0a4cb3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -73,6 +73,15 @@ type API struct { // sender. The code is never returned to the client on either path. Mailer OTPMailer + // Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6 + // passkey bind). It is optional: when nil the passkey register routes report 503 + // rather than panic, so the authenticated enrollment boundary is exercised before + // the go-webauthn verifier is wired in (cmd/felis). The credential-management + // reads/deletes do not need it (they read the Repo), only the begin/finish + // ceremony. Tests inject a fake verifier so the enrollment state machine is + // exercised without real attestation crypto. + Passkey PasskeyVerifier + // RootDomain is injected from config (spec §2). It is the only place the // deployment zone enters the API; hostnames are validated against it and // never hardcoded. @@ -268,6 +277,18 @@ func (a *API) externalAPIRoutes() []apiRoute { // email is an ordinary authenticated operation, scoped to the principal. {Method: "POST", Pattern: "/api/v1/account/email/start", h: a.handleEmailOTPStart}, {Method: "POST", Pattern: "/api/v1/account/email/verify", h: a.handleEmailOTPVerify}, + // Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin + // mints a credential-creation challenge for the caller, /register/finish verifies + // the authenticator's attestation and binds the passkey, and the credentials + // collection lists and unbinds the caller's OWN passkeys. App-tier like the email + // routes — binding a passkey to your own account is an ordinary authenticated + // operation, scoped entirely to the principal (the body never names a user). This + // is enrollment only; passkey LOGIN/assertion is a deferred slice (see migration + // 0007 and handlers_passkey.go). + {Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", h: a.handlePasskeyRegisterBegin}, + {Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", h: a.handlePasskeyRegisterFinish}, + {Method: "GET", Pattern: "/api/v1/account/passkey/credentials", h: a.handlePasskeyList}, + {Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", h: a.handlePasskeyDelete}, // Modpack submission (user-directed lane over §16), user side: a user files an upload for review // and lists their own. App-tier — the submitter and the "my uploads" scope are // both taken from the principal, never the body, so an ordinary authenticated diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f70c6f7..6fe0ef6 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" + "sort" "strings" "testing" "time" @@ -61,6 +63,26 @@ type fakeRepo struct { // the same all-or-nothing contract the PG transaction enforces. blacklist map[string]bool holds map[string]fakeDataHold + // player passkey enrollment (spec §14 / Phase 6). passkeyCreds is keyed by row id + // and mirrors webauthn_credentials (the credential_id UNIQUE guard is enforced in + // CreatePasskeyCredential); passkeyChallenges is keyed by row id and mirrors + // webauthn_challenges, so the consume path scans the newest live (user, purpose) + // just as the PG query does. + passkeyCreds map[string]PasskeyCredential + passkeyChallenges map[string]*fakePasskeyChallenge +} + +// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the +// opaque stashed SessionData, single-use via consumed, and createdAt to order the +// newest-live lookup the consume path performs. +type fakePasskeyChallenge struct { + id string + userID string + purpose string + sessionData []byte + expiresAt time.Time + consumed bool + createdAt time.Time } // fakeDataHold mirrors a player_data_holds row at the granularity the verifiable @@ -127,6 +149,8 @@ func newFakeRepo() *fakeRepo { otps: map[string]*fakeEmailOTP{}, blacklist: map[string]bool{}, holds: map[string]fakeDataHold{}, + passkeyCreds: map[string]PasskeyCredential{}, + passkeyChallenges: map[string]*fakePasskeyChallenge{}, } } @@ -218,6 +242,113 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s } return live.email, nil } + +// CreatePasskeyChallenge / ConsumePasskeyChallengeByUser mirror PGRepo's contract so +// the hermetic tests exercise the same semantics: a fresh begin supersedes the prior +// live challenge for (user, purpose), and the consume path redeems the newest live one +// (expiry checked before consuming), single-use. +func (f *fakeRepo) CreatePasskeyChallenge(_ context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error { + for k, c := range f.passkeyChallenges { // supersede prior live (DELETE ... consumed_at IS NULL) + if c.userID == userID && c.purpose == purpose && !c.consumed { + delete(f.passkeyChallenges, k) + } + } + f.passkeyChallenges[id] = &fakePasskeyChallenge{ + id: id, userID: userID, purpose: purpose, sessionData: sessionData, + expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation + } + return nil +} +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) + if c.userID != userID || c.purpose != purpose || c.consumed { + continue + } + if live == nil || c.createdAt.After(live.createdAt) { + live = c + } + } + if live == nil || !live.expiresAt.After(now) { + return nil, ErrPasskeyChallengeInvalid + } + live.consumed = true + return live.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 { + for _, ex := range f.passkeyCreds { + if ex.CredentialID == c.CredentialID { + return ErrConflict + } + } + f.passkeyCreds[c.ID] = c + return nil +} + +// PasskeyCredentialsForUser mirrors PGRepo: the user's own passkeys, newest first. +// CreatedAt orders the list; id is a deterministic tie-break for the frozen test clock +// (the PG ORDER BY is created_at DESC; same-instant rows are simply stable here). +func (f *fakeRepo) PasskeyCredentialsForUser(_ context.Context, userID string) ([]PasskeyCredential, error) { + var out []PasskeyCredential + for _, c := range f.passkeyCreds { + if c.UserID == userID { + out = append(out, c) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].ID > out[j].ID + } + return out[i].CreatedAt.After(out[j].CreatedAt) + }) + return out, nil +} + +// DeletePasskeyCredential mirrors PGRepo: scoped to userID so a caller can only unbind +// their OWN credential; no matching (user, id) row → ErrNotFound. +func (f *fakeRepo) DeletePasskeyCredential(_ context.Context, userID, id string) error { + if c, ok := f.passkeyCreds[id]; ok && c.UserID == userID { + delete(f.passkeyCreds, id) + return nil + } + return ErrNotFound +} + +// fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation +// crypto, so it exercises the enrollment STATE MACHINE (challenge persistence, consume, +// conflict, audit) without go-webauthn. BeginRegistration returns a fixed options blob +// and an opaque session marker; FinishRegistration returns the credential the test +// preloaded, or a forced error when failErr is set (to drive the 400 path). +type fakePasskeyVerifier struct { + options json.RawMessage + credential VerifiedCredential + failErr error + // lastUser/lastSession capture what the handler passed, so a test can assert the + // stashed SessionData round-trips and the existing credentials reach the verifier. + lastUser PasskeyUser + lastSession []byte +} + +func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) { + v.lastUser = user + opts := v.options + if opts == nil { + opts = json.RawMessage(`{"publicKey":{"challenge":"ZmFrZQ"}}`) + } + return opts, []byte("session:" + user.ID), nil +} + +func (v *fakePasskeyVerifier) FinishRegistration(user PasskeyUser, sessionData []byte, _ io.Reader) (VerifiedCredential, error) { + v.lastUser = user + v.lastSession = sessionData + if v.failErr != nil { + return VerifiedCredential{}, v.failErr + } + return v.credential, nil +} func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) { return f.allowlist[n][u], nil } diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go new file mode 100644 index 0000000..6fc75c4 --- /dev/null +++ b/internal/api/handlers_passkey.go @@ -0,0 +1,278 @@ +package api + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "time" +) + +// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind). An already-authenticated +// principal binds a passkey to their account — the WebAuthn credential-creation +// ceremony — and manages the credentials they have bound. Email-OTP (handlers_email_otp.go) +// stays the fallback factor, so a player with no passkey is never locked out. +// +// Scope: ENROLLMENT only. The login/assertion path (proving a passkey to mint or +// elevate a session from an unauthenticated state) is a deferred slice — the panel.* +// passkey relying-party boundary is a later decision (see migration 0007). So every +// ceremony here rides on a known principal: the challenge is bound to the caller's +// user_id and the finish verifies against the server-stashed SessionData, never a +// client-echoed challenge. +// +// The cryptographic half is a seam (PasskeyVerifier) so this package never imports +// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation +// as an io.Reader, and the verified result as a plain VerifiedCredential. Production +// wires the real go-webauthn verifier (cmd/felis); a nil verifier makes the begin and +// finish routes report 503 (the authenticated enrollment boundary is still exercised), +// and tests inject a fake so the state machine runs without real attestation crypto. + +const ( + // passkeyChallengeTTL bounds how long a freshly minted credential-creation + // challenge is accepted. The ceremony is interactive (a user taps an + // authenticator), so a few minutes is ample; a shorter window shrinks the gap in + // which a stashed challenge is live. + passkeyChallengeTTL = 5 * time.Minute + // passkeyPurposeRegister scopes a challenge to the enrollment (credential-creation) + // flow. The purpose column exists so a later assertion/login flow can mint + // challenges that never collide with an enrollment challenge for the same user. + passkeyPurposeRegister = "passkey_register" +) + +// PasskeyVerifier performs the cryptographic half of a WebAuthn credential-creation +// ceremony. It is a seam so the api package stays free of go-webauthn types: the real +// implementation (cmd/felis) wraps github.com/go-webauthn/webauthn, while tests inject +// a fake. All ceremony state crosses the seam as opaque bytes — the marshaled +// SessionData the server stashes between begin and finish — so the handler persists it +// without understanding it. +type PasskeyVerifier interface { + // BeginRegistration starts a credential-creation ceremony for user. It returns the + // publicKey creation options to hand to the browser's navigator.credentials.create() + // AND the opaque sessionData the server must stash and replay at finish. + // user.Credentials carries the passkeys already bound so the authenticator can be + // told to exclude them (no double-binding one device). + BeginRegistration(user PasskeyUser) (options json.RawMessage, sessionData []byte, err error) + // FinishRegistration verifies the authenticator's attestation response against the + // stashed sessionData and returns the credential to persist. attestation is the raw + // navigator.credentials.create() result the browser posts back; sessionData is the + // blob BeginRegistration returned. A failed verification returns a non-nil error; + // the handler maps it to 400 (the ceremony state exists; the attestation is bad). + FinishRegistration(user PasskeyUser, sessionData []byte, attestation io.Reader) (VerifiedCredential, error) +} + +// PasskeyUser is the relying-party view of the enrolling principal the verifier needs: +// a stable user handle (ID), the names an authenticator shows the human, and the +// passkeys already bound (so the ceremony can exclude them). It is a plain value so the +// api package stays free of go-webauthn types; the real verifier adapts it to a +// webauthn.User. +type PasskeyUser struct { + ID string + Name string + DisplayName string + Credentials []PasskeyCredential +} + +// VerifiedCredential is the public, persist-ready output of a finished registration +// ceremony — the material CreatePasskeyCredential stores. It carries no secret: a +// WebAuthn public key is public by design, so it is safe at rest. +type VerifiedCredential struct { + CredentialID string // base64url(raw credential id) + PublicKey string // base64(COSE public key bytes) + SignCount uint32 + AAGUID string +} + +// errPasskeyUnavailable is returned when the WebAuthn verifier is not configured on +// this api instance, so the begin/finish ceremony routes answer 503 rather than panic. +var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable", + "passkey subsystem is not configured") + +// newPasskeyID returns an opaque random row id (128 bits, hex) for a passkey row. +func newPasskeyID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// passkeyUserFor builds the relying-party view of a principal for the verifier. The +// human-facing names fall back to the user id when no email is bound yet (a player +// mid-onboarding), so the authenticator always shows a stable, non-empty label. +func passkeyUserFor(p *Principal, creds []PasskeyCredential) PasskeyUser { + label := auditActor(p) + return PasskeyUser{ID: p.UserID, Name: label, DisplayName: label, Credentials: creds} +} + +// 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 +// creation options + opaque SessionData, stashes the SessionData under a short TTL, and +// returns the options verbatim for navigator.credentials.create(). The challenge never +// leaves the server in a forgeable form — only the publicKey options the browser needs. +func (a *API) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) { + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + p := principalFromContext(r.Context()) + creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) + if err != nil { + writeError(w, r, err) + return + } + options, sessionData, err := a.Passkey.BeginRegistration(passkeyUserFor(p, creds)) + if err != nil { + writeError(w, r, 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, p.UserID, passkeyPurposeRegister, sessionData, expiresAt); err != nil { + writeError(w, r, err) + return + } + // The creation options are the WebAuthn {"publicKey": {...}} document the browser + // passes straight to navigator.credentials.create(); return them verbatim. + writeJSON(w, http.StatusOK, options) +} + +// passkeyFinishRequest is the finish body: the human nickname for the new passkey and +// the raw navigator.credentials.create() attestation response. Attestation is captured +// as RawMessage so the handler hands the exact bytes the browser produced to the +// verifier without re-encoding (a re-marshal could perturb the signed payload). +type passkeyFinishRequest struct { + Name string `json:"name"` + Attestation json.RawMessage `json:"attestation"` +} + +// handlePasskeyRegisterFinish verifies an attestation and binds the passkey (spec §14, +// external app face). It atomically consumes the caller's live challenge (a missing or +// expired one → 400, single-use), verifies the attestation against the stashed +// SessionData, and persists the public credential. A credential_id already bound to any +// account → 409 (the UNIQUE guard); the handler never silently rebinds an authenticator. +func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) { + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + p := principalFromContext(r.Context()) + var req passkeyFinishRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + if len(req.Attestation) == 0 { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "attestation is required")) + return + } + sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeRegister, a.now()) + if err != nil { + if errors.Is(err, ErrPasskeyChallengeInvalid) { + writeError(w, r, newError(http.StatusBadRequest, "passkey_challenge_invalid", + "no live passkey registration in progress; begin again")) + return + } + writeError(w, r, err) + return + } + vc, err := a.Passkey.FinishRegistration(passkeyUserFor(p, nil), sessionData, bytes.NewReader(req.Attestation)) + if err != nil { + writeError(w, r, newError(http.StatusBadRequest, "invalid_attestation", + "passkey attestation could not be verified")) + return + } + id, err := newPasskeyID() + if err != nil { + writeError(w, r, err) + return + } + cred := PasskeyCredential{ + ID: id, + UserID: p.UserID, + CredentialID: vc.CredentialID, + PublicKey: vc.PublicKey, + SignCount: vc.SignCount, + AAGUID: vc.AAGUID, + Name: req.Name, + CreatedAt: a.now(), + } + if err := a.Repo.CreatePasskeyCredential(r.Context(), cred); err != nil { + if errors.Is(err, ErrConflict) { + writeError(w, r, newError(http.StatusConflict, "passkey_already_bound", + "this passkey is already bound to an account")) + return + } + writeError(w, r, err) + return + } + a.audit(r, auditActor(p), "account.passkey.registered", "") + writeJSON(w, http.StatusCreated, passkeyView(cred)) +} + +// passkeyCredentialView is the display projection of a bound passkey: never the public +// key (the client has no use for it), only what the credential-management UI renders. +type passkeyCredentialView struct { + ID string `json:"id"` + Name string `json:"name"` + AAGUID string `json:"aaguid,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +// passkeyView maps a stored credential to its display projection. +func passkeyView(c PasskeyCredential) passkeyCredentialView { + return passkeyCredentialView{ + ID: c.ID, + Name: c.Name, + AAGUID: c.AAGUID, + CreatedAt: c.CreatedAt.UTC(), + LastUsedAt: c.LastUsedAt, + } +} + +// handlePasskeyList returns the passkeys the caller has bound (spec §14, external app +// face), newest first, for the credential-management view. It reads only the +// principal's own credentials and returns display fields only (never a secret). +func (a *API) handlePasskeyList(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) + if err != nil { + writeError(w, r, err) + return + } + views := make([]passkeyCredentialView, 0, len(creds)) + for _, c := range creds { + views = append(views, passkeyView(c)) + } + writeJSON(w, http.StatusOK, map[string]any{"credentials": views}) +} + +// handlePasskeyDelete unbinds one of the caller's passkeys (spec §14, external app +// face). The delete is scoped to the principal, so a caller can only remove their OWN +// credential; an unknown or cross-user id → 404 (it never silently no-ops as success). +func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + id := r.PathValue("id") + if id == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "credential id is required")) + return + } + if err := a.Repo.DeletePasskeyCredential(r.Context(), p.UserID, id); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey")) + return + } + writeError(w, r, err) + return + } + a.audit(r, auditActor(p), "account.passkey.removed", id) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers_passkey_test.go b/internal/api/handlers_passkey_test.go new file mode 100644 index 0000000..35a95c2 --- /dev/null +++ b/internal/api/handlers_passkey_test.go @@ -0,0 +1,344 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "testing" + "time" +) + +// Passkey enrollment handler tests (spec §14 / Phase 6 bind), enrollment-only slice. +// They drive the four account routes against the fakeRepo state machine and a fake +// PasskeyVerifier — no real attestation crypto, no SQL — so what these PROVE is the +// handler + challenge state machine, not the pgrepo SQL (mirrored, not run here) nor +// the cryptographic verification (Slice 1). + +// frozenNow is the test clock newTestAPI installs; a live challenge expires at +// frozenNow+passkeyChallengeTTL, an expired one strictly before frozenNow. +var frozenNow = time.Unix(1_700_000_000, 0) + +// newPasskeyAPI wires an external face with a fake verifier and a fixed principal. +func newPasskeyAPI(repo *fakeRepo, v PasskeyVerifier, p *Principal) http.Handler { + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: p} + api.Passkey = v + return api.ExternalHandler() +} + +// plantPasskeyChallenge seeds a stashed registration challenge for u1 directly, so the +// finish-side branches (expired, conflict) are reachable under the frozen clock without +// running begin first. +func plantPasskeyChallenge(repo *fakeRepo, id string, expiresAt time.Time, session []byte) { + repo.passkeyChallenges[id] = &fakePasskeyChallenge{ + id: id, userID: "u1", purpose: passkeyPurposeRegister, + sessionData: session, expiresAt: expiresAt, createdAt: expiresAt, + } +} + +// TestPasskeyRegisterVertical walks the whole enrollment slice across the external +// face: begin mints + stashes a challenge, finish verifies the attestation against the +// SERVER-STASHED session data and binds the credential, list shows it, delete unbinds +// it. The decisive assertion is the session-data round-trip: the finish body carries +// only name+attestation, so the only path for the stashed blob into FinishRegistration +// is store-stash → consume — proving the challenge is never client-echoed. +func TestPasskeyRegisterVertical(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + v := &fakePasskeyVerifier{ + options: json.RawMessage(`{"publicKey":{"challenge":"Y2hhbGxlbmdl"}}`), + credential: VerifiedCredential{ + CredentialID: "cred-abc", PublicKey: "SECRET_COSE_KEY", SignCount: 0, AAGUID: "aaguid-1", + }, + } + eh := newPasskeyAPI(repo, v, user) + + // 1) begin returns the verifier's creation options verbatim and stashes exactly one + // challenge bound to the caller. + w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil) + if w.Code != http.StatusOK { + t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if b := acctBody(t, w); b["publicKey"] == nil { + t.Errorf("begin must return the publicKey creation options verbatim, got %s", w.Body.String()) + } + if len(repo.passkeyChallenges) != 1 { + t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges)) + } + if string(v.lastUser.ID) != "u1" { + t.Errorf("begin passed user id %q, want u1", v.lastUser.ID) + } + + // 2) finish binds the credential. The finish body carries NO challenge — only the + // nickname and attestation. + body := `{"name":"My YubiKey","attestation":{"id":"abc","type":"public-key"}}` + w = do(eh, "POST", "/api/v1/account/passkey/register/finish", body, nil) + if w.Code != http.StatusCreated { + t.Fatalf("finish: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + // THE security assertion: the blob the verifier saw at finish is exactly what begin + // stashed — it travelled store-stash → consume, never the client. + if !bytes.Equal(v.lastSession, []byte("session:u1")) { + t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)", + v.lastSession, "session:u1") + } + // The view never leaks the public key. + if bytes.Contains(w.Body.Bytes(), []byte("SECRET_COSE_KEY")) { + t.Error("finish response leaked the credential public key") + } + fb := acctBody(t, w) + if fb["name"] != "My YubiKey" { + t.Errorf("finish view name = %v, want \"My YubiKey\"", fb["name"]) + } + if len(repo.passkeyCreds) != 1 { + t.Fatalf("finish must persist exactly one credential, got %d", len(repo.passkeyCreds)) + } + + // 3) the challenge is single-use: a second finish (no new begin) → 400. + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", body, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_challenge_invalid" { + t.Fatalf("replayed finish: code = %d body %s, want 400 passkey_challenge_invalid", w.Code, w.Body.String()) + } + + // 4) list shows the bound credential (display fields only, never the public key). + w = do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("list: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if bytes.Contains(w.Body.Bytes(), []byte("SECRET_COSE_KEY")) { + t.Error("list response leaked the credential public key") + } + creds, _ := acctBody(t, w)["credentials"].([]any) + if len(creds) != 1 { + t.Fatalf("list returned %d credentials, want 1 (%s)", len(creds), w.Body.String()) + } + id, _ := creds[0].(map[string]any)["id"].(string) + if id == "" { + t.Fatalf("listed credential has no id: %s", w.Body.String()) + } + + // 5) delete unbinds it (204) and the row is gone. + if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/"+id, "", nil); w.Code != http.StatusNoContent { + t.Fatalf("delete: code = %d, want 204 (%s)", w.Code, w.Body.String()) + } + if len(repo.passkeyCreds) != 0 { + t.Fatalf("delete left %d credentials, want 0", len(repo.passkeyCreds)) + } + + // Both mutating halves audit by the caller's Access email. + var registered, removed bool + for _, a := range repo.audits { + switch a.Action { + case "account.passkey.registered": + registered = a.Actor == "u1@example.net" + case "account.passkey.removed": + removed = a.Actor == "u1@example.net" + } + } + if !registered || !removed { + t.Errorf("want registered+removed audits by u1@example.net, got %+v", repo.audits) + } +} + +// TestPasskeyBeginPassesExistingCredentials proves excludeCredentials is wired +// end-to-end: a caller who already has a passkey hands that credential to the verifier +// at begin, so the authenticator can refuse to double-bind one device. The hook is +// PasskeyCredentialsForUser → passkeyUserFor → BeginRegistration. +func TestPasskeyBeginPassesExistingCredentials(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + repo.passkeyCreds["row1"] = PasskeyCredential{ + ID: "row1", UserID: "u1", CredentialID: "existing-cred", PublicKey: "k", CreatedAt: frozenNow, + } + v := &fakePasskeyVerifier{} + eh := newPasskeyAPI(repo, v, user) + + if w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil); w.Code != http.StatusOK { + t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if len(v.lastUser.Credentials) != 1 || v.lastUser.Credentials[0].CredentialID != "existing-cred" { + t.Fatalf("begin must hand the caller's existing credentials to the verifier, got %+v", v.lastUser.Credentials) + } +} + +// TestPasskeyBeginSupersedes proves a fresh begin invalidates the prior in-flight +// challenge for the same user: two begins leave exactly one stashed challenge, so an +// abandoned ceremony cannot be finished after the user restarts. +func TestPasskeyBeginSupersedes(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + + do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil) + do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil) + if len(repo.passkeyChallenges) != 1 { + t.Fatalf("a second begin must supersede the first; stashed challenges = %d, want 1", len(repo.passkeyChallenges)) + } +} + +// TestPasskeyUnavailable pins the graceful-degradation path: when no verifier is wired +// the ceremony routes answer 503 (not a panic). It is NOT an auth assertion — auth runs +// in middleware upstream of these handlers regardless. List/delete need no verifier, so +// they keep working. +func TestPasskeyUnavailable(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + eh := newPasskeyAPI(repo, nil, user) // nil verifier + + if w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" { + t.Fatalf("begin with no verifier: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String()) + } + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", `{"name":"x","attestation":{"a":1}}`, nil); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" { + t.Fatalf("finish with no verifier: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String()) + } + // list still works without a verifier (it touches only the store). + if w := do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil); w.Code != http.StatusOK { + t.Errorf("list with no verifier: code = %d, want 200 (it needs no verifier)", w.Code) + } +} + +// TestPasskeyFinishRejections is the finish-side failure matrix. The expired and +// conflict cases plant state directly: the test clock is frozen, so a pre-expired +// challenge or a pre-bound credential is the only way to reach those branches. +func TestPasskeyFinishRejections(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + const goodBody = `{"name":"k","attestation":{"id":"abc"}}` + + t.Run("missing attestation -> 400 bad_request", func(t *testing.T) { + repo := newFakeRepo() + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + plantPasskeyChallenge(repo, "c1", frozenNow.Add(passkeyChallengeTTL), []byte("s")) + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", `{"name":"k"}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" { + t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String()) + } + }) + + t.Run("unknown field -> 400 (strict decode)", func(t *testing.T) { + repo := newFakeRepo() + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", `{"name":"k","attestation":{"a":1},"x":1}`, nil); w.Code != http.StatusBadRequest { + t.Fatalf("strict decode must reject unknown field, code = %d", w.Code) + } + }) + + t.Run("no live challenge -> 400 passkey_challenge_invalid", func(t *testing.T) { + repo := newFakeRepo() + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_challenge_invalid" { + t.Fatalf("code = %d body %s, want 400 passkey_challenge_invalid", w.Code, w.Body.String()) + } + }) + + t.Run("expired challenge -> 400 passkey_challenge_invalid, not consumed", func(t *testing.T) { + repo := newFakeRepo() + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + plantPasskeyChallenge(repo, "ex", frozenNow.Add(-time.Second), []byte("s")) + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_challenge_invalid" { + t.Fatalf("code = %d body %s, want 400 passkey_challenge_invalid", w.Code, w.Body.String()) + } + if repo.passkeyChallenges["ex"].consumed { + t.Error("an expired challenge must not be consumed") + } + }) + + t.Run("attestation fails verification -> 400 invalid_attestation", func(t *testing.T) { + repo := newFakeRepo() + v := &fakePasskeyVerifier{failErr: errors.New("bad signature")} + eh := newPasskeyAPI(repo, v, user) + plantPasskeyChallenge(repo, "c1", frozenNow.Add(passkeyChallengeTTL), []byte("s")) + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_attestation" { + t.Fatalf("code = %d body %s, want 400 invalid_attestation", w.Code, w.Body.String()) + } + // A bad attestation still consumes the challenge (single-use): the consume is + // committed before verification, so a re-finish finds nothing live. + if !repo.passkeyChallenges["c1"].consumed { + t.Error("a consumed challenge must stay consumed even when verification fails") + } + }) + + t.Run("credential already bound -> 409 passkey_already_bound", func(t *testing.T) { + repo := newFakeRepo() + // Some other account already holds this credential id. + repo.passkeyCreds["other"] = PasskeyCredential{ID: "other", UserID: "u2", CredentialID: "dup-cred", CreatedAt: frozenNow} + v := &fakePasskeyVerifier{credential: VerifiedCredential{CredentialID: "dup-cred", PublicKey: "k"}} + eh := newPasskeyAPI(repo, v, user) + plantPasskeyChallenge(repo, "c1", frozenNow.Add(passkeyChallengeTTL), []byte("s")) + if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusConflict || decodeErr(t, w) != "passkey_already_bound" { + t.Fatalf("code = %d body %s, want 409 passkey_already_bound", w.Code, w.Body.String()) + } + }) +} + +// TestPasskeyDeleteScoping proves a caller can only unbind their OWN passkey: u2's +// credential is invisible to u1's list and u1's delete of it 404s (never a silent +// success that would let one account strip another's factor). +func TestPasskeyDeleteScoping(t *testing.T) { + u1 := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + repo.passkeyCreds["row2"] = PasskeyCredential{ID: "row2", UserID: "u2", CredentialID: "u2-cred", CreatedAt: frozenNow} + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, u1) + + // u1's list does not show u2's credential. + w := do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil) + if creds, _ := acctBody(t, w)["credentials"].([]any); len(creds) != 0 { + t.Fatalf("u1 list shows %d credentials, want 0 (u2's must be invisible)", len(creds)) + } + // u1 cannot delete u2's credential. + if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/row2", "", nil); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" { + t.Fatalf("cross-user delete: code = %d body %s, want 404 not_found", w.Code, w.Body.String()) + } + if _, ok := repo.passkeyCreds["row2"]; !ok { + t.Error("u2's credential must survive u1's failed delete") + } +} + +// TestPasskeyDeleteUnknown pins the unknown-id path: deleting an id that does not exist +// is a 404, never a silent 204. +func TestPasskeyDeleteUnknown(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + eh := newPasskeyAPI(newFakeRepo(), &fakePasskeyVerifier{}, user) + if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/nope", "", nil); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" { + t.Fatalf("delete unknown: code = %d body %s, want 404 not_found", w.Code, w.Body.String()) + } +} + +// TestPasskeyListNewestFirst proves the management view orders newest-first, matching +// the pgrepo ORDER BY created_at DESC the fake mirrors. +func TestPasskeyListNewestFirst(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + repo.passkeyCreds["old"] = PasskeyCredential{ID: "old", UserID: "u1", CredentialID: "c-old", Name: "old", CreatedAt: frozenNow.Add(-time.Hour)} + repo.passkeyCreds["new"] = PasskeyCredential{ID: "new", UserID: "u1", CredentialID: "c-new", Name: "new", CreatedAt: frozenNow} + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + + w := do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil) + creds, _ := acctBody(t, w)["credentials"].([]any) + if len(creds) != 2 { + t.Fatalf("list returned %d, want 2 (%s)", len(creds), w.Body.String()) + } + if first, _ := creds[0].(map[string]any)["name"].(string); first != "new" { + t.Errorf("list order: first = %q, want \"new\" (newest first)", first) + } +} + +// TestPasskeyFaceSeparation enforces that the enrollment routes are web-only: they +// require a logged-in principal the internal (service-token) face never carries, so +// crossing the face boundary must 404 rather than silently work. +func TestPasskeyFaceSeparation(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.External = staticExternal{p: user} + api.Passkey = &fakePasskeyVerifier{} + ih := api.InternalHandler() + + for _, tc := range []struct{ method, path, body string }{ + {"POST", "/api/v1/account/passkey/register/begin", `{}`}, + {"POST", "/api/v1/account/passkey/register/finish", `{"name":"k","attestation":{"a":1}}`}, + {"GET", "/api/v1/account/passkey/credentials", ""}, + {"DELETE", "/api/v1/account/passkey/credentials/x", ""}, + } { + if w := do(ih, tc.method, tc.path, tc.body, nil); w.Code != http.StatusNotFound { + t.Errorf("%s %s on internal face: code = %d, want 404", tc.method, tc.path, w.Code) + } + } +}