From af1e4f4734372a52e00dd00d30dd849ec44b5966 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 18:21:55 -0700 Subject: [PATCH 1/6] refactor(proof/passkey): split service.go into registration/login/validations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service.go was 559 LOC mixing ceremony orchestration, validation predicates, and scaffolding. Split it per ceremony, mirroring the exchange/ and management/ shape from PRs #63 and #65: - service.go (~170 LOC) — Service, NewService, newService, validateConfig, scaffolding helpers (passkeyAuthenticatorSelection, sessionRequiringUserVerification, enforcedTimeout, registrationTimeout, loginTimeout, identityForCredential, credentialIDString). - registration.go — BeginRegistration, FinishRegistration, registrationUser, finishRegistrationUser, credentialExclusions. - login.go — BeginLogin, FinishLogin, validateLinkedPrincipal, discoverableUserHandler. - validations.go — the four pure validate* predicates that confirm a store-returned RegistrationResult matches the verified ceremony state. No logic changes; tests unmodified at this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/passkey/login.go | 146 +++++++++++++ proof/passkey/registration.go | 202 ++++++++++++++++++ proof/passkey/service.go | 386 ---------------------------------- proof/passkey/validations.go | 66 ++++++ 4 files changed, 414 insertions(+), 386 deletions(-) create mode 100644 proof/passkey/login.go create mode 100644 proof/passkey/registration.go create mode 100644 proof/passkey/validations.go diff --git a/proof/passkey/login.go b/proof/passkey/login.go new file mode 100644 index 0000000..948eee7 --- /dev/null +++ b/proof/passkey/login.go @@ -0,0 +1,146 @@ +package passkey + +import ( + "context" + "errors" + "fmt" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/meigma/authkit" +) + +// BeginLogin starts a discoverable passkey login ceremony. +func (s *Service) BeginLogin(ctx context.Context, _ BeginLoginRequest) (BeginLoginResult, error) { + if err := ctx.Err(); err != nil { + return BeginLoginResult{}, err + } + + assertion, session, err := s.rp.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + return BeginLoginResult{}, internalError("begin login", err) + } + if assertion == nil || session == nil { + return BeginLoginResult{}, internalError("begin login", errors.New("relying party returned nil result")) + } + + return BeginLoginResult{ + Assertion: assertion, + SessionData: *session, + }, nil +} + +// FinishLogin verifies a discoverable passkey login response. +func (s *Service) FinishLogin(ctx context.Context, req FinishLoginRequest) (FinishLoginResult, error) { + if err := ctx.Err(); err != nil { + return FinishLoginResult{}, err + } + if len(req.Response) == 0 { + return FinishLoginResult{}, unauthenticated("login response is required") + } + + parsed, err := s.parseAssertionResponse(req.Response) + if err != nil { + return FinishLoginResult{}, unauthenticated("invalid login response") + } + session := sessionRequiringUserVerification(req.SessionData) + user, upstreamCredential, err := s.rp.ValidatePasskeyLogin(s.discoverableUserHandler(ctx), session, parsed) + if err != nil { + if errors.Is(err, authkit.ErrInternal) { + return FinishLoginResult{}, err + } + return FinishLoginResult{}, unauthenticated("login verification failed") + } + if upstreamCredential == nil { + return FinishLoginResult{}, internalError("finish login", errors.New("relying party returned nil credential")) + } + passkeyUser, ok := user.(webAuthnUser) + if !ok { + return FinishLoginResult{}, internalError("finish login", errors.New("unexpected WebAuthn user type")) + } + storedCredential, ok := credentialByID(passkeyUser.credentials, upstreamCredential.ID) + if !ok { + return FinishLoginResult{}, internalError( + "match credential", + fmt.Errorf("credential %q is not stored for passkey user", credentialIDString(upstreamCredential.ID)), + ) + } + + credential := Credential{ + RPID: storedCredential.RPID, + PrincipalID: storedCredential.PrincipalID, + UserHandle: cloneBytes(storedCredential.UserHandle), + CredentialID: cloneBytes(storedCredential.CredentialID), + WebAuthn: cloneWebAuthnCredential(*upstreamCredential), + } + if upstreamCredential.Authenticator.CloneWarning { + if err := s.store.UpdateCredentialAfterLogin(ctx, credential); err != nil { + return FinishLoginResult{}, internalError("update credential after clone warning", err) + } + + return FinishLoginResult{}, cloneWarning() + } + + identity := identityForCredential(s.config.RPID, storedCredential.UserHandle, storedCredential.CredentialID) + if err := s.validateLinkedPrincipal(ctx, identity, passkeyUser.user.PrincipalID); err != nil { + return FinishLoginResult{}, err + } + if err := s.store.UpdateCredentialAfterLogin(ctx, credential); err != nil { + return FinishLoginResult{}, internalError("update credential after login", err) + } + + return FinishLoginResult{ + Identity: identity, + User: cloneUser(passkeyUser.user), + Credential: cloneCredential(credential), + }, nil +} + +func (s *Service) validateLinkedPrincipal( + ctx context.Context, + identity authkit.Identity, + wantPrincipalID string, +) error { + principal, err := s.store.ResolveIdentity(ctx, identity) + if errors.Is(err, authkit.ErrUnresolvedIdentity) { + return err + } + if err != nil { + return internalError("resolve identity", err) + } + if principal == nil { + return internalError("resolve identity", errors.New("store returned nil principal")) + } + if principal.ID != wantPrincipalID { + return internalError( + "resolve identity", + fmt.Errorf("identity resolved to principal %q, want %q", principal.ID, wantPrincipalID), + ) + } + + return nil +} + +func (s *Service) discoverableUserHandler(ctx context.Context) webauthn.DiscoverableUserHandler { + return func(_, userHandle []byte) (webauthn.User, error) { + user, err := s.store.FindUserByHandle(ctx, s.config.RPID, userHandle) + if errors.Is(err, ErrUserNotFound) { + return nil, err + } + if err != nil { + return nil, internalError("find user by handle", err) + } + credentials, err := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) + if err != nil { + return nil, internalError("list credentials", err) + } + + webUser, err := newWebAuthnUser(user, credentials) + if err != nil { + return nil, internalError("validate credentials", err) + } + + return webUser, nil + } +} diff --git a/proof/passkey/registration.go b/proof/passkey/registration.go new file mode 100644 index 0000000..b6280b4 --- /dev/null +++ b/proof/passkey/registration.go @@ -0,0 +1,202 @@ +package passkey + +import ( + "context" + "crypto/rand" + "errors" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// BeginRegistration starts a passkey registration ceremony for an existing authkit principal. +func (s *Service) BeginRegistration( + ctx context.Context, + req BeginRegistrationRequest, +) (BeginRegistrationResult, error) { + if err := ctx.Err(); err != nil { + return BeginRegistrationResult{}, err + } + if req.PrincipalID == "" { + return BeginRegistrationResult{}, errors.New("passkey: principal ID is required") + } + if req.Name == "" { + return BeginRegistrationResult{}, errors.New("passkey: user name is required") + } + if req.DisplayName == "" { + return BeginRegistrationResult{}, errors.New("passkey: user display name is required") + } + + user, credentials, err := s.registrationUser(ctx, req) + if err != nil { + return BeginRegistrationResult{}, err + } + + webUser, err := newWebAuthnUser(user, credentials) + if err != nil { + return BeginRegistrationResult{}, internalError("validate credentials", err) + } + + creation, session, err := s.rp.BeginRegistration( + webUser, + webauthn.WithAuthenticatorSelection(passkeyAuthenticatorSelection()), + webauthn.WithExclusions(credentialExclusions(webUser)), + ) + if err != nil { + return BeginRegistrationResult{}, internalError("begin registration", err) + } + if creation == nil || session == nil { + return BeginRegistrationResult{}, internalError( + "begin registration", + errors.New("relying party returned nil result"), + ) + } + + return BeginRegistrationResult{ + Creation: creation, + SessionData: *session, + User: cloneUser(user), + }, nil +} + +// FinishRegistration verifies a passkey registration response and stores its credential. +func (s *Service) FinishRegistration( + ctx context.Context, + req FinishRegistrationRequest, +) (FinishRegistrationResult, error) { + if err := ctx.Err(); err != nil { + return FinishRegistrationResult{}, err + } + if req.PrincipalID == "" { + return FinishRegistrationResult{}, errors.New("passkey: principal ID is required") + } + user, err := s.finishRegistrationUser(req) + if err != nil { + return FinishRegistrationResult{}, err + } + if len(req.Response) == 0 { + return FinishRegistrationResult{}, unauthenticated("registration response is required") + } + + credentials, err := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) + if err != nil { + return FinishRegistrationResult{}, internalError("list credentials", err) + } + + parsed, err := s.parseCreationResponse(req.Response) + if err != nil { + return FinishRegistrationResult{}, unauthenticated("invalid registration response") + } + webUser, err := newWebAuthnUser(user, credentials) + if err != nil { + return FinishRegistrationResult{}, internalError("validate credentials", err) + } + + session := sessionRequiringUserVerification(req.SessionData) + upstreamCredential, err := s.rp.CreateCredential(webUser, session, parsed) + if err != nil { + return FinishRegistrationResult{}, unauthenticated("registration verification failed") + } + if upstreamCredential == nil { + return FinishRegistrationResult{}, internalError( + "finish registration", + errors.New("relying party returned nil credential"), + ) + } + + identity := identityForCredential(s.config.RPID, user.Handle, upstreamCredential.ID) + credential := Credential{ + RPID: s.config.RPID, + PrincipalID: user.PrincipalID, + UserHandle: cloneBytes(user.Handle), + CredentialID: cloneBytes(upstreamCredential.ID), + WebAuthn: cloneWebAuthnCredential(*upstreamCredential), + } + expectedRegistration := Registration{ + User: user, + Credential: credential, + Identity: identity, + } + registration, err := s.store.CreateRegistration(ctx, expectedRegistration) + if err != nil { + if errors.Is(err, ErrCredentialExists) || errors.Is(err, ErrUserExists) { + return FinishRegistrationResult{}, err + } + return FinishRegistrationResult{}, internalError("create registration", err) + } + if err := validateRegistrationResult(registration, expectedRegistration); err != nil { + return FinishRegistrationResult{}, internalError("validate registration result", err) + } + + return FinishRegistrationResult{ + Identity: identity, + Link: registration.Link, + Credential: cloneCredential(registration.Credential), + }, nil +} + +func (s *Service) registrationUser( + ctx context.Context, + req BeginRegistrationRequest, +) (User, []Credential, error) { + user, err := s.store.FindUserByPrincipal(ctx, s.config.RPID, req.PrincipalID) + if err == nil { + credentials, listErr := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) + if listErr != nil { + return User{}, nil, internalError("list credentials", listErr) + } + + return cloneUser(user), credentials, nil + } + if !errors.Is(err, ErrUserNotFound) { + return User{}, nil, internalError("find user by principal", err) + } + + handle := make([]byte, userHandleBytes) + if _, err := rand.Read(handle); err != nil { + return User{}, nil, internalError("generate user handle", err) + } + + user = User{ + RPID: s.config.RPID, + PrincipalID: req.PrincipalID, + Handle: handle, + Name: req.Name, + DisplayName: req.DisplayName, + } + + return cloneUser(user), nil, nil +} + +func (s *Service) finishRegistrationUser(req FinishRegistrationRequest) (User, error) { + user := req.User + if user.RPID == "" && user.PrincipalID == "" && len(user.Handle) == 0 { + return User{}, unauthenticated("registration user session data is required") + } + if user.RPID != s.config.RPID { + return User{}, unauthenticated("registration user relying party does not match") + } + if user.PrincipalID != req.PrincipalID { + return User{}, unauthenticated("registration user principal does not match") + } + if len(user.Handle) == 0 { + return User{}, unauthenticated("registration user handle is required") + } + if user.Name == "" { + return User{}, unauthenticated("registration user name is required") + } + if user.DisplayName == "" { + return User{}, unauthenticated("registration user display name is required") + } + + return cloneUser(user), nil +} + +func credentialExclusions(user webAuthnUser) []protocol.CredentialDescriptor { + credentials := user.WebAuthnCredentials() + if len(credentials) == 0 { + return nil + } + + return webauthn.Credentials(credentials).CredentialDescriptors() +} diff --git a/proof/passkey/service.go b/proof/passkey/service.go index 2774765..7bcccc5 100644 --- a/proof/passkey/service.go +++ b/proof/passkey/service.go @@ -1,9 +1,6 @@ package passkey import ( - "bytes" - "context" - "crypto/rand" "encoding/base64" "errors" "fmt" @@ -100,323 +97,6 @@ func newService(store Store, config Config, rp relyingParty) (*Service, error) { }, nil } -// BeginRegistration starts a passkey registration ceremony for an existing authkit principal. -func (s *Service) BeginRegistration( - ctx context.Context, - req BeginRegistrationRequest, -) (BeginRegistrationResult, error) { - if err := ctx.Err(); err != nil { - return BeginRegistrationResult{}, err - } - if req.PrincipalID == "" { - return BeginRegistrationResult{}, errors.New("passkey: principal ID is required") - } - if req.Name == "" { - return BeginRegistrationResult{}, errors.New("passkey: user name is required") - } - if req.DisplayName == "" { - return BeginRegistrationResult{}, errors.New("passkey: user display name is required") - } - - user, credentials, err := s.registrationUser(ctx, req) - if err != nil { - return BeginRegistrationResult{}, err - } - - webUser, err := newWebAuthnUser(user, credentials) - if err != nil { - return BeginRegistrationResult{}, internalError("validate credentials", err) - } - - creation, session, err := s.rp.BeginRegistration( - webUser, - webauthn.WithAuthenticatorSelection(passkeyAuthenticatorSelection()), - webauthn.WithExclusions(credentialExclusions(webUser)), - ) - if err != nil { - return BeginRegistrationResult{}, internalError("begin registration", err) - } - if creation == nil || session == nil { - return BeginRegistrationResult{}, internalError( - "begin registration", - errors.New("relying party returned nil result"), - ) - } - - return BeginRegistrationResult{ - Creation: creation, - SessionData: *session, - User: cloneUser(user), - }, nil -} - -// FinishRegistration verifies a passkey registration response and stores its credential. -func (s *Service) FinishRegistration( - ctx context.Context, - req FinishRegistrationRequest, -) (FinishRegistrationResult, error) { - if err := ctx.Err(); err != nil { - return FinishRegistrationResult{}, err - } - if req.PrincipalID == "" { - return FinishRegistrationResult{}, errors.New("passkey: principal ID is required") - } - user, err := s.finishRegistrationUser(req) - if err != nil { - return FinishRegistrationResult{}, err - } - if len(req.Response) == 0 { - return FinishRegistrationResult{}, unauthenticated("registration response is required") - } - - credentials, err := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) - if err != nil { - return FinishRegistrationResult{}, internalError("list credentials", err) - } - - parsed, err := s.parseCreationResponse(req.Response) - if err != nil { - return FinishRegistrationResult{}, unauthenticated("invalid registration response") - } - webUser, err := newWebAuthnUser(user, credentials) - if err != nil { - return FinishRegistrationResult{}, internalError("validate credentials", err) - } - - session := sessionRequiringUserVerification(req.SessionData) - upstreamCredential, err := s.rp.CreateCredential(webUser, session, parsed) - if err != nil { - return FinishRegistrationResult{}, unauthenticated("registration verification failed") - } - if upstreamCredential == nil { - return FinishRegistrationResult{}, internalError( - "finish registration", - errors.New("relying party returned nil credential"), - ) - } - - identity := identityForCredential(s.config.RPID, user.Handle, upstreamCredential.ID) - credential := Credential{ - RPID: s.config.RPID, - PrincipalID: user.PrincipalID, - UserHandle: cloneBytes(user.Handle), - CredentialID: cloneBytes(upstreamCredential.ID), - WebAuthn: cloneWebAuthnCredential(*upstreamCredential), - } - expectedRegistration := Registration{ - User: user, - Credential: credential, - Identity: identity, - } - registration, err := s.store.CreateRegistration(ctx, expectedRegistration) - if err != nil { - if errors.Is(err, ErrCredentialExists) || errors.Is(err, ErrUserExists) { - return FinishRegistrationResult{}, err - } - return FinishRegistrationResult{}, internalError("create registration", err) - } - if err := validateRegistrationResult(registration, expectedRegistration); err != nil { - return FinishRegistrationResult{}, internalError("validate registration result", err) - } - - return FinishRegistrationResult{ - Identity: identity, - Link: registration.Link, - Credential: cloneCredential(registration.Credential), - }, nil -} - -// BeginLogin starts a discoverable passkey login ceremony. -func (s *Service) BeginLogin(ctx context.Context, _ BeginLoginRequest) (BeginLoginResult, error) { - if err := ctx.Err(); err != nil { - return BeginLoginResult{}, err - } - - assertion, session, err := s.rp.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) - if err != nil { - return BeginLoginResult{}, internalError("begin login", err) - } - if assertion == nil || session == nil { - return BeginLoginResult{}, internalError("begin login", errors.New("relying party returned nil result")) - } - - return BeginLoginResult{ - Assertion: assertion, - SessionData: *session, - }, nil -} - -// FinishLogin verifies a discoverable passkey login response. -func (s *Service) FinishLogin(ctx context.Context, req FinishLoginRequest) (FinishLoginResult, error) { - if err := ctx.Err(); err != nil { - return FinishLoginResult{}, err - } - if len(req.Response) == 0 { - return FinishLoginResult{}, unauthenticated("login response is required") - } - - parsed, err := s.parseAssertionResponse(req.Response) - if err != nil { - return FinishLoginResult{}, unauthenticated("invalid login response") - } - session := sessionRequiringUserVerification(req.SessionData) - user, upstreamCredential, err := s.rp.ValidatePasskeyLogin(s.discoverableUserHandler(ctx), session, parsed) - if err != nil { - if errors.Is(err, authkit.ErrInternal) { - return FinishLoginResult{}, err - } - return FinishLoginResult{}, unauthenticated("login verification failed") - } - if upstreamCredential == nil { - return FinishLoginResult{}, internalError("finish login", errors.New("relying party returned nil credential")) - } - passkeyUser, ok := user.(webAuthnUser) - if !ok { - return FinishLoginResult{}, internalError("finish login", errors.New("unexpected WebAuthn user type")) - } - storedCredential, ok := credentialByID(passkeyUser.credentials, upstreamCredential.ID) - if !ok { - return FinishLoginResult{}, internalError( - "match credential", - fmt.Errorf("credential %q is not stored for passkey user", credentialIDString(upstreamCredential.ID)), - ) - } - - credential := Credential{ - RPID: storedCredential.RPID, - PrincipalID: storedCredential.PrincipalID, - UserHandle: cloneBytes(storedCredential.UserHandle), - CredentialID: cloneBytes(storedCredential.CredentialID), - WebAuthn: cloneWebAuthnCredential(*upstreamCredential), - } - if upstreamCredential.Authenticator.CloneWarning { - if err := s.store.UpdateCredentialAfterLogin(ctx, credential); err != nil { - return FinishLoginResult{}, internalError("update credential after clone warning", err) - } - - return FinishLoginResult{}, cloneWarning() - } - - identity := identityForCredential(s.config.RPID, storedCredential.UserHandle, storedCredential.CredentialID) - if err := s.validateLinkedPrincipal(ctx, identity, passkeyUser.user.PrincipalID); err != nil { - return FinishLoginResult{}, err - } - if err := s.store.UpdateCredentialAfterLogin(ctx, credential); err != nil { - return FinishLoginResult{}, internalError("update credential after login", err) - } - - return FinishLoginResult{ - Identity: identity, - User: cloneUser(passkeyUser.user), - Credential: cloneCredential(credential), - }, nil -} - -func (s *Service) validateLinkedPrincipal( - ctx context.Context, - identity authkit.Identity, - wantPrincipalID string, -) error { - principal, err := s.store.ResolveIdentity(ctx, identity) - if errors.Is(err, authkit.ErrUnresolvedIdentity) { - return err - } - if err != nil { - return internalError("resolve identity", err) - } - if principal == nil { - return internalError("resolve identity", errors.New("store returned nil principal")) - } - if principal.ID != wantPrincipalID { - return internalError( - "resolve identity", - fmt.Errorf("identity resolved to principal %q, want %q", principal.ID, wantPrincipalID), - ) - } - - return nil -} - -func (s *Service) registrationUser( - ctx context.Context, - req BeginRegistrationRequest, -) (User, []Credential, error) { - user, err := s.store.FindUserByPrincipal(ctx, s.config.RPID, req.PrincipalID) - if err == nil { - credentials, listErr := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) - if listErr != nil { - return User{}, nil, internalError("list credentials", listErr) - } - - return cloneUser(user), credentials, nil - } - if !errors.Is(err, ErrUserNotFound) { - return User{}, nil, internalError("find user by principal", err) - } - - handle := make([]byte, userHandleBytes) - if _, err := rand.Read(handle); err != nil { - return User{}, nil, internalError("generate user handle", err) - } - - user = User{ - RPID: s.config.RPID, - PrincipalID: req.PrincipalID, - Handle: handle, - Name: req.Name, - DisplayName: req.DisplayName, - } - - return cloneUser(user), nil, nil -} - -func (s *Service) finishRegistrationUser(req FinishRegistrationRequest) (User, error) { - user := req.User - if user.RPID == "" && user.PrincipalID == "" && len(user.Handle) == 0 { - return User{}, unauthenticated("registration user session data is required") - } - if user.RPID != s.config.RPID { - return User{}, unauthenticated("registration user relying party does not match") - } - if user.PrincipalID != req.PrincipalID { - return User{}, unauthenticated("registration user principal does not match") - } - if len(user.Handle) == 0 { - return User{}, unauthenticated("registration user handle is required") - } - if user.Name == "" { - return User{}, unauthenticated("registration user name is required") - } - if user.DisplayName == "" { - return User{}, unauthenticated("registration user display name is required") - } - - return cloneUser(user), nil -} - -func (s *Service) discoverableUserHandler(ctx context.Context) webauthn.DiscoverableUserHandler { - return func(_, userHandle []byte) (webauthn.User, error) { - user, err := s.store.FindUserByHandle(ctx, s.config.RPID, userHandle) - if errors.Is(err, ErrUserNotFound) { - return nil, err - } - if err != nil { - return nil, internalError("find user by handle", err) - } - credentials, err := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) - if err != nil { - return nil, internalError("list credentials", err) - } - - webUser, err := newWebAuthnUser(user, credentials) - if err != nil { - return nil, internalError("validate credentials", err) - } - - return webUser, nil - } -} - func validateConfig(config Config) error { if strings.TrimSpace(config.RPID) == "" { return errors.New("passkey: RP ID is required") @@ -450,78 +130,12 @@ func passkeyAuthenticatorSelection() protocol.AuthenticatorSelection { } } -func credentialExclusions(user webAuthnUser) []protocol.CredentialDescriptor { - credentials := user.WebAuthnCredentials() - if len(credentials) == 0 { - return nil - } - - return webauthn.Credentials(credentials).CredentialDescriptors() -} - func sessionRequiringUserVerification(session webauthn.SessionData) webauthn.SessionData { session.UserVerification = protocol.VerificationRequired return session } -func validateRegistrationResult(result RegistrationResult, expected Registration) error { - if err := validateRegistrationUser(result.User, expected.User); err != nil { - return err - } - if err := validateRegistrationCredential(result.Credential, expected.Credential); err != nil { - return err - } - if err := validateRegistrationLink(result.Link, expected); err != nil { - return err - } - - return nil -} - -func validateRegistrationUser(got User, want User) error { - switch { - case got.RPID != want.RPID: - return fmt.Errorf("registration user RP ID %q, want %q", got.RPID, want.RPID) - case got.PrincipalID != want.PrincipalID: - return fmt.Errorf("registration user principal %q, want %q", got.PrincipalID, want.PrincipalID) - case !bytes.Equal(got.Handle, want.Handle): - return errors.New("registration user handle does not match verified ceremony") - } - - return nil -} - -func validateRegistrationCredential(got Credential, want Credential) error { - switch { - case got.RPID != want.RPID: - return fmt.Errorf("registration credential RP ID %q, want %q", got.RPID, want.RPID) - case got.PrincipalID != want.PrincipalID: - return fmt.Errorf("registration credential principal %q, want %q", got.PrincipalID, want.PrincipalID) - case !bytes.Equal(got.UserHandle, want.UserHandle): - return errors.New("registration credential user handle does not match verified ceremony") - case !bytes.Equal(got.CredentialID, want.CredentialID): - return errors.New("registration credential ID does not match verified ceremony") - case len(got.WebAuthn.ID) > 0 && !bytes.Equal(got.WebAuthn.ID, want.CredentialID): - return errors.New("registration credential WebAuthn ID does not match verified ceremony") - } - - return nil -} - -func validateRegistrationLink(got authkit.ExternalIdentity, expected Registration) error { - switch { - case got.Provider != expected.Identity.Provider: - return fmt.Errorf("registration link provider %q, want %q", got.Provider, expected.Identity.Provider) - case got.Subject != expected.Identity.Subject: - return fmt.Errorf("registration link subject %q, want %q", got.Subject, expected.Identity.Subject) - case got.PrincipalID != expected.User.PrincipalID: - return fmt.Errorf("registration link principal %q, want %q", got.PrincipalID, expected.User.PrincipalID) - } - - return nil -} - func enforcedTimeout(timeout time.Duration) webauthn.TimeoutConfig { return webauthn.TimeoutConfig{ Enforce: true, diff --git a/proof/passkey/validations.go b/proof/passkey/validations.go new file mode 100644 index 0000000..d691d12 --- /dev/null +++ b/proof/passkey/validations.go @@ -0,0 +1,66 @@ +package passkey + +import ( + "bytes" + "errors" + "fmt" + + "github.com/meigma/authkit" +) + +func validateRegistrationResult(result RegistrationResult, expected Registration) error { + if err := validateRegistrationUser(result.User, expected.User); err != nil { + return err + } + if err := validateRegistrationCredential(result.Credential, expected.Credential); err != nil { + return err + } + if err := validateRegistrationLink(result.Link, expected); err != nil { + return err + } + + return nil +} + +func validateRegistrationUser(got User, want User) error { + switch { + case got.RPID != want.RPID: + return fmt.Errorf("registration user RP ID %q, want %q", got.RPID, want.RPID) + case got.PrincipalID != want.PrincipalID: + return fmt.Errorf("registration user principal %q, want %q", got.PrincipalID, want.PrincipalID) + case !bytes.Equal(got.Handle, want.Handle): + return errors.New("registration user handle does not match verified ceremony") + } + + return nil +} + +func validateRegistrationCredential(got Credential, want Credential) error { + switch { + case got.RPID != want.RPID: + return fmt.Errorf("registration credential RP ID %q, want %q", got.RPID, want.RPID) + case got.PrincipalID != want.PrincipalID: + return fmt.Errorf("registration credential principal %q, want %q", got.PrincipalID, want.PrincipalID) + case !bytes.Equal(got.UserHandle, want.UserHandle): + return errors.New("registration credential user handle does not match verified ceremony") + case !bytes.Equal(got.CredentialID, want.CredentialID): + return errors.New("registration credential ID does not match verified ceremony") + case len(got.WebAuthn.ID) > 0 && !bytes.Equal(got.WebAuthn.ID, want.CredentialID): + return errors.New("registration credential WebAuthn ID does not match verified ceremony") + } + + return nil +} + +func validateRegistrationLink(got authkit.ExternalIdentity, expected Registration) error { + switch { + case got.Provider != expected.Identity.Provider: + return fmt.Errorf("registration link provider %q, want %q", got.Provider, expected.Identity.Provider) + case got.Subject != expected.Identity.Subject: + return fmt.Errorf("registration link subject %q, want %q", got.Subject, expected.Identity.Subject) + case got.PrincipalID != expected.User.PrincipalID: + return fmt.Errorf("registration link principal %q, want %q", got.PrincipalID, expected.User.PrincipalID) + } + + return nil +} From 09dacce6e1670ebab62e2a9e97572b39b9e1f35b Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 18:26:18 -0700 Subject: [PATCH 2/6] refactor(proof/passkey): godocs + inline security comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add concise godocs to every previously-undocumented private item across clone.go, errors.go, login.go, registration.go, service.go, validations.go, and webauthn_user.go. Several name the security invariant the helper preserves — defensive-copy header on clone.go, the 8-point binding invariant on credentialsForUser, the upstream seam rationale on relyingParty, ErrCloneWarning's chained semantics on cloneWarning. Annotate the two high-blast-radius branches in the login path: - The clone-warning rejection branch persists the updated counter state *before* refusing the login, defending against replay against the pre-clone counter. - The validateLinkedPrincipal call defends against a cross-principal collision where a credential resolves to a different principal than the discoverable handler returned. Annotate sessionRequiringUserVerification with its downgrade-defense rationale: even if a tampered SessionData claims a weaker flag, the finish step forces Required before the verifying parse. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/passkey/clone.go | 23 +++++++++++++++++++++++ proof/passkey/errors.go | 9 +++++++++ proof/passkey/login.go | 16 ++++++++++++++++ proof/passkey/registration.go | 10 ++++++++++ proof/passkey/service.go | 34 ++++++++++++++++++++++++++++++++++ proof/passkey/validations.go | 13 +++++++++++++ proof/passkey/webauthn_user.go | 15 +++++++++++++++ 7 files changed, 120 insertions(+) diff --git a/proof/passkey/clone.go b/proof/passkey/clone.go index e3e6623..0039395 100644 --- a/proof/passkey/clone.go +++ b/proof/passkey/clone.go @@ -8,6 +8,14 @@ import ( "github.com/meigma/authkit" ) +// Defensive copies in this file protect ceremony state and credential records +// from in-place mutation after they cross the package boundary. The + // upstream webauthn.Credential carries several mutable byte slices and maps +// that callers could otherwise overwrite — corrupting stored credentials, +// invalidating clone-warning detection, or skewing the next verification. + +// cloneConfig returns a deep copy of config so a Service can outlive any +// caller-owned mutation of the original Config struct. func cloneConfig(config Config) Config { return Config{ RPID: config.RPID, @@ -18,6 +26,7 @@ func cloneConfig(config Config) Config { } } +// cloneUser returns a deep copy of user, including the Handle byte slice. func cloneUser(user User) User { return User{ RPID: user.RPID, @@ -28,6 +37,8 @@ func cloneUser(user User) User { } } +// cloneCredential returns a deep copy of credential, including all mutable +// byte slices in the embedded webauthn.Credential. func cloneCredential(credential Credential) Credential { return Credential{ RPID: credential.RPID, @@ -38,6 +49,8 @@ func cloneCredential(credential Credential) Credential { } } +// cloneRegistration returns a deep copy of registration so the Store layer +// can hand records back to callers without sharing internal slices. func cloneRegistration(registration Registration) Registration { return Registration{ User: cloneUser(registration.User), @@ -46,6 +59,8 @@ func cloneRegistration(registration Registration) Registration { } } +// cloneCredentials returns a deep copy of credentials. Returns nil for an +// empty input to avoid distinguishing nil from a zero-length slice. func cloneCredentials(credentials []Credential) []Credential { if len(credentials) == 0 { return nil @@ -58,6 +73,11 @@ func cloneCredentials(credentials []Credential) []Credential { return clones } +// cloneWebAuthnCredential returns a deep copy of the upstream webauthn.Credential +// covering every mutable byte slice it carries (ID, PublicKey, Transport, +// AAGUID, and the four Attestation blobs). Missing any of these would leave a +// shared backing array reachable through both the stored record and the value +// handed back to callers. func cloneWebAuthnCredential(credential webauthn.Credential) webauthn.Credential { clone := credential clone.ID = cloneBytes(credential.ID) @@ -72,6 +92,7 @@ func cloneWebAuthnCredential(credential webauthn.Credential) webauthn.Credential return clone } +// cloneIdentity returns a deep copy of identity, including the Claims map. func cloneIdentity(identity authkit.Identity) authkit.Identity { clone := identity if identity.Claims != nil { @@ -82,6 +103,7 @@ func cloneIdentity(identity authkit.Identity) authkit.Identity { return clone } +// cloneBytes returns a defensive copy of value, returning nil for empty input. func cloneBytes(value []byte) []byte { if len(value) == 0 { return nil @@ -92,6 +114,7 @@ func cloneBytes(value []byte) []byte { return clone } +// cloneStrings returns a defensive copy of values, returning nil for empty input. func cloneStrings(values []string) []string { if len(values) == 0 { return nil diff --git a/proof/passkey/errors.go b/proof/passkey/errors.go index f10e69c..910393d 100644 --- a/proof/passkey/errors.go +++ b/proof/passkey/errors.go @@ -21,14 +21,23 @@ var ( ErrCloneWarning = errors.New("passkey: clone warning") ) +// unauthenticated wraps reason as an [authkit.ErrUnauthenticated] so callers +// can detect auth failures with [errors.Is] without inspecting message strings. func unauthenticated(reason string) error { return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) } +// cloneWarning returns the error that signals an authenticator clone-warning +// rejection. The error chain carries both authkit.ErrUnauthenticated (so the +// caller treats it as a login failure) and ErrCloneWarning (so the caller can +// surface remediation guidance specific to a possibly-cloned credential). func cloneWarning() error { return fmt.Errorf("%w: %w", authkit.ErrUnauthenticated, ErrCloneWarning) } +// internalError wraps err as an [authkit.ErrInternal] annotated with the +// failing operation name so collaborator failures stay distinguishable from +// caller-facing rejections. func internalError(op string, err error) error { return fmt.Errorf("%w: passkey: %s: %w", authkit.ErrInternal, op, err) } diff --git a/proof/passkey/login.go b/proof/passkey/login.go index 948eee7..708095f 100644 --- a/proof/passkey/login.go +++ b/proof/passkey/login.go @@ -74,6 +74,11 @@ func (s *Service) FinishLogin(ctx context.Context, req FinishLoginRequest) (Fini CredentialID: cloneBytes(storedCredential.CredentialID), WebAuthn: cloneWebAuthnCredential(*upstreamCredential), } + // Clone-warning rejection: the authenticator's sign-count signaled a + // possible credential clone. Persist the updated counter state (so the + // next login compares against the latest value) then refuse the login + // without returning an identity. Skipping the persist would let an + // attacker who controls the clone replay against the old counter. if upstreamCredential.Authenticator.CloneWarning { if err := s.store.UpdateCredentialAfterLogin(ctx, credential); err != nil { return FinishLoginResult{}, internalError("update credential after clone warning", err) @@ -83,6 +88,10 @@ func (s *Service) FinishLogin(ctx context.Context, req FinishLoginRequest) (Fini } identity := identityForCredential(s.config.RPID, storedCredential.UserHandle, storedCredential.CredentialID) + // Cross-principal collision defense: a stored credential could in theory + // resolve to a different principal than the one the discoverable handler + // returned (corrupt link, racy re-link). Refuse rather than authenticate + // against a mismatched binding. if err := s.validateLinkedPrincipal(ctx, identity, passkeyUser.user.PrincipalID); err != nil { return FinishLoginResult{}, err } @@ -97,6 +106,9 @@ func (s *Service) FinishLogin(ctx context.Context, req FinishLoginRequest) (Fini }, nil } +// validateLinkedPrincipal resolves identity through the Store and confirms it +// maps to wantPrincipalID. ErrUnresolvedIdentity is returned unchanged so the +// caller can route the credential to onboarding rather than failing closed. func (s *Service) validateLinkedPrincipal( ctx context.Context, identity authkit.Identity, @@ -122,6 +134,10 @@ func (s *Service) validateLinkedPrincipal( return nil } +// discoverableUserHandler is the callback go-webauthn invokes during +// discoverable login to look up the user for a given user handle. It loads the +// user and their credentials from the Store and constructs the WebAuthn user +// adapter with full credential-binding validation. func (s *Service) discoverableUserHandler(ctx context.Context) webauthn.DiscoverableUserHandler { return func(_, userHandle []byte) (webauthn.User, error) { user, err := s.store.FindUserByHandle(ctx, s.config.RPID, userHandle) diff --git a/proof/passkey/registration.go b/proof/passkey/registration.go index b6280b4..1b39b46 100644 --- a/proof/passkey/registration.go +++ b/proof/passkey/registration.go @@ -135,6 +135,9 @@ func (s *Service) FinishRegistration( }, nil } +// registrationUser returns the passkey user for a Begin ceremony. It reuses +// the existing user record when the principal already has one (so additional +// passkeys join the same UserHandle) and otherwise mints a fresh random handle. func (s *Service) registrationUser( ctx context.Context, req BeginRegistrationRequest, @@ -168,6 +171,10 @@ func (s *Service) registrationUser( return cloneUser(user), nil, nil } +// finishRegistrationUser validates the caller-supplied session user from +// FinishRegistration against the relying party and the request's PrincipalID. +// Rejects sessions whose user fields disagree with the begin step so a +// tampered session cannot bind a credential to the wrong principal. func (s *Service) finishRegistrationUser(req FinishRegistrationRequest) (User, error) { user := req.User if user.RPID == "" && user.PrincipalID == "" && len(user.Handle) == 0 { @@ -192,6 +199,9 @@ func (s *Service) finishRegistrationUser(req FinishRegistrationRequest) (User, e return cloneUser(user), nil } +// credentialExclusions builds the WebAuthn excludeCredentials list so an +// authenticator the user has already registered will not be re-registered +// silently (preventing a duplicate that the user thinks is a separate device). func credentialExclusions(user webAuthnUser) []protocol.CredentialDescriptor { credentials := user.WebAuthnCredentials() if len(credentials) == 0 { diff --git a/proof/passkey/service.go b/proof/passkey/service.go index 7bcccc5..dd9ab4f 100644 --- a/proof/passkey/service.go +++ b/proof/passkey/service.go @@ -20,6 +20,8 @@ const ( defaultLoginTimeout = 5 * time.Minute ) +// relyingParty is the narrow seam onto go-webauthn so the package can swap in +// a test double without depending on the concrete *webauthn.WebAuthn type. type relyingParty interface { BeginRegistration(user webauthn.User, opts ...webauthn.RegistrationOption) ( *protocol.CredentialCreation, @@ -39,7 +41,13 @@ type relyingParty interface { ) (webauthn.User, *webauthn.Credential, error) } +// creationParser parses a registration response body into the upstream +// ParsedCredentialCreationData. Pluggable so tests can inject malformed input +// without going through HTTP encoding rules. type creationParser func([]byte) (*protocol.ParsedCredentialCreationData, error) + +// assertionParser parses a login response body into the upstream +// ParsedCredentialAssertionData. Mirror of creationParser for the login path. type assertionParser func([]byte) (*protocol.ParsedCredentialAssertionData, error) // Service runs WebAuthn passkey registration and login ceremonies. @@ -77,6 +85,8 @@ func NewService(store Store, config Config) (*Service, error) { return newService(store, config, rp) } +// newService is the test seam used to inject a fake relyingParty. Production +// code routes through NewService, which constructs the real upstream library. func newService(store Store, config Config, rp relyingParty) (*Service, error) { if store == nil { return nil, errors.New("passkey: store is required") @@ -97,6 +107,8 @@ func newService(store Store, config Config, rp relyingParty) (*Service, error) { }, nil } +// validateConfig rejects Config values missing the relying-party fields that +// the upstream library and ceremony correctness rely on. func validateConfig(config Config) error { if strings.TrimSpace(config.RPID) == "" { return errors.New("passkey: RP ID is required") @@ -122,6 +134,10 @@ func validateConfig(config Config) error { return nil } +// passkeyAuthenticatorSelection returns the AuthenticatorSelection criteria +// the package mandates for every ceremony: resident-key required, user +// verification required. Centralizing the choice keeps Begin and Finish in +// agreement and makes the security posture explicit at one read. func passkeyAuthenticatorSelection() protocol.AuthenticatorSelection { return protocol.AuthenticatorSelection{ RequireResidentKey: protocol.ResidentKeyRequired(), @@ -130,12 +146,21 @@ func passkeyAuthenticatorSelection() protocol.AuthenticatorSelection { } } +// sessionRequiringUserVerification overwrites the session's UserVerification +// flag to Required before the ceremony-finish call. +// +// Defends against a downgrade attack where the verifying parse runs against a +// session whose UserVerification was lowered after the begin step: even if a +// stale or tampered SessionData claims a weaker flag, the upstream library +// receives Required. func sessionRequiringUserVerification(session webauthn.SessionData) webauthn.SessionData { session.UserVerification = protocol.VerificationRequired return session } +// enforcedTimeout produces a TimeoutConfig that the upstream library actually +// honors. Without Enforce=true the library treats the duration as advisory. func enforcedTimeout(timeout time.Duration) webauthn.TimeoutConfig { return webauthn.TimeoutConfig{ Enforce: true, @@ -144,6 +169,8 @@ func enforcedTimeout(timeout time.Duration) webauthn.TimeoutConfig { } } +// registrationTimeout returns the configured registration ceremony timeout +// or the package default when unset. func registrationTimeout(config Config) time.Duration { if config.RegistrationTimeout != 0 { return config.RegistrationTimeout @@ -152,6 +179,8 @@ func registrationTimeout(config Config) time.Duration { return defaultRegistrationTimeout } +// loginTimeout returns the configured login ceremony timeout or the package +// default when unset. func loginTimeout(config Config) time.Duration { if config.LoginTimeout != 0 { return config.LoginTimeout @@ -160,6 +189,9 @@ func loginTimeout(config Config) time.Duration { return defaultLoginTimeout } +// identityForCredential derives the authkit.Identity that names the verified +// passkey ceremony output: Provider scoped to the relying party, Subject as +// base64url(userHandle), CredentialID as base64url(credentialID). func identityForCredential(rpID string, userHandle []byte, credentialID []byte) authkit.Identity { return authkit.Identity{ Provider: providerPrefix + rpID, @@ -168,6 +200,8 @@ func identityForCredential(rpID string, userHandle []byte, credentialID []byte) } } +// credentialIDString renders credentialID as the base64url form used in +// authkit identity subjects and error messages. func credentialIDString(credentialID []byte) string { return base64.RawURLEncoding.EncodeToString(credentialID) } diff --git a/proof/passkey/validations.go b/proof/passkey/validations.go index d691d12..c97e604 100644 --- a/proof/passkey/validations.go +++ b/proof/passkey/validations.go @@ -8,6 +8,10 @@ import ( "github.com/meigma/authkit" ) +// validateRegistrationResult enforces that the Store's RegistrationResult +// matches the ceremony-verified Registration the package handed it. The four +// helpers below isolate the user, credential, and link checks so the failing +// invariant is named in the error. func validateRegistrationResult(result RegistrationResult, expected Registration) error { if err := validateRegistrationUser(result.User, expected.User); err != nil { return err @@ -22,6 +26,8 @@ func validateRegistrationResult(result RegistrationResult, expected Registration return nil } +// validateRegistrationUser confirms the Store returned the same RPID, +// PrincipalID, and Handle as the ceremony produced. func validateRegistrationUser(got User, want User) error { switch { case got.RPID != want.RPID: @@ -35,6 +41,10 @@ func validateRegistrationUser(got User, want User) error { return nil } +// validateRegistrationCredential confirms the stored Credential carries the +// RPID, PrincipalID, UserHandle, CredentialID, and (if set) inner WebAuthn.ID +// the ceremony verified. A mismatch here would let a Store smuggle in a record +// bound to different principal or RP material. func validateRegistrationCredential(got Credential, want Credential) error { switch { case got.RPID != want.RPID: @@ -52,6 +62,9 @@ func validateRegistrationCredential(got Credential, want Credential) error { return nil } +// validateRegistrationLink confirms the Store linked the verified ceremony +// identity to the principal that drove registration. A mismatch here would +// silently link a passkey to the wrong principal. func validateRegistrationLink(got authkit.ExternalIdentity, expected Registration) error { switch { case got.Provider != expected.Identity.Provider: diff --git a/proof/passkey/webauthn_user.go b/proof/passkey/webauthn_user.go index 1fbe81e..3a6b959 100644 --- a/proof/passkey/webauthn_user.go +++ b/proof/passkey/webauthn_user.go @@ -7,11 +7,16 @@ import ( "github.com/go-webauthn/webauthn/webauthn" ) +// webAuthnUser adapts the passkey package's User+Credential pair to the +// webauthn.User interface required by the upstream go-webauthn library. type webAuthnUser struct { user User credentials []Credential } +// newWebAuthnUser validates that every credential is bound to user across RPID, +// PrincipalID, UserHandle, and CredentialID before constructing the adapter, so +// the upstream library never receives a credential that belongs to someone else. func newWebAuthnUser(user User, credentials []Credential) (webAuthnUser, error) { validCredentials, err := credentialsForUser(user, credentials) if err != nil { @@ -49,6 +54,14 @@ func (u webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return credentials } +// credentialsForUser enforces the binding invariant between a passkey User and +// each of its stored Credentials before either reaches the upstream WebAuthn +// library. Every credential must share the user's RPID, PrincipalID, and +// UserHandle, must carry a non-empty CredentialID, and must keep the inner +// WebAuthn.ID consistent with that CredentialID. Failing any of these checks +// would let a stored record bound to a different principal pass through as if +// it were this user's, so the function returns an error rather than skipping +// the offending entry. func credentialsForUser(user User, credentials []Credential) ([]Credential, error) { if len(credentials) == 0 { return nil, nil @@ -90,6 +103,8 @@ func credentialsForUser(user User, credentials []Credential) ([]Credential, erro return valid, nil } +// credentialByID returns a deep copy of the credential in credentials whose +// CredentialID matches credentialID, reporting ok=false when none does. func credentialByID(credentials []Credential, credentialID []byte) (Credential, bool) { for _, credential := range credentials { if bytes.Equal(credential.CredentialID, credentialID) { From 23431f810a729180ab4ac9edaff7dc6839c06807 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 18:28:45 -0700 Subject: [PATCH 3/6] refactor(proof/passkey/session): godocs + inline security comments Add godocs to every private item in the session subpackage: the sessionKind tag, memorySession, the clone family, defaultMemoryOptions, and the memory store's put/take/pruneExpiredLocked helpers. Annotate the wrong-flow rejection in MemoryStore.take: the session is deleted before the kind and expiry checks so a session presented for the wrong ceremony (registration session at login finish, or vice versa) is consumed rather than left available for replay, and the kind-mismatch path returns ErrNotFound rather than a more specific error to avoid leaking whether the ID belonged to the other flow. The session/clone.go header now documents the defensive-copy security rationale: a caller could otherwise mutate the SessionData byte slices, credential parameter list, or extensions map post-Put, silently corrupting the session a Take would return. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/passkey/session/clone.go | 25 +++++++++++++++++++++++++ proof/passkey/session/memory.go | 21 +++++++++++++++++++++ proof/passkey/session/options.go | 3 +++ 3 files changed, 49 insertions(+) diff --git a/proof/passkey/session/clone.go b/proof/passkey/session/clone.go index 272adea..16d91ed 100644 --- a/proof/passkey/session/clone.go +++ b/proof/passkey/session/clone.go @@ -7,6 +7,14 @@ import ( "github.com/meigma/authkit/proof/passkey" ) +// Defensive copies in this file protect ceremony session state on the way into +// the store and on the way out. The upstream webauthn.SessionData carries +// several mutable byte slices, credential parameter lists, and extension maps +// that a caller could otherwise mutate post-Put, silently corrupting the +// session a Take would later return. + +// cloneRegistration returns a deep copy of registration so caller mutations +// after Put cannot corrupt the stored session. func cloneRegistration(registration Registration) Registration { return Registration{ User: cloneUser(registration.User), @@ -14,12 +22,14 @@ func cloneRegistration(registration Registration) Registration { } } +// cloneLogin returns a deep copy of login covering the mutable session data. func cloneLogin(login Login) Login { return Login{ SessionData: cloneSessionData(login.SessionData), } } +// cloneUser returns a deep copy of user, including the Handle byte slice. func cloneUser(user passkey.User) passkey.User { return passkey.User{ RPID: user.RPID, @@ -30,6 +40,10 @@ func cloneUser(user passkey.User) passkey.User { } } +// cloneSessionData returns a deep copy of the upstream webauthn.SessionData +// covering the UserID, AllowedCredentialIDs, CredParams, and Extensions +// fields. Missing any would leave a shared backing array reachable through +// both the stored record and the value handed back to callers. func cloneSessionData(data webauthn.SessionData) webauthn.SessionData { clone := data clone.UserID = cloneBytes(data.UserID) @@ -40,6 +54,9 @@ func cloneSessionData(data webauthn.SessionData) webauthn.SessionData { return clone } +// cloneCredentialParameters returns a defensive copy of the credential +// parameter slice. CredentialParameter is a value type, so a shallow copy of +// the slice is enough. func cloneCredentialParameters(params []protocol.CredentialParameter) []protocol.CredentialParameter { if len(params) == 0 { return nil @@ -51,6 +68,9 @@ func cloneCredentialParameters(params []protocol.CredentialParameter) []protocol return clone } +// cloneAuthenticationExtensions returns a deep copy of the authentication +// extensions map. Values can carry nested maps, byte slices, and slice-of-any +// payloads — each variant is handled by cloneExtensionValue below. func cloneAuthenticationExtensions( extensions protocol.AuthenticationExtensions, ) protocol.AuthenticationExtensions { @@ -66,6 +86,9 @@ func cloneAuthenticationExtensions( return clone } +// cloneExtensionValue returns a deep copy of value covering the JSON-decoded +// shapes WebAuthn extension payloads can carry. Other scalars are returned +// as-is because they have no mutable backing array. func cloneExtensionValue(value any) any { switch typed := value.(type) { case []byte: @@ -93,6 +116,7 @@ func cloneExtensionValue(value any) any { } } +// cloneBytes returns a defensive copy of value, returning nil for empty input. func cloneBytes(value []byte) []byte { if len(value) == 0 { return nil @@ -104,6 +128,7 @@ func cloneBytes(value []byte) []byte { return clone } +// cloneByteSlices returns a deep copy of values, returning nil for empty input. func cloneByteSlices(values [][]byte) [][]byte { if len(values) == 0 { return nil diff --git a/proof/passkey/session/memory.go b/proof/passkey/session/memory.go index 22a56e4..b7c601b 100644 --- a/proof/passkey/session/memory.go +++ b/proof/passkey/session/memory.go @@ -14,6 +14,9 @@ const ( maxIDAttempts = 4 ) +// sessionKind tags a stored session with the ceremony flow it belongs to so a +// registration session can never be redeemed at the login finish step (and +// vice versa). Without this tag a single ID could be replayed across flows. type sessionKind string const ( @@ -28,6 +31,8 @@ type MemoryStore struct { sessions map[string]memorySession } +// memorySession is the in-memory record for one ceremony's session data, +// keyed by the kind it belongs to so cross-flow redemption fails fast. type memorySession struct { kind sessionKind registration Registration @@ -110,6 +115,9 @@ func (s *MemoryStore) TakeLogin(ctx context.Context, id string) (Login, error) { return cloneLogin(session.login), nil } +// put inserts session under a freshly-generated opaque ID, retrying on the +// astronomically unlikely event of a collision and proactively pruning expired +// entries so memory does not grow unbounded across long-running processes. func (s *MemoryStore) put(session memorySession) (string, error) { s.mu.Lock() defer s.mu.Unlock() @@ -132,6 +140,13 @@ func (s *MemoryStore) put(session memorySession) (string, error) { return "", errors.New("passkey/session: generate unique session ID") } +// take redeems the session identified by id once and only once. +// +// The delete happens before the kind and expiry checks so a session presented +// for the wrong flow (registration session at login finish, or vice versa) +// or an expired session is consumed rather than left available for a replay. +// Returning ErrNotFound for a kind mismatch (rather than a more specific +// error) avoids leaking whether the ID belonged to the other flow. func (s *MemoryStore) take(id string, kind sessionKind) (memorySession, error) { s.mu.Lock() defer s.mu.Unlock() @@ -152,6 +167,9 @@ func (s *MemoryStore) take(id string, kind sessionKind) (memorySession, error) { return session, nil } +// pruneExpiredLocked drops every session past its expiration. Called under +// the store mutex during put so memory cannot grow unbounded even when callers +// never finish their ceremonies. func (s *MemoryStore) pruneExpiredLocked(now time.Time) { for id, session := range s.sessions { if sessionExpired(session.expiresAt, now) { @@ -160,10 +178,13 @@ func (s *MemoryStore) pruneExpiredLocked(now time.Time) { } } +// sessionExpired treats both a zero expiration and a non-future expiration as +// expired so a session can never be redeemed past its window. func sessionExpired(expiresAt time.Time, now time.Time) bool { return expiresAt.IsZero() || !expiresAt.After(now) } +// newSessionID returns a cryptographically-random base64url session ID. func newSessionID() (string, error) { raw := make([]byte, sessionIDBytes) if _, err := rand.Read(raw); err != nil { diff --git a/proof/passkey/session/options.go b/proof/passkey/session/options.go index 303cef3..2bd37ba 100644 --- a/proof/passkey/session/options.go +++ b/proof/passkey/session/options.go @@ -5,10 +5,13 @@ import "time" // MemoryOption configures a MemoryStore. type MemoryOption func(*memoryOptions) +// memoryOptions is the resolved configuration consumed by a MemoryStore. type memoryOptions struct { clock func() time.Time } +// defaultMemoryOptions returns the baseline options applied before any +// caller-supplied MemoryOption. func defaultMemoryOptions() memoryOptions { return memoryOptions{ clock: time.Now, From 6546af88f8f681d13f11dd40bd30652203bf7276 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 18:32:12 -0700 Subject: [PATCH 4/6] test(proof/passkey): split service_test.go per ceremony Lift the 1115-LOC service_test.go into four files mirroring the production layout: service_test.go (config validation + ceremony timeout enforcement), registration_test.go (10 Begin/Finish registration tests), login_test.go (11 Begin/Finish login tests), helpers_test.go (consts + newTestService + testConfig/User/Credential fixtures + fakeRelyingParty + fakeStore + handleKey/identityKey). No test logic changes; each function is lifted verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/passkey/helpers_test.go | 290 +++++++++ proof/passkey/login_test.go | 340 ++++++++++ proof/passkey/registration_test.go | 373 +++++++++++ proof/passkey/service_test.go | 968 ----------------------------- 4 files changed, 1003 insertions(+), 968 deletions(-) create mode 100644 proof/passkey/helpers_test.go create mode 100644 proof/passkey/login_test.go create mode 100644 proof/passkey/registration_test.go diff --git a/proof/passkey/helpers_test.go b/proof/passkey/helpers_test.go new file mode 100644 index 0000000..c11a0d5 --- /dev/null +++ b/proof/passkey/helpers_test.go @@ -0,0 +1,290 @@ +package passkey + +import ( + "context" + "testing" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +const ( + testRPID = "example.test" + testPrincipalID = "principal_123" +) + +func newTestService(t *testing.T, store Store, rp *fakeRelyingParty) *Service { + t.Helper() + + service, err := newService(store, testConfig(), rp) + require.NoError(t, err) + + return service +} + +func testConfig() Config { + return Config{ + RPID: testRPID, + RPDisplayName: "Authkit Test", + RPOrigins: []string{"https://example.test"}, + } +} + +func testUser() User { + return User{ + RPID: testRPID, + PrincipalID: testPrincipalID, + Handle: []byte("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + Name: "ada@example.test", + DisplayName: "Ada Lovelace", + } +} + +func testCredential(user User) Credential { + credentialID := []byte("credential-1") + + return Credential{ + RPID: user.RPID, + PrincipalID: user.PrincipalID, + UserHandle: user.Handle, + CredentialID: credentialID, + WebAuthn: webauthn.Credential{ + ID: credentialID, + PublicKey: []byte("public-key"), + }, + } +} + +type fakeRelyingParty struct { + creation *protocol.CredentialCreation + registrationSession *webauthn.SessionData + registrationUser webauthn.User + + createdCredential *webauthn.Credential + createCredentialUser webauthn.User + createCredentialSession webauthn.SessionData + createCredentialErr error + + assertion *protocol.CredentialAssertion + loginSession *webauthn.SessionData + + validateUserHandle []byte + validatedHandlerUser webauthn.User + validatedCredential *webauthn.Credential + validateSession webauthn.SessionData + validateCredentialErr error +} + +func newFakeRelyingParty() *fakeRelyingParty { + return &fakeRelyingParty{ + creation: &protocol.CredentialCreation{}, + registrationSession: &webauthn.SessionData{Challenge: "registration-challenge"}, + assertion: &protocol.CredentialAssertion{}, + loginSession: &webauthn.SessionData{Challenge: "login-challenge"}, + } +} + +func (f *fakeRelyingParty) BeginRegistration( + user webauthn.User, + opts ...webauthn.RegistrationOption, +) (*protocol.CredentialCreation, *webauthn.SessionData, error) { + f.registrationUser = user + for _, opt := range opts { + opt(&f.creation.Response) + } + session := *f.registrationSession + session.UserVerification = f.creation.Response.AuthenticatorSelection.UserVerification + f.registrationSession = &session + + return f.creation, f.registrationSession, nil +} + +func (f *fakeRelyingParty) CreateCredential( + user webauthn.User, + session webauthn.SessionData, + _ *protocol.ParsedCredentialCreationData, +) (*webauthn.Credential, error) { + f.createCredentialUser = user + f.createCredentialSession = session + if f.createCredentialErr != nil { + return nil, f.createCredentialErr + } + + return f.createdCredential, nil +} + +func (f *fakeRelyingParty) BeginDiscoverableLogin( + opts ...webauthn.LoginOption, +) (*protocol.CredentialAssertion, *webauthn.SessionData, error) { + for _, opt := range opts { + opt(&f.assertion.Response) + } + session := *f.loginSession + session.UserVerification = f.assertion.Response.UserVerification + f.loginSession = &session + + return f.assertion, f.loginSession, nil +} + +func (f *fakeRelyingParty) ValidatePasskeyLogin( + handler webauthn.DiscoverableUserHandler, + session webauthn.SessionData, + _ *protocol.ParsedCredentialAssertionData, +) (webauthn.User, *webauthn.Credential, error) { + f.validateSession = session + if f.validateCredentialErr != nil { + return nil, nil, f.validateCredentialErr + } + + user, err := handler([]byte("credential-1"), f.validateUserHandle) + if err != nil { + return nil, nil, err + } + f.validatedHandlerUser = user + + return user, f.validatedCredential, nil +} + +type fakeStore struct { + usersByPrincipal map[string]User + usersByHandle map[string]User + credentials map[string][]Credential + links map[string]authkit.ExternalIdentity + + createdRegistrations []Registration + updatedCredential *Credential + calls []string + + createRegistrationErr error + createRegistrationResult func(RegistrationResult) RegistrationResult + updateCredentialErr error + findUserByHandleErr error + resolveIdentityErr error +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + usersByPrincipal: make(map[string]User), + usersByHandle: make(map[string]User), + credentials: make(map[string][]Credential), + links: make(map[string]authkit.ExternalIdentity), + } +} + +func (s *fakeStore) ResolveIdentity( + _ context.Context, + identity authkit.Identity, +) (*authkit.Principal, error) { + if s.resolveIdentityErr != nil { + return nil, s.resolveIdentityErr + } + + link, ok := s.links[identityKey(identity.Provider, identity.Subject)] + if !ok { + return nil, authkit.ErrUnresolvedIdentity + } + + return &authkit.Principal{ + ID: link.PrincipalID, + }, nil +} + +func (s *fakeStore) FindUserByPrincipal(_ context.Context, rpID string, principalID string) (User, error) { + user, ok := s.usersByPrincipal[rpID+"\x00"+principalID] + if !ok { + return User{}, ErrUserNotFound + } + + return cloneUser(user), nil +} + +func (s *fakeStore) FindUserByHandle(_ context.Context, rpID string, handle []byte) (User, error) { + if s.findUserByHandleErr != nil { + return User{}, s.findUserByHandleErr + } + + user, ok := s.usersByHandle[handleKey(rpID, handle)] + if !ok { + return User{}, ErrUserNotFound + } + + return cloneUser(user), nil +} + +func (s *fakeStore) ListCredentials(_ context.Context, rpID string, userHandle []byte) ([]Credential, error) { + return cloneCredentials(s.credentials[handleKey(rpID, userHandle)]), nil +} + +func (s *fakeStore) CreateRegistration( + _ context.Context, + registration Registration, +) (RegistrationResult, error) { + s.calls = append(s.calls, "createRegistration") + if s.createRegistrationErr != nil { + return RegistrationResult{}, s.createRegistrationErr + } + + cloned := cloneRegistration(registration) + s.createdRegistrations = append(s.createdRegistrations, cloned) + s.putUser(cloned.User) + s.putCredential(cloned.Credential) + link := authkit.ExternalIdentity{ + Provider: cloned.Identity.Provider, + Subject: cloned.Identity.Subject, + PrincipalID: cloned.User.PrincipalID, + } + s.links[identityKey(link.Provider, link.Subject)] = link + + result := RegistrationResult{ + User: cloneUser(cloned.User), + Credential: cloneCredential(cloned.Credential), + Link: link, + } + if s.createRegistrationResult != nil { + result = s.createRegistrationResult(result) + } + + return result, nil +} + +func (s *fakeStore) UpdateCredentialAfterLogin(_ context.Context, credential Credential) error { + if s.updateCredentialErr != nil { + return s.updateCredentialErr + } + + clone := cloneCredential(credential) + s.updatedCredential = &clone + + return nil +} + +func (s *fakeStore) putUser(user User) { + cloned := cloneUser(user) + s.usersByPrincipal[cloned.RPID+"\x00"+cloned.PrincipalID] = cloned + s.usersByHandle[handleKey(cloned.RPID, cloned.Handle)] = cloned +} + +func (s *fakeStore) putCredential(credential Credential) { + cloned := cloneCredential(credential) + key := handleKey(cloned.RPID, cloned.UserHandle) + s.credentials[key] = append(s.credentials[key], cloned) +} + +func (s *fakeStore) putLink(identity authkit.Identity, principalID string) { + s.links[identityKey(identity.Provider, identity.Subject)] = authkit.ExternalIdentity{ + Provider: identity.Provider, + Subject: identity.Subject, + PrincipalID: principalID, + } +} + +func handleKey(rpID string, handle []byte) string { + return rpID + "\x00" + string(handle) +} + +func identityKey(provider string, subject string) string { + return provider + "\x00" + subject +} diff --git a/proof/passkey/login_test.go b/proof/passkey/login_test.go new file mode 100644 index 0000000..7c62c88 --- /dev/null +++ b/proof/passkey/login_test.go @@ -0,0 +1,340 @@ +package passkey + +import ( + "context" + "encoding/base64" + "errors" + "testing" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +func TestBeginLoginReturnsDiscoverableAssertion(t *testing.T) { + rp := newFakeRelyingParty() + service := newTestService(t, newFakeStore(), rp) + + result, err := service.BeginLogin(context.Background(), BeginLoginRequest{}) + + require.NoError(t, err) + assert.Same(t, rp.assertion, result.Assertion) + assert.Equal(t, *rp.loginSession, result.SessionData) + assert.Equal(t, protocol.VerificationRequired, result.SessionData.UserVerification) + assert.Equal(t, protocol.VerificationRequired, result.Assertion.Response.UserVerification) +} + +func TestFinishLoginUpdatesCredentialAndReturnsIdentity(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + Authenticator: webauthn.Authenticator{ + SignCount: 7, + }, + } + service := newTestService(t, store, rp) + response := []byte(`{"id":"credential-1"}`) + service.parseAssertionResponse = func(data []byte) (*protocol.ParsedCredentialAssertionData, error) { + assert.Equal(t, response, data) + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: response, + }) + + require.NoError(t, err) + require.NotNil(t, store.updatedCredential) + assert.Equal(t, []byte("credential-1"), store.updatedCredential.CredentialID) + assert.Equal(t, uint32(7), store.updatedCredential.WebAuthn.Authenticator.SignCount) + assert.Equal(t, *store.updatedCredential, result.Credential) + assert.Equal(t, user, result.User) + assert.Equal(t, authkit.Identity{ + Provider: "passkey:" + testRPID, + Subject: base64.RawURLEncoding.EncodeToString(user.Handle), + CredentialID: base64.RawURLEncoding.EncodeToString([]byte("credential-1")), + }, result.Identity) + require.NotNil(t, rp.validatedHandlerUser) + assert.Equal(t, user.Handle, rp.validatedHandlerUser.WebAuthnID()) + assert.Equal(t, webauthn.SessionData{ + Challenge: "login-challenge", + UserVerification: protocol.VerificationRequired, + }, rp.validateSession) +} + +func TestFinishLoginOverridesDowngradedUserVerification(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{ + Challenge: "login-challenge", + UserVerification: protocol.VerificationDiscouraged, + }, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Equal(t, protocol.VerificationRequired, rp.validateSession.UserVerification) +} + +func TestFinishLoginWrapsCredentialUpdateFailures(t *testing.T) { + updateErr := errors.New("update failed") + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) + store.updateCredentialErr = updateErr + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, updateErr) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsCloneWarning(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + Authenticator: webauthn.Authenticator{ + CloneWarning: true, + }, + } + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + require.ErrorIs(t, err, ErrCloneWarning) + require.NotNil(t, store.updatedCredential) + assert.True(t, store.updatedCredential.WebAuthn.Authenticator.CloneWarning) + assert.Empty(t, result) +} + +func TestFinishLoginReturnsInternalWhenCloneWarningUpdateFails(t *testing.T) { + updateErr := errors.New("update failed") + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.updateCredentialErr = updateErr + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + Authenticator: webauthn.Authenticator{ + CloneWarning: true, + }, + } + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, updateErr) + require.NotErrorIs(t, err, ErrCloneWarning) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsMismatchedStoredCredential(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: "principal_other", + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + assert.Nil(t, rp.validatedHandlerUser) + assert.Nil(t, store.updatedCredential) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsValidatedCredentialNotLoadedFromStore(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-2")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-2"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.NotErrorIs(t, err, authkit.ErrUnresolvedIdentity) + assert.Nil(t, store.updatedCredential) + assert.Empty(t, result) +} + +func TestFinishLoginReturnsUnresolvedIdentityForUnlinkedCredential(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) + require.NotErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsDivergedIdentityLink(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), "principal_other") + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + assert.Nil(t, store.updatedCredential) + assert.Empty(t, result) +} + +func TestFinishLoginWrapsDiscoverableLookupFailures(t *testing.T) { + lookupErr := errors.New("lookup failed") + store := newFakeStore() + store.findUserByHandleErr = lookupErr + rp := newFakeRelyingParty() + rp.validateUserHandle = []byte("user-handle") + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, lookupErr) + assert.Empty(t, result) +} diff --git a/proof/passkey/registration_test.go b/proof/passkey/registration_test.go new file mode 100644 index 0000000..ba64a76 --- /dev/null +++ b/proof/passkey/registration_test.go @@ -0,0 +1,373 @@ +package passkey + +import ( + "context" + "errors" + "testing" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +func TestBeginRegistrationGeneratesSessionUser(t *testing.T) { + store := newFakeStore() + rp := newFakeRelyingParty() + service := newTestService(t, store, rp) + + result, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ + PrincipalID: testPrincipalID, + Name: "ada@example.test", + DisplayName: "Ada Lovelace", + }) + + require.NoError(t, err) + assert.Same(t, rp.creation, result.Creation) + assert.Equal(t, *rp.registrationSession, result.SessionData) + assert.Empty(t, store.usersByPrincipal) + assert.Empty(t, store.createdRegistrations) + assert.Equal(t, testRPID, result.User.RPID) + assert.Equal(t, testPrincipalID, result.User.PrincipalID) + assert.Len(t, result.User.Handle, userHandleBytes) + assert.Equal(t, "ada@example.test", result.User.Name) + assert.Equal(t, "Ada Lovelace", result.User.DisplayName) + assert.Equal(t, protocol.VerificationRequired, result.SessionData.UserVerification) + assert.Equal(t, protocol.VerificationRequired, rp.creation.Response.AuthenticatorSelection.UserVerification) + assert.Equal(t, protocol.ResidentKeyRequirementRequired, rp.creation.Response.AuthenticatorSelection.ResidentKey) + assert.Equal(t, result.User.Handle, rp.registrationUser.WebAuthnID()) + assert.Empty(t, rp.registrationUser.WebAuthnCredentials()) + assert.Empty(t, result.Creation.Response.CredentialExcludeList) +} + +func TestBeginRegistrationReusesPasskeyUserAndCredentials(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + service := newTestService(t, store, rp) + + result, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ + PrincipalID: testPrincipalID, + Name: "ignored@example.test", + DisplayName: "Ignored", + }) + + require.NoError(t, err) + assert.Empty(t, store.createdRegistrations) + assert.Equal(t, user, result.User) + credentials := rp.registrationUser.WebAuthnCredentials() + require.Len(t, credentials, 1) + assert.Equal(t, []byte("credential-1"), credentials[0].ID) + assert.Equal(t, []byte("public-key"), credentials[0].PublicKey) + exclusions := result.Creation.Response.CredentialExcludeList + require.Len(t, exclusions, 1) + assert.Equal(t, protocol.PublicKeyCredentialType, exclusions[0].Type) + assert.Equal(t, []byte("credential-1"), []byte(exclusions[0].CredentialID)) +} + +func TestFinishRegistrationStoresCredentialAndReturnsIdentity(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + Authenticator: webauthn.Authenticator{ + SignCount: 2, + }, + } + service := newTestService(t, store, rp) + response := []byte(`{"id":"credential-1"}`) + identity := identityForCredential(testRPID, user.Handle, rp.createdCredential.ID) + service.parseCreationResponse = func(data []byte) (*protocol.ParsedCredentialCreationData, error) { + assert.Equal(t, response, data) + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: response, + }) + + require.NoError(t, err) + assert.Equal(t, []string{"createRegistration"}, store.calls) + require.Len(t, store.createdRegistrations, 1) + createdRegistration := store.createdRegistrations[0] + assert.Equal(t, user, createdRegistration.User) + assert.Equal(t, identity, createdRegistration.Identity) + createdCredential := createdRegistration.Credential + assert.Equal(t, testRPID, createdCredential.RPID) + assert.Equal(t, testPrincipalID, createdCredential.PrincipalID) + assert.Equal(t, user.Handle, createdCredential.UserHandle) + assert.Equal(t, []byte("credential-1"), createdCredential.CredentialID) + assert.Equal(t, []byte("public-key"), createdCredential.WebAuthn.PublicKey) + assert.Equal(t, uint32(2), createdCredential.WebAuthn.Authenticator.SignCount) + assert.Equal(t, createdCredential, result.Credential) + assert.Equal(t, identity, result.Identity) + assert.Equal(t, authkit.ExternalIdentity{ + Provider: identity.Provider, + Subject: identity.Subject, + PrincipalID: testPrincipalID, + }, result.Link) + assert.Equal(t, user.Handle, rp.createCredentialUser.WebAuthnID()) + assert.Equal(t, webauthn.SessionData{ + Challenge: "registration-challenge", + UserVerification: protocol.VerificationRequired, + }, rp.createCredentialSession) +} + +func TestFinishRegistrationRejectsMismatchedRegistrationResult(t *testing.T) { + tests := []struct { + name string + mutate func(RegistrationResult) RegistrationResult + }{ + { + name: "user RP ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.User.RPID = "other.example.test" + return result + }, + }, + { + name: "user principal", + mutate: func(result RegistrationResult) RegistrationResult { + result.User.PrincipalID = "other-principal" + return result + }, + }, + { + name: "user handle", + mutate: func(result RegistrationResult) RegistrationResult { + result.User.Handle = []byte("other-handle") + return result + }, + }, + { + name: "credential RP ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.RPID = "other.example.test" + return result + }, + }, + { + name: "credential principal", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.PrincipalID = "other-principal" + return result + }, + }, + { + name: "credential user handle", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.UserHandle = []byte("other-handle") + return result + }, + }, + { + name: "credential ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.CredentialID = []byte("other-credential") + return result + }, + }, + { + name: "credential WebAuthn ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.WebAuthn.ID = []byte("other-credential") + return result + }, + }, + { + name: "link provider", + mutate: func(result RegistrationResult) RegistrationResult { + result.Link.Provider = "passkey:other.example.test" + return result + }, + }, + { + name: "link subject", + mutate: func(result RegistrationResult) RegistrationResult { + result.Link.Subject = "other-subject" + return result + }, + }, + { + name: "link principal", + mutate: func(result RegistrationResult) RegistrationResult { + result.Link.PrincipalID = "other-principal" + return result + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.createRegistrationResult = tt.mutate + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + } + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + assert.Empty(t, result) + }) + } +} + +func TestFinishRegistrationOverridesDowngradedUserVerification(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{ + Challenge: "registration-challenge", + UserVerification: protocol.VerificationDiscouraged, + }, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Equal(t, protocol.VerificationRequired, rp.createCredentialSession.UserVerification) +} + +func TestFinishRegistrationReturnsDuplicateCredential(t *testing.T) { + store := newFakeStore() + store.putUser(testUser()) + store.createRegistrationErr = ErrCredentialExists + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: testUser(), + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, ErrCredentialExists) + require.NotErrorIs(t, err, authkit.ErrInternal) + assert.Empty(t, result) +} + +func TestFinishRegistrationReturnsDuplicateUser(t *testing.T) { + store := newFakeStore() + store.createRegistrationErr = ErrUserExists + user := testUser() + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, ErrUserExists) + require.NotErrorIs(t, err, authkit.ErrInternal) + assert.Empty(t, result) +} + +func TestFinishRegistrationWrapsStoreFailures(t *testing.T) { + storeErr := errors.New("store failed") + store := newFakeStore() + store.putUser(testUser()) + store.createRegistrationErr = storeErr + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: testUser(), + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, storeErr) + assert.Empty(t, store.createdRegistrations) + assert.Empty(t, result) +} + +func TestFinishRegistrationRejectsMalformedResponses(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + service := newTestService(t, store, newFakeRelyingParty()) + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`not-json`), + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} + +func TestFinishRegistrationRequiresSessionUser(t *testing.T) { + service := newTestService(t, newFakeStore(), newFakeRelyingParty()) + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} diff --git a/proof/passkey/service_test.go b/proof/passkey/service_test.go index 0373792..d8330d1 100644 --- a/proof/passkey/service_test.go +++ b/proof/passkey/service_test.go @@ -2,22 +2,11 @@ package passkey import ( "context" - "encoding/base64" - "errors" "testing" "time" - "github.com/go-webauthn/webauthn/protocol" - "github.com/go-webauthn/webauthn/webauthn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/meigma/authkit" -) - -const ( - testRPID = "example.test" - testPrincipalID = "principal_123" ) func TestNewServiceValidatesConfig(t *testing.T) { @@ -156,960 +145,3 @@ func TestNewServiceAcceptsCustomCeremonyTimeouts(t *testing.T) { assert.Equal(t, int(loginTimeout.Milliseconds()), login.Assertion.Response.Timeout) assert.False(t, login.SessionData.Expires.IsZero()) } - -func TestBeginRegistrationGeneratesSessionUser(t *testing.T) { - store := newFakeStore() - rp := newFakeRelyingParty() - service := newTestService(t, store, rp) - - result, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ - PrincipalID: testPrincipalID, - Name: "ada@example.test", - DisplayName: "Ada Lovelace", - }) - - require.NoError(t, err) - assert.Same(t, rp.creation, result.Creation) - assert.Equal(t, *rp.registrationSession, result.SessionData) - assert.Empty(t, store.usersByPrincipal) - assert.Empty(t, store.createdRegistrations) - assert.Equal(t, testRPID, result.User.RPID) - assert.Equal(t, testPrincipalID, result.User.PrincipalID) - assert.Len(t, result.User.Handle, userHandleBytes) - assert.Equal(t, "ada@example.test", result.User.Name) - assert.Equal(t, "Ada Lovelace", result.User.DisplayName) - assert.Equal(t, protocol.VerificationRequired, result.SessionData.UserVerification) - assert.Equal(t, protocol.VerificationRequired, rp.creation.Response.AuthenticatorSelection.UserVerification) - assert.Equal(t, protocol.ResidentKeyRequirementRequired, rp.creation.Response.AuthenticatorSelection.ResidentKey) - assert.Equal(t, result.User.Handle, rp.registrationUser.WebAuthnID()) - assert.Empty(t, rp.registrationUser.WebAuthnCredentials()) - assert.Empty(t, result.Creation.Response.CredentialExcludeList) -} - -func TestBeginRegistrationReusesPasskeyUserAndCredentials(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(Credential{ - RPID: testRPID, - PrincipalID: testPrincipalID, - UserHandle: user.Handle, - CredentialID: []byte("credential-1"), - WebAuthn: webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - }, - }) - rp := newFakeRelyingParty() - service := newTestService(t, store, rp) - - result, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ - PrincipalID: testPrincipalID, - Name: "ignored@example.test", - DisplayName: "Ignored", - }) - - require.NoError(t, err) - assert.Empty(t, store.createdRegistrations) - assert.Equal(t, user, result.User) - credentials := rp.registrationUser.WebAuthnCredentials() - require.Len(t, credentials, 1) - assert.Equal(t, []byte("credential-1"), credentials[0].ID) - assert.Equal(t, []byte("public-key"), credentials[0].PublicKey) - exclusions := result.Creation.Response.CredentialExcludeList - require.Len(t, exclusions, 1) - assert.Equal(t, protocol.PublicKeyCredentialType, exclusions[0].Type) - assert.Equal(t, []byte("credential-1"), []byte(exclusions[0].CredentialID)) -} - -func TestFinishRegistrationStoresCredentialAndReturnsIdentity(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - rp := newFakeRelyingParty() - rp.createdCredential = &webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - Authenticator: webauthn.Authenticator{ - SignCount: 2, - }, - } - service := newTestService(t, store, rp) - response := []byte(`{"id":"credential-1"}`) - identity := identityForCredential(testRPID, user.Handle, rp.createdCredential.ID) - service.parseCreationResponse = func(data []byte) (*protocol.ParsedCredentialCreationData, error) { - assert.Equal(t, response, data) - return &protocol.ParsedCredentialCreationData{}, nil - } - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - User: user, - SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, - Response: response, - }) - - require.NoError(t, err) - assert.Equal(t, []string{"createRegistration"}, store.calls) - require.Len(t, store.createdRegistrations, 1) - createdRegistration := store.createdRegistrations[0] - assert.Equal(t, user, createdRegistration.User) - assert.Equal(t, identity, createdRegistration.Identity) - createdCredential := createdRegistration.Credential - assert.Equal(t, testRPID, createdCredential.RPID) - assert.Equal(t, testPrincipalID, createdCredential.PrincipalID) - assert.Equal(t, user.Handle, createdCredential.UserHandle) - assert.Equal(t, []byte("credential-1"), createdCredential.CredentialID) - assert.Equal(t, []byte("public-key"), createdCredential.WebAuthn.PublicKey) - assert.Equal(t, uint32(2), createdCredential.WebAuthn.Authenticator.SignCount) - assert.Equal(t, createdCredential, result.Credential) - assert.Equal(t, identity, result.Identity) - assert.Equal(t, authkit.ExternalIdentity{ - Provider: identity.Provider, - Subject: identity.Subject, - PrincipalID: testPrincipalID, - }, result.Link) - assert.Equal(t, user.Handle, rp.createCredentialUser.WebAuthnID()) - assert.Equal(t, webauthn.SessionData{ - Challenge: "registration-challenge", - UserVerification: protocol.VerificationRequired, - }, rp.createCredentialSession) -} - -func TestFinishRegistrationRejectsMismatchedRegistrationResult(t *testing.T) { - tests := []struct { - name string - mutate func(RegistrationResult) RegistrationResult - }{ - { - name: "user RP ID", - mutate: func(result RegistrationResult) RegistrationResult { - result.User.RPID = "other.example.test" - return result - }, - }, - { - name: "user principal", - mutate: func(result RegistrationResult) RegistrationResult { - result.User.PrincipalID = "other-principal" - return result - }, - }, - { - name: "user handle", - mutate: func(result RegistrationResult) RegistrationResult { - result.User.Handle = []byte("other-handle") - return result - }, - }, - { - name: "credential RP ID", - mutate: func(result RegistrationResult) RegistrationResult { - result.Credential.RPID = "other.example.test" - return result - }, - }, - { - name: "credential principal", - mutate: func(result RegistrationResult) RegistrationResult { - result.Credential.PrincipalID = "other-principal" - return result - }, - }, - { - name: "credential user handle", - mutate: func(result RegistrationResult) RegistrationResult { - result.Credential.UserHandle = []byte("other-handle") - return result - }, - }, - { - name: "credential ID", - mutate: func(result RegistrationResult) RegistrationResult { - result.Credential.CredentialID = []byte("other-credential") - return result - }, - }, - { - name: "credential WebAuthn ID", - mutate: func(result RegistrationResult) RegistrationResult { - result.Credential.WebAuthn.ID = []byte("other-credential") - return result - }, - }, - { - name: "link provider", - mutate: func(result RegistrationResult) RegistrationResult { - result.Link.Provider = "passkey:other.example.test" - return result - }, - }, - { - name: "link subject", - mutate: func(result RegistrationResult) RegistrationResult { - result.Link.Subject = "other-subject" - return result - }, - }, - { - name: "link principal", - mutate: func(result RegistrationResult) RegistrationResult { - result.Link.PrincipalID = "other-principal" - return result - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.createRegistrationResult = tt.mutate - rp := newFakeRelyingParty() - rp.createdCredential = &webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - } - service := newTestService(t, store, rp) - service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { - return &protocol.ParsedCredentialCreationData{}, nil - } - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - User: user, - SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - assert.Empty(t, result) - }) - } -} - -func TestFinishRegistrationOverridesDowngradedUserVerification(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - rp := newFakeRelyingParty() - rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { - return &protocol.ParsedCredentialCreationData{}, nil - } - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - User: user, - SessionData: webauthn.SessionData{ - Challenge: "registration-challenge", - UserVerification: protocol.VerificationDiscouraged, - }, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.NoError(t, err) - assert.NotEmpty(t, result) - assert.Equal(t, protocol.VerificationRequired, rp.createCredentialSession.UserVerification) -} - -func TestFinishRegistrationReturnsDuplicateCredential(t *testing.T) { - store := newFakeStore() - store.putUser(testUser()) - store.createRegistrationErr = ErrCredentialExists - rp := newFakeRelyingParty() - rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { - return &protocol.ParsedCredentialCreationData{}, nil - } - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - User: testUser(), - SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, ErrCredentialExists) - require.NotErrorIs(t, err, authkit.ErrInternal) - assert.Empty(t, result) -} - -func TestFinishRegistrationReturnsDuplicateUser(t *testing.T) { - store := newFakeStore() - store.createRegistrationErr = ErrUserExists - user := testUser() - rp := newFakeRelyingParty() - rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { - return &protocol.ParsedCredentialCreationData{}, nil - } - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - User: user, - SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, ErrUserExists) - require.NotErrorIs(t, err, authkit.ErrInternal) - assert.Empty(t, result) -} - -func TestFinishRegistrationWrapsStoreFailures(t *testing.T) { - storeErr := errors.New("store failed") - store := newFakeStore() - store.putUser(testUser()) - store.createRegistrationErr = storeErr - rp := newFakeRelyingParty() - rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { - return &protocol.ParsedCredentialCreationData{}, nil - } - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - User: testUser(), - SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - require.ErrorIs(t, err, storeErr) - assert.Empty(t, store.createdRegistrations) - assert.Empty(t, result) -} - -func TestFinishRegistrationRejectsMalformedResponses(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - service := newTestService(t, store, newFakeRelyingParty()) - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - User: user, - SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, - Response: []byte(`not-json`), - }) - - require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Empty(t, result) -} - -func TestFinishRegistrationRequiresSessionUser(t *testing.T) { - service := newTestService(t, newFakeStore(), newFakeRelyingParty()) - - result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ - PrincipalID: testPrincipalID, - SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Empty(t, result) -} - -func TestBeginLoginReturnsDiscoverableAssertion(t *testing.T) { - rp := newFakeRelyingParty() - service := newTestService(t, newFakeStore(), rp) - - result, err := service.BeginLogin(context.Background(), BeginLoginRequest{}) - - require.NoError(t, err) - assert.Same(t, rp.assertion, result.Assertion) - assert.Equal(t, *rp.loginSession, result.SessionData) - assert.Equal(t, protocol.VerificationRequired, result.SessionData.UserVerification) - assert.Equal(t, protocol.VerificationRequired, result.Assertion.Response.UserVerification) -} - -func TestFinishLoginUpdatesCredentialAndReturnsIdentity(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(Credential{ - RPID: testRPID, - PrincipalID: testPrincipalID, - UserHandle: user.Handle, - CredentialID: []byte("credential-1"), - WebAuthn: webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - }, - }) - store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - Authenticator: webauthn.Authenticator{ - SignCount: 7, - }, - } - service := newTestService(t, store, rp) - response := []byte(`{"id":"credential-1"}`) - service.parseAssertionResponse = func(data []byte) (*protocol.ParsedCredentialAssertionData, error) { - assert.Equal(t, response, data) - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: response, - }) - - require.NoError(t, err) - require.NotNil(t, store.updatedCredential) - assert.Equal(t, []byte("credential-1"), store.updatedCredential.CredentialID) - assert.Equal(t, uint32(7), store.updatedCredential.WebAuthn.Authenticator.SignCount) - assert.Equal(t, *store.updatedCredential, result.Credential) - assert.Equal(t, user, result.User) - assert.Equal(t, authkit.Identity{ - Provider: "passkey:" + testRPID, - Subject: base64.RawURLEncoding.EncodeToString(user.Handle), - CredentialID: base64.RawURLEncoding.EncodeToString([]byte("credential-1")), - }, result.Identity) - require.NotNil(t, rp.validatedHandlerUser) - assert.Equal(t, user.Handle, rp.validatedHandlerUser.WebAuthnID()) - assert.Equal(t, webauthn.SessionData{ - Challenge: "login-challenge", - UserVerification: protocol.VerificationRequired, - }, rp.validateSession) -} - -func TestFinishLoginOverridesDowngradedUserVerification(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(testCredential(user)) - store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{ - Challenge: "login-challenge", - UserVerification: protocol.VerificationDiscouraged, - }, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.NoError(t, err) - assert.NotEmpty(t, result) - assert.Equal(t, protocol.VerificationRequired, rp.validateSession.UserVerification) -} - -func TestFinishLoginWrapsCredentialUpdateFailures(t *testing.T) { - updateErr := errors.New("update failed") - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(testCredential(user)) - store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) - store.updateCredentialErr = updateErr - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - require.ErrorIs(t, err, updateErr) - assert.Empty(t, result) -} - -func TestFinishLoginRejectsCloneWarning(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(Credential{ - RPID: testRPID, - PrincipalID: testPrincipalID, - UserHandle: user.Handle, - CredentialID: []byte("credential-1"), - WebAuthn: webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - }, - }) - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ - ID: []byte("credential-1"), - Authenticator: webauthn.Authenticator{ - CloneWarning: true, - }, - } - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrUnauthenticated) - require.ErrorIs(t, err, ErrCloneWarning) - require.NotNil(t, store.updatedCredential) - assert.True(t, store.updatedCredential.WebAuthn.Authenticator.CloneWarning) - assert.Empty(t, result) -} - -func TestFinishLoginReturnsInternalWhenCloneWarningUpdateFails(t *testing.T) { - updateErr := errors.New("update failed") - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(testCredential(user)) - store.updateCredentialErr = updateErr - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ - ID: []byte("credential-1"), - Authenticator: webauthn.Authenticator{ - CloneWarning: true, - }, - } - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - require.ErrorIs(t, err, updateErr) - require.NotErrorIs(t, err, ErrCloneWarning) - assert.Empty(t, result) -} - -func TestFinishLoginRejectsMismatchedStoredCredential(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(Credential{ - RPID: testRPID, - PrincipalID: "principal_other", - UserHandle: user.Handle, - CredentialID: []byte("credential-1"), - WebAuthn: webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - }, - }) - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - assert.Nil(t, rp.validatedHandlerUser) - assert.Nil(t, store.updatedCredential) - assert.Empty(t, result) -} - -func TestFinishLoginRejectsValidatedCredentialNotLoadedFromStore(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(Credential{ - RPID: testRPID, - PrincipalID: testPrincipalID, - UserHandle: user.Handle, - CredentialID: []byte("credential-1"), - WebAuthn: webauthn.Credential{ - ID: []byte("credential-1"), - PublicKey: []byte("public-key"), - }, - }) - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-2")} - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-2"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - require.NotErrorIs(t, err, authkit.ErrUnresolvedIdentity) - assert.Nil(t, store.updatedCredential) - assert.Empty(t, result) -} - -func TestFinishLoginReturnsUnresolvedIdentityForUnlinkedCredential(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(testCredential(user)) - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) - require.NotErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Empty(t, result) -} - -func TestFinishLoginRejectsDivergedIdentityLink(t *testing.T) { - store := newFakeStore() - user := testUser() - store.putUser(user) - store.putCredential(testCredential(user)) - store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), "principal_other") - rp := newFakeRelyingParty() - rp.validateUserHandle = user.Handle - rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - assert.Nil(t, store.updatedCredential) - assert.Empty(t, result) -} - -func TestFinishLoginWrapsDiscoverableLookupFailures(t *testing.T) { - lookupErr := errors.New("lookup failed") - store := newFakeStore() - store.findUserByHandleErr = lookupErr - rp := newFakeRelyingParty() - rp.validateUserHandle = []byte("user-handle") - service := newTestService(t, store, rp) - service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { - return &protocol.ParsedCredentialAssertionData{}, nil - } - - result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ - SessionData: webauthn.SessionData{Challenge: "login-challenge"}, - Response: []byte(`{"id":"credential-1"}`), - }) - - require.ErrorIs(t, err, authkit.ErrInternal) - require.ErrorIs(t, err, lookupErr) - assert.Empty(t, result) -} - -func newTestService(t *testing.T, store Store, rp *fakeRelyingParty) *Service { - t.Helper() - - service, err := newService(store, testConfig(), rp) - require.NoError(t, err) - - return service -} - -func testConfig() Config { - return Config{ - RPID: testRPID, - RPDisplayName: "Authkit Test", - RPOrigins: []string{"https://example.test"}, - } -} - -func testUser() User { - return User{ - RPID: testRPID, - PrincipalID: testPrincipalID, - Handle: []byte("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), - Name: "ada@example.test", - DisplayName: "Ada Lovelace", - } -} - -func testCredential(user User) Credential { - credentialID := []byte("credential-1") - - return Credential{ - RPID: user.RPID, - PrincipalID: user.PrincipalID, - UserHandle: user.Handle, - CredentialID: credentialID, - WebAuthn: webauthn.Credential{ - ID: credentialID, - PublicKey: []byte("public-key"), - }, - } -} - -type fakeRelyingParty struct { - creation *protocol.CredentialCreation - registrationSession *webauthn.SessionData - registrationUser webauthn.User - - createdCredential *webauthn.Credential - createCredentialUser webauthn.User - createCredentialSession webauthn.SessionData - createCredentialErr error - - assertion *protocol.CredentialAssertion - loginSession *webauthn.SessionData - - validateUserHandle []byte - validatedHandlerUser webauthn.User - validatedCredential *webauthn.Credential - validateSession webauthn.SessionData - validateCredentialErr error -} - -func newFakeRelyingParty() *fakeRelyingParty { - return &fakeRelyingParty{ - creation: &protocol.CredentialCreation{}, - registrationSession: &webauthn.SessionData{Challenge: "registration-challenge"}, - assertion: &protocol.CredentialAssertion{}, - loginSession: &webauthn.SessionData{Challenge: "login-challenge"}, - } -} - -func (f *fakeRelyingParty) BeginRegistration( - user webauthn.User, - opts ...webauthn.RegistrationOption, -) (*protocol.CredentialCreation, *webauthn.SessionData, error) { - f.registrationUser = user - for _, opt := range opts { - opt(&f.creation.Response) - } - session := *f.registrationSession - session.UserVerification = f.creation.Response.AuthenticatorSelection.UserVerification - f.registrationSession = &session - - return f.creation, f.registrationSession, nil -} - -func (f *fakeRelyingParty) CreateCredential( - user webauthn.User, - session webauthn.SessionData, - _ *protocol.ParsedCredentialCreationData, -) (*webauthn.Credential, error) { - f.createCredentialUser = user - f.createCredentialSession = session - if f.createCredentialErr != nil { - return nil, f.createCredentialErr - } - - return f.createdCredential, nil -} - -func (f *fakeRelyingParty) BeginDiscoverableLogin( - opts ...webauthn.LoginOption, -) (*protocol.CredentialAssertion, *webauthn.SessionData, error) { - for _, opt := range opts { - opt(&f.assertion.Response) - } - session := *f.loginSession - session.UserVerification = f.assertion.Response.UserVerification - f.loginSession = &session - - return f.assertion, f.loginSession, nil -} - -func (f *fakeRelyingParty) ValidatePasskeyLogin( - handler webauthn.DiscoverableUserHandler, - session webauthn.SessionData, - _ *protocol.ParsedCredentialAssertionData, -) (webauthn.User, *webauthn.Credential, error) { - f.validateSession = session - if f.validateCredentialErr != nil { - return nil, nil, f.validateCredentialErr - } - - user, err := handler([]byte("credential-1"), f.validateUserHandle) - if err != nil { - return nil, nil, err - } - f.validatedHandlerUser = user - - return user, f.validatedCredential, nil -} - -type fakeStore struct { - usersByPrincipal map[string]User - usersByHandle map[string]User - credentials map[string][]Credential - links map[string]authkit.ExternalIdentity - - createdRegistrations []Registration - updatedCredential *Credential - calls []string - - createRegistrationErr error - createRegistrationResult func(RegistrationResult) RegistrationResult - updateCredentialErr error - findUserByHandleErr error - resolveIdentityErr error -} - -func newFakeStore() *fakeStore { - return &fakeStore{ - usersByPrincipal: make(map[string]User), - usersByHandle: make(map[string]User), - credentials: make(map[string][]Credential), - links: make(map[string]authkit.ExternalIdentity), - } -} - -func (s *fakeStore) ResolveIdentity( - _ context.Context, - identity authkit.Identity, -) (*authkit.Principal, error) { - if s.resolveIdentityErr != nil { - return nil, s.resolveIdentityErr - } - - link, ok := s.links[identityKey(identity.Provider, identity.Subject)] - if !ok { - return nil, authkit.ErrUnresolvedIdentity - } - - return &authkit.Principal{ - ID: link.PrincipalID, - }, nil -} - -func (s *fakeStore) FindUserByPrincipal(_ context.Context, rpID string, principalID string) (User, error) { - user, ok := s.usersByPrincipal[rpID+"\x00"+principalID] - if !ok { - return User{}, ErrUserNotFound - } - - return cloneUser(user), nil -} - -func (s *fakeStore) FindUserByHandle(_ context.Context, rpID string, handle []byte) (User, error) { - if s.findUserByHandleErr != nil { - return User{}, s.findUserByHandleErr - } - - user, ok := s.usersByHandle[handleKey(rpID, handle)] - if !ok { - return User{}, ErrUserNotFound - } - - return cloneUser(user), nil -} - -func (s *fakeStore) ListCredentials(_ context.Context, rpID string, userHandle []byte) ([]Credential, error) { - return cloneCredentials(s.credentials[handleKey(rpID, userHandle)]), nil -} - -func (s *fakeStore) CreateRegistration( - _ context.Context, - registration Registration, -) (RegistrationResult, error) { - s.calls = append(s.calls, "createRegistration") - if s.createRegistrationErr != nil { - return RegistrationResult{}, s.createRegistrationErr - } - - cloned := cloneRegistration(registration) - s.createdRegistrations = append(s.createdRegistrations, cloned) - s.putUser(cloned.User) - s.putCredential(cloned.Credential) - link := authkit.ExternalIdentity{ - Provider: cloned.Identity.Provider, - Subject: cloned.Identity.Subject, - PrincipalID: cloned.User.PrincipalID, - } - s.links[identityKey(link.Provider, link.Subject)] = link - - result := RegistrationResult{ - User: cloneUser(cloned.User), - Credential: cloneCredential(cloned.Credential), - Link: link, - } - if s.createRegistrationResult != nil { - result = s.createRegistrationResult(result) - } - - return result, nil -} - -func (s *fakeStore) UpdateCredentialAfterLogin(_ context.Context, credential Credential) error { - if s.updateCredentialErr != nil { - return s.updateCredentialErr - } - - clone := cloneCredential(credential) - s.updatedCredential = &clone - - return nil -} - -func (s *fakeStore) putUser(user User) { - cloned := cloneUser(user) - s.usersByPrincipal[cloned.RPID+"\x00"+cloned.PrincipalID] = cloned - s.usersByHandle[handleKey(cloned.RPID, cloned.Handle)] = cloned -} - -func (s *fakeStore) putCredential(credential Credential) { - cloned := cloneCredential(credential) - key := handleKey(cloned.RPID, cloned.UserHandle) - s.credentials[key] = append(s.credentials[key], cloned) -} - -func (s *fakeStore) putLink(identity authkit.Identity, principalID string) { - s.links[identityKey(identity.Provider, identity.Subject)] = authkit.ExternalIdentity{ - Provider: identity.Provider, - Subject: identity.Subject, - PrincipalID: principalID, - } -} - -func handleKey(rpID string, handle []byte) string { - return rpID + "\x00" + string(handle) -} - -func identityKey(provider string, subject string) string { - return provider + "\x00" + subject -} From fc6e04fb232384686a24c1ed710bb418fc878294 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 18:32:52 -0700 Subject: [PATCH 5/6] refactor(proof/passkey/session): compile-time port assertion for MemoryStore Make MemoryStore's implementation of session.Store a compile-time check so an accidental method-signature drift breaks the build rather than a downstream caller. passkey.Store assertions already live in store/memory/passkey_test.go and store/postgres/passkey_test.go. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/passkey/session/memory.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/proof/passkey/session/memory.go b/proof/passkey/session/memory.go index b7c601b..deb7d40 100644 --- a/proof/passkey/session/memory.go +++ b/proof/passkey/session/memory.go @@ -31,6 +31,8 @@ type MemoryStore struct { sessions map[string]memorySession } +var _ Store = (*MemoryStore)(nil) + // memorySession is the in-memory record for one ceremony's session data, // keyed by the kind it belongs to so cross-flow redemption fails fast. type memorySession struct { From 4aefb95b0f40c3e8d113528443d5f6565b9b59d1 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 18:34:07 -0700 Subject: [PATCH 6/6] chore(proof/passkey): drop stray tab in clone.go header comment golangci-lint fmt flagged a leading tab in the multi-line defensive-copy header that survived my godoc commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/passkey/clone.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/proof/passkey/clone.go b/proof/passkey/clone.go index 0039395..b1126e5 100644 --- a/proof/passkey/clone.go +++ b/proof/passkey/clone.go @@ -9,9 +9,9 @@ import ( ) // Defensive copies in this file protect ceremony state and credential records -// from in-place mutation after they cross the package boundary. The - // upstream webauthn.Credential carries several mutable byte slices and maps -// that callers could otherwise overwrite — corrupting stored credentials, +// from in-place mutation after they cross the package boundary. The upstream +// webauthn.Credential carries several mutable byte slices and maps that +// callers could otherwise overwrite — corrupting stored credentials, // invalidating clone-warning detection, or skewing the next verification. // cloneConfig returns a deep copy of config so a Service can outlive any