This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user