diff --git a/internal/api/errors.go b/internal/api/errors.go index bafae19..b8b9994 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -36,6 +36,12 @@ var ( // can answer 429 (back off / request a new code) rather than inviting another // guess against a code that will never accept one. ErrOTPLocked = errors.New("email code locked: too many attempts") + // ErrPasskeyChallengeInvalid means a passkey enrollment ceremony cannot be + // finished: there is no live (unconsumed, unexpired) challenge for the caller and + // purpose (Phase 6 WebAuthn bind). Like ErrOTPInvalid it is a client error — the + // finish endpoint exists; the ceremony state is gone (never begun, already + // consumed, or expired) — so handlers map it to 400, not 404. + ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired") ) // apiError is a handler-level error carrying an HTTP status and a stable, diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 5f38def..1a8dc6c 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -721,3 +721,149 @@ func (p *PGRepo) SetSetting(ctx context.Context, key string, value []byte) error key, string(value)) return err } + +// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind, migration 0007) ---- + +// CreatePasskeyChallenge supersedes any prior live challenge for (user, purpose) and +// inserts the fresh one, in one transaction (mirrors CreateEmailOTP). The supersede +// DELETE means a re-begin invalidates the earlier ceremony, so only the most recent +// challenge can ever finish — at most one outstanding challenge per (user, purpose). +// The opaque SessionData is held server-side so the client cannot forge the challenge +// it must answer at finish. +func (p *PGRepo) CreatePasskeyChallenge(ctx context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + if _, err := tx.ExecContext(ctx, + `DELETE FROM webauthn_challenges WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`, + userID, purpose); err != nil { + return fmt.Errorf("supersede prior passkey challenge: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO webauthn_challenges (id, user_id, purpose, session_data, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + id, userID, purpose, sessionData, expiresAt); err != nil { + return fmt.Errorf("insert passkey challenge: %w", err) + } + return tx.Commit() +} + +// ConsumePasskeyChallengeByUser redeems the newest live (unconsumed, unexpired at now) +// challenge for (user, purpose) in one transaction (mirrors VerifyEmailOTP). The row is +// taken FOR UPDATE so a concurrent finish cannot double-spend it; expiry is checked +// before consuming so a stale challenge is never accepted. On success consumed_at is +// stamped (single-use) and the stashed SessionData is returned. No live row → +// ErrPasskeyChallengeInvalid. +func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) ([]byte, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + var ( + id string + sessionData []byte + expiresAt time.Time + ) + switch err := tx.QueryRowContext(ctx, + `SELECT id, session_data, expires_at FROM webauthn_challenges + WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL + ORDER BY created_at DESC LIMIT 1 FOR UPDATE`, + userID, purpose).Scan(&id, &sessionData, &expiresAt); { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrPasskeyChallengeInvalid + case err != nil: + return nil, err + } + + if !expiresAt.After(now) { + return nil, ErrPasskeyChallengeInvalid + } + if _, err := tx.ExecContext(ctx, + `UPDATE webauthn_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil { + return nil, fmt.Errorf("consume passkey 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, +// so an authenticator is never silently rebound. Empty aaguid/name land as SQL NULL. +func (p *PGRepo) CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error { + res, err := p.db.ExecContext(ctx, + `INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, sign_count, aaguid, name, created_at) + VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''), $8) + ON CONFLICT (credential_id) DO NOTHING`, + c.ID, c.UserID, c.CredentialID, c.PublicKey, int64(c.SignCount), c.AAGUID, c.Name, c.CreatedAt) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrConflict + } + return nil +} + +// PasskeyCredentialsForUser lists the passkeys a user has bound, newest first, for the +// credential-management view. Nullable aaguid/name collapse to "" via COALESCE; the +// nullable last_used_at maps to a *time.Time (nil until an assertion is verified). +func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error) { + const q = `SELECT id, user_id, credential_id, public_key, sign_count, + COALESCE(aaguid, ''), COALESCE(name, ''), created_at, last_used_at + FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at DESC` + rows, err := p.db.QueryContext(ctx, q, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []PasskeyCredential + for rows.Next() { + var ( + c PasskeyCredential + signCount int64 + lastUsed sql.NullTime + ) + if err := rows.Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &signCount, + &c.AAGUID, &c.Name, &c.CreatedAt, &lastUsed); err != nil { + return nil, err + } + c.SignCount = uint32(signCount) + if lastUsed.Valid { + t := lastUsed.Time + c.LastUsedAt = &t + } + out = append(out, c) + } + return out, rows.Err() +} + +// DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller can +// only unbind their OWN credential. No matching (user, id) row → ErrNotFound via a zero +// RowsAffected, so a stale or cross-user id cannot silently no-op as success. +func (p *PGRepo) DeletePasskeyCredential(ctx context.Context, userID, id string) error { + res, err := p.db.ExecContext(ctx, + `DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/api/repo.go b/internal/api/repo.go index c91f634..b1e9122 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -89,6 +89,26 @@ type StaffUser struct { EmailVerified bool } +// PasskeyCredential is one bound passkey (Phase 6 WebAuthn enrollment). It carries +// only public, non-secret attestation material: a WebAuthn public key is meant to +// be public (unlike a session token), so it is safe at rest. CredentialID is the +// authenticator's globally-unique handle (base64url) and PublicKey the COSE key +// (base64); SignCount is the uint32 signature counter captured at registration. +// LastUsedAt is nil until an assertion is verified — the login/step-up path that +// would stamp it is out of scope for this enrollment-only slice (deferred), so it +// stays nil through the flow this type backs. +type PasskeyCredential struct { + ID string + UserID string + CredentialID string + PublicKey string + SignCount uint32 + AAGUID string + Name string + CreatedAt time.Time + LastUsedAt *time.Time +} + // SessionedUser is the projection resolved from a live session cookie: the // identity SessionAuth needs to build a Principal. It omits the password hash — // the session has already authenticated the caller — but carries the pending @@ -196,6 +216,39 @@ type Repo interface { // proven email is returned. now is the API clock so expiry is testable. VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error) + // ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind) ---- + + // CreatePasskeyChallenge persists the server-side state of a credential-creation + // ceremony for (userID, purpose): the opaque go-webauthn SessionData blob and its + // expiry. Only the server holds it, so the client cannot forge the challenge it + // must answer at finish. It supersedes any prior live (unconsumed) challenge for + // the same (userID, purpose) so a re-begin invalidates the earlier ceremony — + // at most one outstanding challenge per (user, purpose). expiresAt is the API + // clock + TTL so expiry is driven by one authoritative clock. + CreatePasskeyChallenge(ctx context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error + // ConsumePasskeyChallengeByUser redeems the newest live (unconsumed, unexpired at + // now) challenge for (userID, purpose), atomically: it stamps consumed_at and + // 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). + ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose 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 + // already bound to ANY account → ErrConflict (the UNIQUE guard); the handler maps + // that to 409 rather than silently rebinding an authenticator. + CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error + // PasskeyCredentialsForUser lists the passkeys a user has bound, newest first, for + // the credential-management view. It returns only display fields (never a secret — + // a passkey carries none); LastUsedAt is nil where no assertion has been verified. + PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error) + // DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller + // can only unbind their OWN credential. No matching (user, id) row → ErrNotFound, + // so a stale or cross-user id cannot silently no-op as success. + DeletePasskeyCredential(ctx context.Context, userID, id string) error + // ---- player game-login: username-collision reclaim (spec §B3) ---- // ReclaimUsername records a Mojang-priority username reclaim, atomically (spec diff --git a/internal/store/migrations/0007_webauthn_credentials.sql b/internal/store/migrations/0007_webauthn_credentials.sql new file mode 100644 index 0000000..158a98b --- /dev/null +++ b/internal/store/migrations/0007_webauthn_credentials.sql @@ -0,0 +1,58 @@ +-- Phase 6 passkey/WebAuthn bind (spec §14 WebAuthn, §B2 onboarding "link + email-OTP +-- + passkey"). This is the ENROLLMENT data layer only: an already-authenticated +-- principal binds a passkey to their account (the credential-creation ceremony), +-- and manages the credentials they have bound. Email-OTP remains the fallback +-- factor (migration 0004), so a player with no passkey is never locked out. +-- +-- Scope boundary (deliberate): this slice covers ENROLLMENT only. Assertion +-- verification for LOGIN — proving a passkey to mint/elevate a session from an +-- unauthenticated state — is out of scope here and deferred. The spec keeps the two +-- passkey surfaces distinct (§14: admin.* rides "Tunnel+Access,WebAuthn/posture", +-- panel.* is "app 登录"; §B2 lists passkey among the player onboarding factors), so +-- where the panel.* passkey relying-party boundary ultimately lands (felis-api vs. +-- the Access edge) is a later decision, not settled by this migration. Accordingly +-- this migration models only the authenticated enrollment ceremony: every challenge +-- is bound to a known user_id, and there is no usernameless (pre-session) login +-- lookup column. Adding a login path later would also need the felis_session +-- honoring model in session.go extended. + +-- webauthn_credentials stores one bound passkey per row. The public key and the +-- signature counter are attestation outputs captured at registration; only public, +-- non-secret material is held (a WebAuthn public key is meant to be public, unlike +-- the RCON password or a session token). credential_id is the authenticator's +-- globally-unique handle, base64url-encoded; UNIQUE guards the (astronomically +-- unlikely) cross-account collision and is the key a future login path would match. +CREATE TABLE webauthn_credentials ( + id text PRIMARY KEY, -- opaque row id (crypto-random hex) + user_id text NOT NULL REFERENCES users(id), + credential_id text NOT NULL UNIQUE, -- base64url(raw credential id) + public_key text NOT NULL, -- base64(COSE public key bytes) + sign_count bigint NOT NULL DEFAULT 0, -- uint32 widened (overflows int4) + aaguid text, -- authenticator model id, for display only + name text, -- caller-supplied nickname ("My phone") + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz -- NULL until an assertion is verified (deferred login path) +); + +-- The credential-management views list a user's bound passkeys, so index that lookup. +CREATE INDEX webauthn_credentials_user_id_idx ON webauthn_credentials (user_id); + +-- webauthn_challenges holds the server-side ceremony state between begin and finish. +-- The full go-webauthn SessionData blob (challenge, allowed credentials, expiry) is +-- stashed here and reloaded at finish, so the client never echoes — and so cannot +-- forge — the challenge it must answer (the same principle as email_otps.code_hash). +-- Every row is bound to a known user_id (NOT NULL): enrollment always rides on an +-- authenticated principal, so there is no usernameless consume-by-hash path. consumed_at +-- enforces single-use; a fresh begin supersedes the prior live row for (user, purpose). +CREATE TABLE webauthn_challenges ( + id text PRIMARY KEY, -- opaque row id (crypto-random hex) + user_id text NOT NULL REFERENCES users(id), + purpose text NOT NULL, -- 'passkey_register' + session_data bytea NOT NULL, -- opaque go-webauthn SessionData + expires_at timestamptz NOT NULL, -- short TTL set by the API clock + consumed_at timestamptz, -- non-NULL once redeemed (single-use) + created_at timestamptz NOT NULL DEFAULT now() +); + +-- The finish path consumes the newest live row for a (user, purpose), so index it. +CREATE INDEX webauthn_challenges_user_purpose_idx ON webauthn_challenges (user_id, purpose);