passkey

package
v0.8.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: AGPL-3.0 Imports: 21 Imported by: 0

Documentation

Overview

Package passkey implements WebAuthn / FIDO2 passkey registration and login (Epic 59) plus the one-time-use recovery-code fallback for passkey-only users. It wraps github.com/go-webauthn/webauthn for ceremony verification.

Security posture:

  • user_passkeys holds ONLY public material (credential id, public key, sign count, transports). The private key never leaves the authenticator; a compromise of this table leaks no secret material.
  • Challenges are crypto-random, single-use (deleted on consume), short-TTL (5 min), and bound to the user (registration) or discovered user (login).
  • Recovery codes are bcrypt-hashed (cost 12) shared secrets — the one phishable factor in an otherwise phishing-resistant system, accepted per the design's recovery tradeoff.

DEK integration: a passkey-only user has no password, so their DEK is wrapped by the master-KEK provider with dek_source='passkey' (Epic 58 machinery).

Index

Constants

View Source
const ChallengeTTL = 5 * time.Minute

ChallengeTTL is how long a WebAuthn challenge is valid. 5 minutes is the consumer standard — long enough for a user to interact with the authenticator prompt, short enough that a leaked challenge is useless quickly.

View Source
const RecoveryCodeCount = 10

RecoveryCodeCount is the number of recovery codes generated at enrollment.

View Source
const RecoveryCodeLen = 20

RecoveryCodeLen is the character length of each recovery code (before any formatting). 20 random characters from an unambiguous alphabet.

Variables

View Source
var (
	ErrCredentialNotFound   = storeErr("passkey credential not found")
	ErrLastCredential       = storeErr("cannot delete the last remaining passkey")
	ErrRecoveryCodeNotFound = storeErr("recovery code not found or already used")
	ErrUserNotFound         = storeErr("user not found")
	ErrNoPasskeyRegistered  = storeErr("user has no registered passkeys")
	ErrChallengeExpired     = storeErr("passkey challenge expired or not found")
)

Sentinel errors. Plain errors.New (not StatusError) because these are internal to the service layer; the HTTP handlers map them to appropriate status codes.

Functions

This section is empty.

Types

type BeginLoginOptions

type BeginLoginOptions struct {
	Options      map[string]any `json:"options"`
	SessionToken string         `json:"sessionToken"`
}

BeginLoginOptions is returned to the browser. Options is the WebAuthn CredentialAssertion dict fed to navigator.credentials.get().

type BeginRegistrationOptions

type BeginRegistrationOptions struct {
	Options      map[string]any `json:"options"`
	SessionToken string         `json:"sessionToken"`
}

BeginRegistrationOptions is returned to the browser. Options is the WebAuthn CredentialCreation dict fed to navigator.credentials.create(). SessionToken is the opaque token the browser sends back at /finish.

type CacheSessionStore

type CacheSessionStore struct {
	// contains filtered or unexported fields
}

CacheSessionStore implements SessionStore against Redis using the raw *redis.Client for both Save (SET) and Consume (GETDEL — atomic read+delete). This avoids the CacheService interface's lack of GETDEL, and closes the concurrent-replay window that separate GET+DEL would leave open.

func NewCacheSessionStore

func NewCacheSessionStore(client *redis.Client) *CacheSessionStore

NewCacheSessionStore constructs a Redis-backed session store. The client is obtained from cache.Service.GetClient() in production wiring.

func (*CacheSessionStore) ConsumeChallenge

func (s *CacheSessionStore) ConsumeChallenge(ctx context.Context, token string) ([]byte, error)

func (*CacheSessionStore) SaveChallenge

func (s *CacheSessionStore) SaveChallenge(ctx context.Context, token string, data []byte, ttl time.Duration) error

type Credential

type Credential struct {
	ID                uuid.UUID
	UserID            string
	CredentialID      []byte
	PublicKey         []byte
	AttestationType   string
	AttestationFormat string
	AAGUID            *uuid.UUID
	SignCount         uint32
	Transports        []string
	Name              string
	CreatedAt         time.Time
	LastUsedAt        *time.Time
}

Credential is the stored WebAuthn credential record (a user_passkeys row).

func (*Credential) ToDTO

func (c *Credential) ToDTO() types.PasskeyCredential

ToDTO converts a stored credential to its API transfer object (public fields only; CredentialID/PublicKey are not exposed over the API).

type FinishRegistrationResult

type FinishRegistrationResult struct {
	Credential         Credential
	RecoveryCodes      []string
	RecoveryCodeHashes []string
}

FinishRegistrationResult holds the verified credential + recovery codes generated at enrollment (one-time display). Neither is persisted by the service — the CALLER (HTTP handler) must create the user row FIRST (so the FK constraint on user_passkeys.user_id is satisfied), then atomically persist both via Store.CreateCredentialAndRecoveryCodes.

type Logger

type Logger interface {
	Warn(msg string, args ...any)
}

Logger is a minimal logger interface for non-fatal warnings (sign-count update failures, etc.). Implementations: *logger.Logger in production, nil in tests.

type PgStore

type PgStore struct {
	// contains filtered or unexported fields
}

PgStore implements Store against PostgreSQL. It is a thin wrapper; the queries are straightforward SELECT/INSERT/UPDATE over user_passkeys and user_recovery_codes (migrations 000009/000010).

func NewPgStore

func NewPgStore(pool *pgxpool.Pool) *PgStore

NewPgStore constructs a Postgres-backed passkey store.

func (*PgStore) ConsumeRecoveryCode

func (s *PgStore) ConsumeRecoveryCode(ctx context.Context, userID, codeHash string) error

func (*PgStore) CountCredentials

func (s *PgStore) CountCredentials(ctx context.Context, userID string) (int, error)

func (*PgStore) CreateCredential

func (s *PgStore) CreateCredential(ctx context.Context, c *Credential) error

func (*PgStore) CreateCredentialAndRecoveryCodes

func (s *PgStore) CreateCredentialAndRecoveryCodes(ctx context.Context, cred *Credential, hashes []string) error

CreateCredentialAndRecoveryCodes atomically persists the credential AND the recovery-code hashes in a single transaction. Partial failure rolls back both — a passkey-only user always gets either (credential + recovery codes) or neither.

func (*PgStore) CreateRecoveryCodes

func (s *PgStore) CreateRecoveryCodes(ctx context.Context, userID string, hashes []string) error

func (*PgStore) DeleteCredential

func (s *PgStore) DeleteCredential(ctx context.Context, userID string, id uuid.UUID) error

func (*PgStore) GetCredentialByCredentialID

func (s *PgStore) GetCredentialByCredentialID(ctx context.Context, credentialID []byte) (*Credential, error)

func (*PgStore) ListAvailableRecoveryCodes

func (s *PgStore) ListAvailableRecoveryCodes(ctx context.Context, userID string) ([]RecoveryCode, error)

func (*PgStore) ListCredentials

func (s *PgStore) ListCredentials(ctx context.Context, userID string) ([]Credential, error)

func (*PgStore) UpdateCredentialAfterLogin

func (s *PgStore) UpdateCredentialAfterLogin(ctx context.Context, id uuid.UUID, signCount uint32, lastUsedAt time.Time) error

type RecoveryCode

type RecoveryCode struct {
	ID        uuid.UUID
	UserID    string
	CodeHash  string
	UsedAt    *time.Time
	CreatedAt time.Time
}

RecoveryCode is a stored (hashed) recovery code row. The plaintext is shown to the user exactly once at enrollment; only code_hash persists.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service implements the WebAuthn registration and login ceremonies, wrapping go-webauthn for crypto verification. The private key never leaves the authenticator; this service only verifies public material (attestation at registration, assertion at login) and persists the resulting credential.

func New

func New(cfg ServiceConfig) (*Service, error)

New constructs the ceremony service. Returns an error when the WebAuthn RP config is invalid (empty RPID, no origins) so the caller can fail at boot rather than at first request.

func (*Service) AddCredential

func (s *Service) AddCredential(ctx context.Context, cred *Credential) error

AddCredential persists a credential for an already-authenticated user (the "Add passkey" flow from settings). Does NOT generate recovery codes.

func (*Service) BeginLogin

func (s *Service) BeginLogin(ctx context.Context, email string) (*BeginLoginOptions, string, error)

BeginLogin starts a passkey assertion for a user identified by email. The user must have at least one registered passkey. Generates a challenge scoped to the user's allowed credentials.

func (*Service) BeginRegistration

func (s *Service) BeginRegistration(ctx context.Context, userID, username string) (*BeginRegistrationOptions, error)

BeginRegistration starts a passkey enrollment for a user who has no passkeys yet. Generates a challenge, persists it (single-use, TTL-bound), and returns the WebAuthn options + session token.

func (*Service) ConsumeRecoveryCode

func (s *Service) ConsumeRecoveryCode(ctx context.Context, email, code string) (string, error)

ConsumeRecoveryCode validates a recovery code against the stored hashes. Returns the user ID on success. A matched code is marked used (single-use). Constant-time comparison is via bcrypt — the same protection as password verification. The caller forces the user to enroll a new passkey after a recovery-code login.

func (*Service) CreateCredentialAndRecoveryCodes

func (s *Service) CreateCredentialAndRecoveryCodes(ctx context.Context, cred *Credential, hashes []string) error

CreateCredentialAndRecoveryCodes atomically persists a credential AND its recovery-code hashes via the Store. The handler calls this after creating the user row, so the FK constraint is satisfied. Exposed on the service so the handler doesn't reach into the Store directly.

func (*Service) DeleteUserCredential

func (s *Service) DeleteUserCredential(ctx context.Context, userID string, credID uuid.UUID) error

DeleteUserCredential removes a passkey. Refuses the last remaining one.

func (*Service) FinishLogin

func (s *Service) FinishLogin(ctx context.Context, sessionToken, email string, response map[string]any) (string, error)

FinishLogin verifies the authenticator's assertion against the stored challenge and returns the user ID. The caller (HTTP handler) issues the session token + unlocks the DEK. Updates the sign count (cloned- authenticator detection) after a successful assertion.

func (*Service) FinishRegistration

func (s *Service) FinishRegistration(ctx context.Context, sessionToken, username, name string, response map[string]any) (*FinishRegistrationResult, error)

func (*Service) GetUserName

func (s *Service) GetUserName(ctx context.Context, userID string) (string, error)

GetUserName returns the username for a userID. Used by the enrollment flow to pass the user's name to BeginRegistration.

func (*Service) ListUserCredentials

func (s *Service) ListUserCredentials(ctx context.Context, userID string) ([]Credential, error)

ListUserCredentials returns all passkeys for a user (for the settings page).

func (*Service) RegenerateRecoveryCodes

func (s *Service) RegenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error)

RegenerateRecoveryCodes generates a new set of recovery codes, replacing all existing unused codes. Returns the plaintext codes (one-time display).

type ServiceConfig

type ServiceConfig struct {
	RPID      string
	RPName    string
	RPOrigins []string
	Store     Store
	Users     UserLookup
	Sessions  SessionStore
	Logger    Logger
}

ServiceConfig holds the constructor-time deps for the ceremony service.

type SessionStore

type SessionStore interface {
	SaveChallenge(ctx context.Context, token string, data []byte, ttl time.Duration) error
	// ConsumeChallenge atomically reads and deletes a challenge in a single
	// operation (e.g. Redis GETDEL). This closes the replay window that
	// separate Get+Delete calls would leave open under concurrent requests.
	// Returns (nil, nil) when the token has no stored challenge.
	ConsumeChallenge(ctx context.Context, token string) ([]byte, error)
}

SessionStore abstracts the WebAuthn challenge store. Challenges MUST be crypto-random, single-use (deleted on consume), short-TTL, and user-bound. The implementation is Redis-backed in production.

type Store

type Store interface {
	// ListCredentials returns every passkey a user has registered. Used both to
	// build the go-webauthn User (WebAuthnCredentials) at login and to surface
	// the credential list in account settings.
	ListCredentials(ctx context.Context, userID string) ([]Credential, error)
	// GetCredentialByCredentialID looks up a credential by its WebAuthn
	// credential id (authenticator-generated, globally unique). Used during
	// login assertion to find the owning user when the authenticator is
	// discoverable.
	GetCredentialByCredentialID(ctx context.Context, credentialID []byte) (*Credential, error)
	// CreateCredential persists a newly-registered credential. Fails on a
	// duplicate credential_id (unique index) — an authenticator cannot be bound
	// to two accounts.
	CreateCredential(ctx context.Context, c *Credential) error
	// CreateCredentialAndRecoveryCodes atomically persists a credential AND
	// its associated recovery-code hashes in a single transaction. Used at
	// passkey enrollment so a partial failure (credential committed, recovery
	// codes lost) cannot leave a passkey-only user with zero recovery codes.
	CreateCredentialAndRecoveryCodes(ctx context.Context, cred *Credential, recoveryCodeHashes []string) error
	// UpdateCredentialAfterLogin records the post-assertion sign count (cloned-
	// authenticator detection) and last-used timestamp.
	UpdateCredentialAfterLogin(ctx context.Context, id uuid.UUID, signCount uint32, lastUsedAt time.Time) error
	// DeleteCredential removes a credential. Refused when it is the user's last
	// one (passkey-only users must keep ≥1 credential or they are locked out).
	DeleteCredential(ctx context.Context, userID string, id uuid.UUID) error
	// CountCredentials returns the number of credentials a user has.
	CountCredentials(ctx context.Context, userID string) (int, error)

	// CreateRecoveryCodes stores a batch of freshly-generated recovery codes
	// (bcrypt-hashed by the caller). Replaces any existing unused codes for the
	// user (re-enrollment invalidates prior codes).
	CreateRecoveryCodes(ctx context.Context, userID string, hashes []string) error
	// ListAvailableRecoveryCodes returns the user's unused recovery-code rows.
	ListAvailableRecoveryCodes(ctx context.Context, userID string) ([]RecoveryCode, error)
	// ConsumeRecoveryCode marks a recovery code used (single-use). Returns
	// ErrRecoveryCodeNotFound when no unused code for the user matches.
	ConsumeRecoveryCode(ctx context.Context, userID string, codeHash string) error
}

Store abstracts persistence for user_passkeys and user_recovery_codes. The passkey service depends on this narrow interface so the WebAuthn ceremony logic is testable against an in-memory fake (no Postgres required).

type UserLookup

type UserLookup interface {
	GetUserByEmail(ctx context.Context, email string) (*types.User, error)
	GetUser(ctx context.Context, userID string) (*types.User, error)
}

UserLookup abstracts the user-lookup the ceremony needs: email→user at login begin, and user existence verification at registration. Implementations are the database service (real) or a fake (tests). Matches the existing database.Service.GetUserByEmail shape.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL