auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-08-21 17:33:00 -04:00
parent fb6bbd0dad
commit bfe6cfd29e
230 changed files with 44547 additions and 17 deletions
+616
View File
@@ -0,0 +1,616 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"regexp"
"strings"
"time"
"gamertan.com/web/internal/webauthnvendored/protocol"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
)
const (
defaultEnrollmentLifetime = 15 * time.Minute
defaultRegistrationTTL = 5 * time.Minute
defaultLoginTTL = 2 * time.Minute
defaultApprovalTTL = 90 * time.Second
maxCredentialLabelBytes = 80
maxResponseBytes = 128 << 10
)
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
type Config struct {
RPID string
RPDisplayName string
Origin string
EnrollmentLifetime time.Duration
RegistrationTTL time.Duration
LoginTTL time.Duration
ApprovalTTL time.Duration
SessionLifetime time.Duration
RequiredCredentialCount int
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
auth *auth.Service
webAuthn *wa.WebAuthn
random io.Reader
now func() time.Time
config Config
}
type BootstrapInput struct {
Username, Email, DisplayName string
}
func New(repository Repository, authService *auth.Service, config Config) (*Service, error) {
if repository == nil || authService == nil {
return nil, errors.New("authwebauthn: repository and auth service are required")
}
if err := validateOrigin(config.RPID, config.Origin); err != nil {
return nil, err
}
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
return nil, errors.New("authwebauthn: relying-party display name is invalid")
}
if config.Random == nil {
config.Random = rand.Reader
}
if config.Now == nil {
config.Now = time.Now
}
if config.EnrollmentLifetime == 0 {
config.EnrollmentLifetime = defaultEnrollmentLifetime
}
if config.RegistrationTTL == 0 {
config.RegistrationTTL = defaultRegistrationTTL
}
if config.LoginTTL == 0 {
config.LoginTTL = defaultLoginTTL
}
if config.ApprovalTTL == 0 {
config.ApprovalTTL = defaultApprovalTTL
}
if config.SessionLifetime == 0 {
config.SessionLifetime = 12 * time.Hour
}
if config.RequiredCredentialCount == 0 {
config.RequiredCredentialCount = 2
}
if config.EnrollmentLifetime < time.Minute || config.EnrollmentLifetime > time.Hour ||
config.RegistrationTTL < time.Minute || config.RegistrationTTL > 10*time.Minute ||
config.LoginTTL < time.Minute || config.LoginTTL > 5*time.Minute ||
config.ApprovalTTL < 30*time.Second || config.ApprovalTTL > 2*time.Minute ||
config.SessionLifetime < 5*time.Minute || config.SessionLifetime > 30*24*time.Hour ||
config.RequiredCredentialCount < 1 || config.RequiredCredentialCount > 8 {
return nil, errors.New("authwebauthn: lifetime or credential-count policy is invalid")
}
webAuthn, err := wa.New(&wa.Config{
RPID: config.RPID,
RPDisplayName: config.RPDisplayName,
RPOrigins: []string{config.Origin},
RPAllowCrossOrigin: false,
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationRequired,
},
Timeouts: wa.TimeoutsConfig{
Login: wa.TimeoutConfig{Timeout: config.LoginTTL, TimeoutUVD: config.LoginTTL},
Registration: wa.TimeoutConfig{Timeout: config.RegistrationTTL, TimeoutUVD: config.RegistrationTTL},
},
})
if err != nil {
return nil, fmt.Errorf("authwebauthn: configure verifier: %w", err)
}
return &Service{repository: repository, auth: authService, webAuthn: webAuthn, random: config.Random, now: config.Now, config: config}, nil
}
func (service *Service) Bootstrap(ctx context.Context, input BootstrapInput) (auth.User, string, error) {
username := strings.TrimSpace(input.Username)
email := strings.TrimSpace(input.Email)
displayName := strings.TrimSpace(input.DisplayName)
if !accountNamePattern.MatchString(username) || email == "" || len(email) > 320 || !strings.Contains(email, "@") || displayName == "" || len(displayName) > 128 {
return auth.User{}, "", errors.New("authwebauthn: invalid user")
}
userID, err := service.randomToken(18)
if err != nil {
return auth.User{}, "", err
}
token, enrollment, err := service.newEnrollment(userID)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
audit, err := service.audit("", "auth.passkey.bootstrap", "user", userID, "A local administrator created a passkey-only account and one-time enrollment token.")
if err != nil {
return auth.User{}, "", err
}
if err = service.repository.CreatePasskeyUser(ctx, user, enrollment, audit); err != nil {
return auth.User{}, "", err
}
return user, token, nil
}
func (service *Service) Recover(ctx context.Context, identifier, reason string) (auth.User, string, error) {
identifier = strings.TrimSpace(identifier)
reason = strings.TrimSpace(reason)
if identifier == "" || len(identifier) > 320 || reason == "" || len(reason) > 240 || strings.ContainsAny(reason, "\x00\r\n") {
return auth.User{}, "", errors.New("authwebauthn: recovery identifier and bounded reason are required")
}
user, err := service.repository.UserByIdentifier(ctx, identifier)
if err != nil {
return auth.User{}, "", err
}
if user.Status != "active" {
return auth.User{}, "", auth.ErrInactiveUser
}
token, enrollment, err := service.newEnrollment(user.ID)
if err != nil {
return auth.User{}, "", err
}
audit, err := service.audit("", "auth.passkey.recovery", "user", user.ID, "A local administrator revoked sessions and issued a one-time passkey enrollment token. Reason: "+reason)
if err != nil {
return auth.User{}, "", err
}
user, err = service.repository.RecoverUser(ctx, identifier, enrollment, audit)
if err != nil {
return auth.User{}, "", err
}
return user, token, nil
}
func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, label string) (BeginResult, error) {
digest, err := tokenDigest(enrollmentToken)
if err != nil {
return BeginResult{}, ErrEnrollmentNotFound
}
user, err := service.repository.ConsumeEnrollmentToken(ctx, digest, service.now().UTC())
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label)
}
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label)
}
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label string) (BeginResult, error) {
label, err := credentialLabel(label)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, user)
if err != nil {
return BeginResult{}, err
}
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
creation, session, err := service.webAuthn.BeginRegistration(adapter,
func(options *protocol.PublicKeyCredentialCreationOptions) { options.Challenge = challenge },
wa.WithCredentialParameters([]protocol.CredentialParameter{{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}}),
wa.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
wa.WithConveyancePreference(protocol.PreferNoAttestation),
)
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin registration: %w", err)
}
return service.storeCeremony(ctx, CeremonyRegistration, user.ID, label, session, [32]byte{}, creation.Response, service.config.RegistrationTTL)
}
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyRegistration)
if err != nil {
return Credential{}, err
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Credential{}, errors.New("authwebauthn: registration response is invalid")
}
user, err := service.repository.UserByID(ctx, ceremony.UserID)
if err != nil {
return Credential{}, err
}
adapter, err := service.user(ctx, user)
if err != nil {
return Credential{}, err
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Credential{}, err
}
parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
if err != nil {
return Credential{}, errors.New("authwebauthn: registration response is invalid")
}
verified, err := service.webAuthn.CreateCredential(adapter, session, parsed)
if err != nil {
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
}
if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) {
return Credential{}, ErrUnsupportedCredential
}
encoded, err := json.Marshal(verified)
if err != nil {
return Credential{}, err
}
now := service.now().UTC()
record := Credential{ID: append([]byte(nil), verified.ID...), UserID: user.ID, Label: ceremony.Label, Data: encoded, CreatedAt: now}
audit, err := service.audit(user.ID, "auth.passkey.add", "passkey", base64.RawURLEncoding.EncodeToString(verified.ID), "A passkey was enrolled.")
if err != nil {
return Credential{}, err
}
if err = service.repository.SaveCredential(ctx, record, audit); err != nil {
return Credential{}, err
}
return record, nil
}
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
assertion, session, err := service.webAuthn.BeginDiscoverableLogin(
wa.WithChallenge(challenge),
wa.WithUserVerification(protocol.VerificationRequired),
)
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin login: %w", err)
}
return service.storeCeremony(ctx, CeremonyLogin, "", "", session, [32]byte{}, assertion.Response, service.config.LoginTTL)
}
func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, response []byte) (Authentication, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyLogin)
if err != nil {
return Authentication{}, err
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Authentication{}, errors.New("authwebauthn: login response is invalid")
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Authentication{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
if err != nil {
return Authentication{}, errors.New("authwebauthn: login response is invalid")
}
var loaded *passkeyUser
user, verified, err := service.webAuthn.ValidatePasskeyLogin(func(rawID, userHandle []byte) (wa.User, error) {
account, lookupErr := service.repository.UserByCredentialID(ctx, rawID)
if lookupErr != nil || account.ID != string(userHandle) {
return nil, ErrCredentialNotFound
}
loaded, lookupErr = service.user(ctx, account)
return loaded, lookupErr
}, session, parsed)
if err != nil || loaded == nil || user == nil {
return Authentication{}, errors.New("authwebauthn: authentication failed")
}
if err = service.persistUsedCredential(ctx, loaded.account.ID, verified); err != nil {
return Authentication{}, err
}
token, principal, err := service.auth.IssueSession(ctx, loaded.account.ID, service.config.SessionLifetime)
if err != nil {
return Authentication{}, err
}
return Authentication{SessionToken: token, Principal: principal, CredentialID: append([]byte(nil), verified.ID...), CloneWarning: verified.Authenticator.CloneWarning}, nil
}
func (service *Service) BeginApproval(ctx context.Context, userID string, binding []byte) (BeginResult, error) {
if len(binding) < 32 || len(binding) > 32<<10 {
return BeginResult{}, errors.New("authwebauthn: operation binding is invalid")
}
if err := service.RequireReady(ctx, userID); err != nil {
return BeginResult{}, err
}
account, err := service.repository.UserByID(ctx, userID)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, account)
if err != nil {
return BeginResult{}, err
}
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
assertion, session, err := service.webAuthn.BeginLogin(adapter, wa.WithChallenge(challenge), wa.WithUserVerification(protocol.VerificationRequired))
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin approval: %w", err)
}
return service.storeCeremony(ctx, CeremonyApproval, account.ID, "", session, BindingDigest(binding), assertion.Response, service.config.ApprovalTTL)
}
func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string, binding, response []byte) (Approval, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyApproval)
if err != nil {
return Approval{}, err
}
if BindingDigest(binding) != ceremony.BindingDigest {
return Approval{}, ErrOperationBinding
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Approval{}, errors.New("authwebauthn: approval response is invalid")
}
account, err := service.repository.UserByID(ctx, ceremony.UserID)
if err != nil {
return Approval{}, err
}
adapter, err := service.user(ctx, account)
if err != nil {
return Approval{}, err
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Approval{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
if err != nil {
return Approval{}, errors.New("authwebauthn: approval response is invalid")
}
verified, err := service.webAuthn.ValidateLogin(adapter, session, parsed)
if err != nil {
return Approval{}, errors.New("authwebauthn: approval failed")
}
if err = service.persistUsedCredential(ctx, account.ID, verified); err != nil {
return Approval{}, err
}
return Approval{User: account, CredentialID: append([]byte(nil), verified.ID...), BindingDigest: ceremony.BindingDigest, CloneWarning: verified.Authenticator.CloneWarning, ApprovedAt: service.now().UTC()}, nil
}
func (service *Service) RequireReady(ctx context.Context, userID string) error {
count, err := service.repository.CredentialCount(ctx, userID)
if err != nil {
return err
}
if count < service.config.RequiredCredentialCount {
return ErrPasskeyReadiness
}
return nil
}
// RequiredCredentialCount reports the configured operational credential
// floor. Applications can use it to explain rotation policy without
// duplicating security configuration.
func (service *Service) RequiredCredentialCount() int {
return service.config.RequiredCredentialCount
}
// CredentialSummaries returns bounded account-owner metadata without exposing
// stored credential documents.
func (service *Service) CredentialSummaries(ctx context.Context, userID string) ([]CredentialSummary, error) {
records, err := service.repository.CredentialsByUserID(ctx, userID)
if err != nil {
return nil, err
}
summaries := make([]CredentialSummary, 0, len(records))
for _, record := range records {
summaries = append(summaries, CredentialSummary{
ID: append([]byte(nil), record.ID...),
Label: record.Label,
CreatedAt: record.CreatedAt,
LastUsedAt: record.LastUsedAt,
})
}
return summaries, nil
}
func (service *Service) BeginCredentialRemoval(ctx context.Context, userID string, credentialID []byte) (BeginResult, error) {
if len(credentialID) < 16 || len(credentialID) > 1024 {
return BeginResult{}, ErrCredentialNotFound
}
records, err := service.repository.CredentialsByUserID(ctx, userID)
if err != nil {
return BeginResult{}, err
}
found := false
for _, record := range records {
if bytes.Equal(record.ID, credentialID) {
found = true
break
}
}
if !found {
return BeginResult{}, ErrCredentialNotFound
}
if len(records) <= service.config.RequiredCredentialCount {
return BeginResult{}, ErrCredentialFloor
}
return service.BeginApproval(ctx, userID, credentialRemovalBinding(userID, credentialID))
}
func (service *Service) FinishCredentialRemoval(ctx context.Context, ceremonyToken, userID string, credentialID, response []byte) error {
binding := credentialRemovalBinding(userID, credentialID)
approval, err := service.FinishApproval(ctx, ceremonyToken, binding, response)
if err != nil {
return err
}
if approval.User.ID != userID || approval.BindingDigest != BindingDigest(binding) {
return ErrOperationBinding
}
audit, err := service.audit(userID, "auth.passkey.remove", "passkey", base64.RawURLEncoding.EncodeToString(credentialID), "A passkey was removed after fresh authentication.")
if err != nil {
return err
}
return service.repository.DeleteCredential(ctx, userID, credentialID, service.config.RequiredCredentialCount, audit)
}
func (service *Service) storeCeremony(ctx context.Context, kind, userID, label string, session *wa.SessionData, binding [32]byte, publicKey any, ttl time.Duration) (BeginResult, error) {
token, err := service.randomToken(32)
if err != nil {
return BeginResult{}, err
}
digest := sha256.Sum256([]byte(token))
now := service.now().UTC()
session.Expires = now.Add(ttl)
sessionJSON, err := json.Marshal(session)
if err != nil {
return BeginResult{}, err
}
publicJSON, err := json.Marshal(publicKey)
if err != nil {
return BeginResult{}, err
}
ceremony := Ceremony{Digest: digest, Kind: kind, UserID: userID, Label: label, SessionData: sessionJSON, BindingDigest: binding, CreatedAt: now, ExpiresAt: now.Add(ttl)}
if err = service.repository.CreateCeremony(ctx, ceremony); err != nil {
return BeginResult{}, err
}
return BeginResult{CeremonyToken: token, PublicKey: publicJSON, ExpiresAt: ceremony.ExpiresAt}, nil
}
func (service *Service) takeCeremony(ctx context.Context, token, kind string) (Ceremony, error) {
digest, err := tokenDigest(token)
if err != nil {
return Ceremony{}, ErrCeremonyNotFound
}
ceremony, err := service.repository.TakeCeremony(ctx, digest, service.now().UTC())
if err != nil {
return Ceremony{}, err
}
if ceremony.Kind != kind {
return Ceremony{}, ErrCeremonyNotFound
}
return ceremony, nil
}
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) {
if account.Status != "active" {
return nil, auth.ErrInactiveUser
}
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
if err != nil {
return nil, err
}
credentials := make([]wa.Credential, 0, len(records))
for _, record := range records {
var credential wa.Credential
if err = json.Unmarshal(record.Data, &credential); err != nil || len(credential.ID) == 0 {
return nil, errors.New("authwebauthn: stored credential is invalid")
}
credentials = append(credentials, credential)
}
return &passkeyUser{account: account, credentials: credentials}, nil
}
func (service *Service) persistUsedCredential(ctx context.Context, userID string, credential *wa.Credential) error {
encoded, err := json.Marshal(credential)
if err != nil {
return err
}
return service.repository.UpdateCredential(ctx, Credential{ID: append([]byte(nil), credential.ID...), UserID: userID, Data: encoded, LastUsedAt: service.now().UTC()})
}
func (service *Service) newEnrollment(userID string) (string, EnrollmentToken, error) {
token, err := service.randomToken(32)
if err != nil {
return "", EnrollmentToken{}, err
}
now := service.now().UTC()
return token, EnrollmentToken{Digest: sha256.Sum256([]byte(token)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.config.EnrollmentLifetime)}, nil
}
func (service *Service) audit(actor, action, resourceType, resourceID, summary string) (auth.AuditEvent, error) {
id, err := service.randomToken(18)
if err != nil {
return auth.AuditEvent{}, err
}
return auth.AuditEvent{ID: id, ActorUserID: actor, Action: action, ResourceType: resourceType, ResourceID: resourceID, Summary: summary, CreatedAt: service.now().UTC()}, nil
}
func (service *Service) randomToken(size int) (string, error) {
value, err := service.randomBytes(size)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (service *Service) randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
if _, err := io.ReadFull(service.random, value); err != nil {
return nil, fmt.Errorf("authwebauthn: secure randomness unavailable: %w", err)
}
return value, nil
}
func tokenDigest(token string) ([32]byte, error) {
if len(token) < 32 || len(token) > 128 {
return [32]byte{}, errors.New("invalid token")
}
if _, err := base64.RawURLEncoding.DecodeString(token); err != nil {
return [32]byte{}, errors.New("invalid token")
}
return sha256.Sum256([]byte(token)), nil
}
func decodeSession(value []byte) (wa.SessionData, error) {
var session wa.SessionData
if len(value) == 0 || len(value) > 64<<10 || json.Unmarshal(value, &session) != nil {
return wa.SessionData{}, errors.New("authwebauthn: stored ceremony is invalid")
}
return session, nil
}
func credentialLabel(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || len(value) > maxCredentialLabelBytes || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("authwebauthn: credential label is invalid")
}
return value, nil
}
func credentialRemovalBinding(userID string, credentialID []byte) []byte {
return []byte("gamertan-web/passkey-remove/v1\x00" + userID + "\x00" + base64.RawURLEncoding.EncodeToString(credentialID))
}
func validateOrigin(rpID, rawOrigin string) error {
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
return errors.New("authwebauthn: relying-party ID and origin are required")
}
origin, err := url.Parse(rawOrigin)
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.Port() != "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
}
type passkeyUser struct {
account auth.User
credentials []wa.Credential
}
func (user *passkeyUser) WebAuthnID() []byte { return []byte(user.account.ID) }
func (user *passkeyUser) WebAuthnName() string { return user.account.Username }
func (user *passkeyUser) WebAuthnDisplayName() string { return user.account.DisplayName }
func (user *passkeyUser) WebAuthnCredentials() []wa.Credential { return user.credentials }