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
// 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,
+146
View File
@@ -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
}
+53
View File
@@ -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
@@ -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);