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
- Variables
- type BeginLoginOptions
- type BeginRegistrationOptions
- type CacheSessionStore
- type Credential
- type FinishRegistrationResult
- type Logger
- type PgStore
- func (s *PgStore) ConsumeRecoveryCode(ctx context.Context, userID, codeHash string) error
- func (s *PgStore) CountCredentials(ctx context.Context, userID string) (int, error)
- func (s *PgStore) CreateCredential(ctx context.Context, c *Credential) error
- func (s *PgStore) CreateCredentialAndRecoveryCodes(ctx context.Context, cred *Credential, hashes []string) error
- func (s *PgStore) CreateRecoveryCodes(ctx context.Context, userID string, hashes []string) error
- func (s *PgStore) DeleteCredential(ctx context.Context, userID string, id uuid.UUID) error
- func (s *PgStore) GetCredentialByCredentialID(ctx context.Context, credentialID []byte) (*Credential, error)
- func (s *PgStore) ListAvailableRecoveryCodes(ctx context.Context, userID string) ([]RecoveryCode, error)
- func (s *PgStore) ListCredentials(ctx context.Context, userID string) ([]Credential, error)
- func (s *PgStore) UpdateCredentialAfterLogin(ctx context.Context, id uuid.UUID, signCount uint32, lastUsedAt time.Time) error
- type RecoveryCode
- type Service
- func (s *Service) AddCredential(ctx context.Context, cred *Credential) error
- func (s *Service) BeginLogin(ctx context.Context, email string) (*BeginLoginOptions, string, error)
- func (s *Service) BeginRegistration(ctx context.Context, userID, username string) (*BeginRegistrationOptions, error)
- func (s *Service) ConsumeRecoveryCode(ctx context.Context, email, code string) (string, error)
- func (s *Service) CreateCredentialAndRecoveryCodes(ctx context.Context, cred *Credential, hashes []string) error
- func (s *Service) DeleteUserCredential(ctx context.Context, userID string, credID uuid.UUID) error
- func (s *Service) FinishLogin(ctx context.Context, sessionToken, email string, response map[string]any) (string, error)
- func (s *Service) FinishRegistration(ctx context.Context, sessionToken, username, name string, ...) (*FinishRegistrationResult, error)
- func (s *Service) GetUserName(ctx context.Context, userID string) (string, error)
- func (s *Service) ListUserCredentials(ctx context.Context, userID string) ([]Credential, error)
- func (s *Service) RegenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error)
- type ServiceConfig
- type SessionStore
- type Store
- type UserLookup
Constants ¶
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.
const RecoveryCodeCount = 10
RecoveryCodeCount is the number of recovery codes generated at enrollment.
const RecoveryCodeLen = 20
RecoveryCodeLen is the character length of each recovery code (before any formatting). 20 random characters from an unambiguous alphabet.
Variables ¶
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 (*CacheSessionStore) SaveChallenge ¶
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 ¶
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 ¶
NewPgStore constructs a Postgres-backed passkey store.
func (*PgStore) ConsumeRecoveryCode ¶
func (*PgStore) CountCredentials ¶
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 (*PgStore) DeleteCredential ¶
func (*PgStore) GetCredentialByCredentialID ¶
func (*PgStore) ListAvailableRecoveryCodes ¶
func (*PgStore) ListCredentials ¶
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 ¶
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 ¶
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 ¶
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 (*Service) GetUserName ¶
GetUserName returns the username for a userID. Used by the enrollment flow to pass the user's name to BeginRegistration.
func (*Service) ListUserCredentials ¶
ListUserCredentials returns all passkeys for a user (for the settings page).
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.