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
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
func FuzzPasskeyResponseParsers(f *testing.F) {
f.Add([]byte(`{}`))
f.Add([]byte(`{"id":"credential","rawId":"Y3JlZGVudGlhbA","type":"public-key","response":{}}`))
f.Fuzz(func(t *testing.T, value []byte) {
if len(value) > maxResponseBytes {
t.Skip()
}
_, _ = protocol.ParseCredentialCreationResponseBytes(value)
_, _ = protocol.ParseCredentialRequestResponseBytes(value)
})
}
+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 }
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn_test
import (
"bytes"
"encoding/json"
"errors"
"io"
"testing"
"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"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
)
func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, enrollmentToken, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "operator.one", Email: "operator@example.test", DisplayName: "Operator One"})
if err != nil {
t.Fatal(err)
}
if enrollmentToken == "" || user.PasswordChangeRequired {
t.Fatalf("unexpected bootstrap user=%+v token=%q", user, enrollmentToken)
}
begin, err := service.BeginEnrollment(t.Context(), enrollmentToken, "Primary passkey")
if err != nil {
t.Fatal(err)
}
if _, err = service.BeginEnrollment(t.Context(), enrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("replayed enrollment err=%v", err)
}
var options protocol.PublicKeyCredentialCreationOptions
if err = json.Unmarshal(begin.PublicKey, &options); err != nil {
t.Fatal(err)
}
if options.RelyingParty.ID != "tend.gamertan.com" || options.AuthenticatorSelection.UserVerification != protocol.VerificationRequired || options.AuthenticatorSelection.ResidentKey != protocol.ResidentKeyRequirementRequired || options.Attestation != protocol.PreferNoAttestation {
t.Fatalf("unexpected registration policy: %+v", options)
}
if len(options.Parameters) != 1 || options.Parameters[0].Algorithm != webauthncose.AlgES256 {
t.Fatalf("unexpected algorithms: %+v", options.Parameters)
}
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
}
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
t.Fatalf("readiness without credentials err=%v", err)
}
for index := range 2 {
credential := wa.Credential{ID: bytes.Repeat([]byte{byte(index + 1)}, 32), PublicKey: []byte{1, 2, 3}}
encoded, marshalErr := json.Marshal(credential)
if marshalErr != nil {
t.Fatal(marshalErr)
}
audit := auth.AuditEvent{ID: "audit-passkey-" + string(rune('a'+index)), ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: "fixture", Summary: "fixture", CreatedAt: now}
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: credential.ID, UserID: user.ID, Label: "Fixture", Data: encoded, CreatedAt: now}, audit); err != nil {
t.Fatal(err)
}
}
if err = service.RequireReady(t.Context(), user.ID); err != nil {
t.Fatal(err)
}
summaries, err := service.CredentialSummaries(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
if len(summaries) != 2 || summaries[0].Label != "Fixture" || len(summaries[0].ID) != 32 {
t.Fatalf("unexpected summaries: %+v", summaries)
}
summaries[0].ID[0] = 99
refreshed, err := service.CredentialSummaries(t.Context(), user.ID)
if err != nil || refreshed[0].ID[0] == 99 {
t.Fatalf("credential summary did not defensively copy the identifier: summaries=%+v err=%v", refreshed, err)
}
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, refreshed[0].ID); !errors.Is(err, authwebauthn.ErrCredentialFloor) {
t.Fatalf("credential removal below operational floor err=%v", err)
}
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, bytes.Repeat([]byte{9}, 32)); !errors.Is(err, authwebauthn.ErrCredentialNotFound) {
t.Fatalf("unknown credential removal err=%v", err)
}
binding := bytes.Repeat([]byte("approved operation "), 3)
approvalBegin, err := service.BeginApproval(t.Context(), user.ID, binding)
if err != nil {
t.Fatal(err)
}
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("tampered binding err=%v", err)
}
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, binding, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("replayed approval err=%v", err)
}
login, err := service.BeginLogin(t.Context())
if err != nil {
t.Fatal(err)
}
now = now.Add(3 * time.Minute)
if _, err = service.FinishLogin(t.Context(), login.CeremonyToken, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("expired login err=%v", err)
}
_ = authService
}
func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, _, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recover Me"})
if err != nil {
t.Fatal(err)
}
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
_, token, err := service.Recover(t.Context(), "recover.me", "all authenticators unavailable")
if err != nil {
t.Fatal(err)
}
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived recovery: %v", err)
}
if _, err = service.BeginEnrollment(t.Context(), token, "Recovered passkey"); err != nil {
t.Fatal(err)
}
if _, err = service.BeginEnrollment(t.Context(), token, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("recovery token replay err=%v", err)
}
}
func TestConfigurationAndEntropyFailures(t *testing.T) {
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
authService, err := auth.New(store, auth.Options{})
if err != nil {
t.Fatal(err)
}
for _, config := range []authwebauthn.Config{
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
} {
if _, err = authwebauthn.New(store, authService, config); err == nil {
t.Fatalf("accepted config=%+v", config)
}
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
if err != nil {
t.Fatal(err)
}
if _, _, err = service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "entropy.fail", Email: "entropy@example.test", DisplayName: "Entropy"}); err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("entropy failure err=%v", err)
}
}
func newService(t *testing.T, now *time.Time, random io.Reader) (*authsqlite.Store, *auth.Service, *authwebauthn.Service) {
t.Helper()
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return *now }})
if err != nil {
store.Close()
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: random, Now: func() time.Time { return *now }})
if err != nil {
store.Close()
t.Fatal(err)
}
return store, authService, service
}
type failingReader struct{}
func (failingReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
type counterReader struct{ next byte }
func (reader *counterReader) Read(value []byte) (int, error) {
for index := range value {
reader.next++
value[index] = reader.next
}
return len(value), nil
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: MPL-2.0
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn
// ceremonies. It owns relying-party policy, bounded single-use ceremony state,
// credential lifecycle, and recovery tokens while delegating protocol parsing
// and signature verification to a pinned WebAuthn implementation.
package authwebauthn
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"time"
"gamertan.com/web/auth"
)
var (
ErrCeremonyNotFound = errors.New("authwebauthn: ceremony not found")
ErrCredentialNotFound = errors.New("authwebauthn: credential not found")
ErrEnrollmentNotFound = errors.New("authwebauthn: enrollment token not found")
ErrCredentialFloor = errors.New("authwebauthn: the required credential floor cannot be crossed")
ErrLastCredential = errors.New("authwebauthn: the last credential cannot be removed remotely")
ErrOperationBinding = errors.New("authwebauthn: operation binding does not match")
ErrPasskeyReadiness = errors.New("authwebauthn: at least two passkeys are required")
ErrUnsupportedCredential = errors.New("authwebauthn: credential algorithm is unsupported")
)
const (
CeremonyRegistration = "registration"
CeremonyLogin = "login"
CeremonyApproval = "approval"
)
type Credential struct {
ID []byte
UserID string
Label string
Data json.RawMessage
CreatedAt time.Time
LastUsedAt time.Time
}
// CredentialSummary is the non-secret credential metadata applications may
// show to an authenticated account owner. It intentionally excludes the
// stored public-key document and user identifier.
type CredentialSummary struct {
ID []byte
Label string
CreatedAt time.Time
LastUsedAt time.Time
}
type EnrollmentToken struct {
Digest [32]byte
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
type Ceremony struct {
Digest [32]byte
Kind string
UserID string
Label string
SessionData json.RawMessage
BindingDigest [32]byte
CreatedAt time.Time
ExpiresAt time.Time
}
type BeginResult struct {
CeremonyToken string `json:"ceremony_token"`
PublicKey json.RawMessage `json:"public_key"`
ExpiresAt time.Time `json:"expires_at"`
}
type Authentication struct {
SessionToken string
Principal auth.Principal
CredentialID []byte
CloneWarning bool
}
type Approval struct {
User auth.User
CredentialID []byte
BindingDigest [32]byte
CloneWarning bool
ApprovedAt time.Time
}
// Repository persists passkey-specific state. Implementations must consume
// enrollment tokens and ceremonies atomically and must perform recovery and
// credential removal invariants in transactions.
type Repository interface {
CreatePasskeyUser(context.Context, auth.User, EnrollmentToken, auth.AuditEvent) error
UserByID(context.Context, string) (auth.User, error)
UserByIdentifier(context.Context, string) (auth.User, error)
UserByCredentialID(context.Context, []byte) (auth.User, error)
CredentialsByUserID(context.Context, string) ([]Credential, error)
SaveCredential(context.Context, Credential, auth.AuditEvent) error
UpdateCredential(context.Context, Credential) error
DeleteCredential(context.Context, string, []byte, int, auth.AuditEvent) error
CredentialCount(context.Context, string) (int, error)
CreateCeremony(context.Context, Ceremony) error
TakeCeremony(context.Context, [32]byte, time.Time) (Ceremony, error)
ConsumeEnrollmentToken(context.Context, [32]byte, time.Time) (auth.User, error)
RecoverUser(context.Context, string, EnrollmentToken, auth.AuditEvent) (auth.User, error)
}
func BindingDigest(value []byte) [32]byte { return sha256.Sum256(value) }