Unverified Commit f2c916d3 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 7507cfaa
Loading
Loading
Loading
Loading
+6 −0
Changes for internal/api/errors.go: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/pgrepo.go: 146 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/repo.go: 53 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+58 −0
Changes for internal/store/migrations/0007_webauthn_credentials.sql: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- 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);