// 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 }