diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea7ce6..b37a88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ # Changelog +## v0.1.0-preview.11 — 2026-09-03 + +- Add expected-user completion for authenticated self-service passkey + enrollment. A mismatched ceremony is consumed and fails before credential + persistence, closing an authorization seam found while dogfooding Gamertan's + account security page. + ## v0.1.0-preview.10 — 2026-09-03 - Add atomic public-account registration with required canonical email, diff --git a/README.md b/README.md index c62688d..6ab3107 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.10`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.11`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -56,14 +56,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.10 +go get gamertan.com/web@v0.1.0-preview.11 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.10 +go get gamertan.com/web/requestmeta@v0.1.0-preview.11 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/authwebauthn/service.go b/authwebauthn/service.go index a0b5f46..76e935f 100644 --- a/authwebauthn/service.go +++ b/authwebauthn/service.go @@ -262,7 +262,19 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l } func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) { - return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false, false, nil) + return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", [32]byte{}, response, false, false, nil) +} + +// FinishRegistrationForUser verifies an ordinary self-service enrollment only +// when the ceremony belongs to the authenticated user selected by the +// application. The ceremony is consumed on mismatch so a leaked token cannot +// be retried through another account session. +func (service *Service) FinishRegistrationForUser(ctx context.Context, ceremonyToken, expectedUserID string, response []byte) (Credential, error) { + expectedUserID = strings.TrimSpace(expectedUserID) + if expectedUserID == "" { + return Credential{}, ErrOperationBinding + } + return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, expectedUserID, [32]byte{}, response, false, false, nil) } // FinishAccountRegistration verifies an initial credential and delegates its @@ -274,7 +286,7 @@ func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyT if len(binding) < 16 || len(binding) > 4096 || commit == nil { return Credential{}, ErrOperationBinding } - return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, BindingDigest(binding), response, false, true, commit) + return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit) } // FinishPasswordMigration verifies the new passkey and persists it together @@ -287,11 +299,14 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil) } -func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) { +func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind, expectedUserID string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) { ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind) if err != nil { return Credential{}, err } + if expectedUserID != "" && ceremony.UserID != expectedUserID { + return Credential{}, ErrOperationBinding + } return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit) } diff --git a/authwebauthn/service_test.go b/authwebauthn/service_test.go index 6a3afa0..1845f26 100644 --- a/authwebauthn/service_test.go +++ b/authwebauthn/service_test.go @@ -53,6 +53,12 @@ func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) { if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) { t.Fatalf("registration expiry=%v", begin.ExpiresAt) } + if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, "another-user", []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) { + t.Fatalf("cross-account registration completion err=%v", err) + } + if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, user.ID, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) { + t.Fatalf("mismatched completion did not consume ceremony: %v", err) + } if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) { t.Fatalf("readiness without credentials err=%v", err) diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 6616d93..3b9d8b4 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -34,3 +34,7 @@ application concern belongs in the shared module. explicit operator command. - Commerce remains a separately versioned nested module so payment-provider policy and catalog evolution do not enlarge the authentication core. +- Self-service enrollment exposed an authorization seam: completing a valid + ceremony and checking its user only after persistence is too late. + `FinishRegistrationForUser` now consumes mismatched ceremonies and checks + the application-authenticated user before storing a credential. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 564a207..b57a78b 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -25,7 +25,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.10 +go get gamertan.com/web/requestmeta@v0.1.0-preview.11 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index 4f0b0e8..f597322 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.10 +go get gamertan.com/web/requestmeta@v0.1.0-preview.11 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/PASSKEYS.md b/docs/PASSKEYS.md index 20d3219..04af487 100644 --- a/docs/PASSKEYS.md +++ b/docs/PASSKEYS.md @@ -31,7 +31,9 @@ timestamp, UUID, or counter for the random challenge. 2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses `navigator.credentials.create` with the returned `public_key` value. 3. The browser posts the credential and opaque ceremony token to a bounded JSON - endpoint; `FinishRegistration` verifies and stores the public credential. + endpoint; authenticated self-service flows use + `FinishRegistrationForUser` so the application session's user ID is checked + before any public credential is stored. 4. Login uses `BeginLogin`, `navigator.credentials.get`, and `FinishLogin`. The successful result contains an ordinary opaque `auth` session token. 5. Sensitive operations call `BeginApproval` with a canonical application