feat(api): add passkey enrollment persistence layer

Phase 6 WebAuthn bind, enrollment-only slice (spec section 14). Adds the data
layer an already-authenticated principal needs to bind and manage passkeys:

- migration 0007: webauthn_credentials (one bound passkey per row, public
  attestation material only) and webauthn_challenges (server-stashed ceremony
  state between begin and finish, single-use via consumed_at). Both rows are
  bound to a known user_id; there is no usernameless login lookup, since the
  assertion/login path is a deferred slice.
- PasskeyCredential type and five Repo methods (create/consume challenge,
  create/list/delete credential) with the PG semantics the handlers rely on:
  supersede-prior-live on begin, expiry-before-consume single-use on finish,
  credential_id UNIQUE -> ErrConflict, owner-scoped delete -> ErrNotFound.
- ErrPasskeyChallengeInvalid sentinel for a missing/expired/consumed ceremony.
This commit is contained in:
flyemoji committed 2026-07-01 02:14:28 +09:00
1 parent 7507cfaae4
commit f2c916d378
4 files changed
+263

No files matched your search

+6
View File
@@ -36,6 +36,12 @@ var (
// can answer 429 (back off / request a new code) rather than inviting another // can answer 429 (back off / request a new code) rather than inviting another
// guess against a code that will never accept one. // guess against a code that will never accept one.
ErrOTPLocked = errors.New("email code locked: too many attempts") 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, // apiError is a handler-level error carrying an HTTP status and a stable,
+146
View File
@@ -721,3 +721,149 @@ func (p *PGRepo) SetSetting(ctx context.Context, key string, value []byte) error
key, string(value)) key, string(value))
return err 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
}
+53
View File
@@ -89,6 +89,26 @@ type StaffUser struct {
EmailVerified bool 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 // SessionedUser is the projection resolved from a live session cookie: the
// identity SessionAuth needs to build a Principal. It omits the password hash — // identity SessionAuth needs to build a Principal. It omits the password hash —
// the session has already authenticated the caller — but carries the pending // 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. // 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) 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) ---- // ---- player game-login: username-collision reclaim (spec §B3) ----
// ReclaimUsername records a Mojang-priority username reclaim, atomically (spec // ReclaimUsername records a Mojang-priority username reclaim, atomically (spec
@@ -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);