diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bcc86..81048b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ # Changelog +## v0.1.0-preview.13 — 2026-09-03 + +- Complete the password-plus-recovery-code flow with a short-lived restricted + grant bound into a replacement-passkey ceremony. Completion atomically + consumes the grant, stores the verified passkey, replaces every recovery + code, revokes any intervening sessions and ceremonies, and records both + audits without issuing a normal session. +- Keep failed completion retryable until grant expiry: a duplicate credential + or other transaction failure rolls back grant consumption and recovery-code + replacement, while a mismatched WebAuthn binding consumes only the affected + ceremony. + ## v0.1.0-preview.12 — 2026-09-03 - Add a root-local bootstrap transaction that creates the first passkey-only diff --git a/README.md b/README.md index e5f61b4..46fb971 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.12`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.13`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -57,14 +57,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.12 +go get gamertan.com/web@v0.1.0-preview.13 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.12 +go get gamertan.com/web/requestmeta@v0.1.0-preview.13 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/authrecovery/recovery.go b/authrecovery/recovery.go index 91e9afc..8229602 100644 --- a/authrecovery/recovery.go +++ b/authrecovery/recovery.go @@ -17,13 +17,15 @@ import ( "time" "gamertan.com/web/auth" + "gamertan.com/web/authwebauthn" ) const DefaultCodeCount = 10 var ( - ErrCodeNotFound = errors.New("authrecovery: recovery code not found") - ErrGrantNotFound = errors.New("authrecovery: recovery grant not found") + ErrCodeNotFound = errors.New("authrecovery: recovery code not found") + ErrGrantNotFound = errors.New("authrecovery: recovery grant not found") + ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable") ) type Grant struct { @@ -39,6 +41,38 @@ type Repository interface { TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error) } +// PasskeyRepository adds the transactional boundary required to finish a +// password-plus-recovery-code flow without issuing a normal session. +type PasskeyRepository interface { + Repository + RecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error) + CompletePasskeyRecovery(context.Context, PasskeyCompletion) error +} + +// Passkeys performs recovery-bound WebAuthn registration ceremonies. +type Passkeys interface { + BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error) + FinishRecoveryRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) +} + +// PasskeyCompletion contains the public credential, digest-only replacement +// codes, and secret-free audits committed after a recovery ceremony. +type PasskeyCompletion struct { + GrantDigest [32]byte + Credential authwebauthn.Credential + RecoveryDigests [][32]byte + PasskeyAudit auth.AuditEvent + RecoveryAudit auth.AuditEvent + CompletedAt time.Time +} + +// PasskeyFinishResult returns the verified credential and the new plaintext +// recovery codes. Applications must display the codes once and retain none. +type PasskeyFinishResult struct { + Credential authwebauthn.Credential + RecoveryCodes []string +} + type PasswordVerifier interface { VerifyPassword(context.Context, string, string) (auth.User, error) } @@ -48,6 +82,7 @@ type Options struct { Now func() time.Time CodeCount int GrantLifetime time.Duration + Passkeys Passkeys } type Service struct { @@ -57,6 +92,7 @@ type Service struct { now func() time.Time count int grantTTL time.Duration + passkeys Passkeys } func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) { @@ -78,7 +114,7 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute { return nil, errors.New("authrecovery: invalid recovery policy") } - return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime}, nil + return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, passkeys: options.Passkeys}, nil } // ReplaceCodes creates a complete new recovery-code set. Codes are returned @@ -133,10 +169,74 @@ func (service *Service) Begin(ctx context.Context, identifier, password, code st } func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) { - if len(raw) < 32 || len(raw) > 128 { - return auth.User{}, ErrGrantNotFound + digest, err := grantDigest(raw) + if err != nil { + return auth.User{}, err } - return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC()) + return service.repository.TakeRecoveryGrant(ctx, digest, service.now().UTC()) +} + +// BeginPasskey starts a ceremony only for a live restricted recovery grant. +// The raw grant remains application-held so a failed or interrupted ceremony +// can be restarted until the grant expires. +func (service *Service) BeginPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) { + repository, ok := service.repository.(PasskeyRepository) + if !ok || service.passkeys == nil { + return authwebauthn.BeginResult{}, ErrPasskeyUnavailable + } + digest, err := grantDigest(rawGrant) + if err != nil { + return authwebauthn.BeginResult{}, err + } + user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC()) + if err != nil { + return authwebauthn.BeginResult{}, err + } + return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant)) +} + +// FinishPasskey consumes the grant only inside the transaction that stores the +// verified passkey and a fresh recovery-code set. It never issues a session. +func (service *Service) FinishPasskey(ctx context.Context, rawGrant, ceremonyToken string, response []byte) (PasskeyFinishResult, error) { + repository, ok := service.repository.(PasskeyRepository) + if !ok || service.passkeys == nil { + return PasskeyFinishResult{}, ErrPasskeyUnavailable + } + digest, err := grantDigest(rawGrant) + if err != nil { + return PasskeyFinishResult{}, err + } + user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC()) + if err != nil { + return PasskeyFinishResult{}, err + } + codes, digests, err := GenerateCodeSet(service.random, service.count) + if err != nil { + return PasskeyFinishResult{}, err + } + credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error { + if verified.UserID != user.ID { + return errors.New("authrecovery: recovery identity mismatch") + } + completedAt := service.now().UTC() + auditID, auditErr := token(service.random, 18) + if auditErr != nil { + return auditErr + } + recoveryAudit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Account recovery enrolled a replacement passkey and replaced the recovery-code set.", CreatedAt: completedAt} + return repository.CompletePasskeyRecovery(commitContext, PasskeyCompletion{ + GrantDigest: digest, + Credential: verified, + RecoveryDigests: digests, + PasskeyAudit: passkeyAudit, + RecoveryAudit: recoveryAudit, + CompletedAt: completedAt, + }) + }) + if err != nil { + return PasskeyFinishResult{}, err + } + return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil } func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) { @@ -173,6 +273,16 @@ func DigestCode(code string) ([32]byte, error) { return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil } +func grantDigest(raw string) ([32]byte, error) { + if len(raw) < 32 || len(raw) > 128 { + return [32]byte{}, ErrGrantNotFound + } + if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil { + return [32]byte{}, ErrGrantNotFound + } + return sha256.Sum256([]byte(raw)), nil +} + func token(random io.Reader, size int) (string, error) { value := make([]byte, size) if _, err := io.ReadFull(random, value); err != nil { diff --git a/authrecovery/recovery_test.go b/authrecovery/recovery_test.go index 1b8641c..41dee11 100644 --- a/authrecovery/recovery_test.go +++ b/authrecovery/recovery_test.go @@ -3,6 +3,10 @@ package authrecovery_test import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" "errors" "path/filepath" "strings" @@ -12,6 +16,8 @@ import ( "gamertan.com/web/auth" "gamertan.com/web/authrecovery" "gamertan.com/web/authsqlite" + "gamertan.com/web/authwebauthn" + wa "gamertan.com/web/internal/webauthnvendored/webauthn" ) func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) { @@ -61,6 +67,112 @@ func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) { } } +func TestPasskeyRecoveryAtomicallyReplacesCodesWithoutIssuingSession(t *testing.T) { + now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC) + store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + random := &counterReader{} + authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.passkey", Email: "recover-passkey@example.test", DisplayName: "Recover Passkey", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + existingID := bytes.Repeat([]byte{7}, 32) + existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}}) + if err != nil { + t.Fatal(err) + } + if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: user.ID, Label: "Existing passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "existing-passkey-audit", ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Existing passkey fixture.", CreatedAt: now}); err != nil { + t.Fatal(err) + } + passkeys := &passkeyRecoveryStub{now: now, credentialID: existingID} + recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys}) + if err != nil { + t.Fatal(err) + } + oldCodes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID) + if err != nil { + t.Fatal(err) + } + _, grant, err := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[0]) + if err != nil { + t.Fatal(err) + } + begin, err := recovery.BeginPasskey(t.Context(), grant, "Replacement passkey") + if err != nil || begin.CeremonyToken == "" || passkeys.userID != user.ID || passkeys.beginBinding != grant { + t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err) + } + if _, err = recovery.FinishPasskey(t.Context(), grant, begin.CeremonyToken, []byte(`{"fixture":true}`)); err == nil { + t.Fatal("duplicate credential unexpectedly committed") + } + if _, err = recovery.BeginPasskey(t.Context(), grant, "Retry replacement"); err != nil { + t.Fatalf("failed completion consumed recovery grant: %v", err) + } + lateSession, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + + passkeys.credentialID = bytes.Repeat([]byte{8}, 32) + result, err := recovery.FinishPasskey(t.Context(), grant, "retry-ceremony-token", []byte(`{"fixture":true}`)) + if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount || !bytes.Equal(result.Credential.ID, passkeys.credentialID) { + t.Fatalf("result=%+v err=%v", result, err) + } + if passkeys.finishBinding != grant { + t.Fatal("finish ceremony was not bound to the restricted recovery grant") + } + if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) { + t.Fatalf("completed grant replay err=%v", err) + } + if _, err = authService.Session(t.Context(), lateSession); !errors.Is(err, auth.ErrSessionNotFound) { + t.Fatalf("session created during recovery survived completion: %v", err) + } + if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("old recovery-code set survived completion: %v", err) + } + if _, newGrant, beginErr := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", result.RecoveryCodes[0]); beginErr != nil || newGrant == "" { + t.Fatalf("new recovery code unavailable: grant=%q err=%v", newGrant, beginErr) + } + credentials, err := store.CredentialsByUserID(t.Context(), user.ID) + if err != nil || len(credentials) != 2 { + t.Fatalf("credentials=%+v err=%v", credentials, err) + } +} + +type passkeyRecoveryStub struct { + now time.Time + userID string + credentialID []byte + beginBinding string + finishBinding string +} + +func (stub *passkeyRecoveryStub) BeginRecoveryRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) { + stub.userID = userID + stub.beginBinding = string(binding) + return authwebauthn.BeginResult{CeremonyToken: "recovery-ceremony-token", PublicKey: json.RawMessage(`{"challenge":"fixture"}`), ExpiresAt: stub.now.Add(5 * time.Minute)}, nil +} + +func (stub *passkeyRecoveryStub) FinishRecoveryRegistration(ctx context.Context, _ string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) { + stub.finishBinding = string(binding) + encoded, err := json.Marshal(wa.Credential{ID: stub.credentialID, PublicKey: []byte{1, 2, 3}}) + if err != nil { + return authwebauthn.Credential{}, err + } + credential := authwebauthn.Credential{ID: append([]byte(nil), stub.credentialID...), UserID: stub.userID, Label: "Replacement passkey", Data: encoded, CreatedAt: stub.now} + audit := auth.AuditEvent{ID: "recovery-passkey-audit", ActorUserID: stub.userID, Action: "auth.recovery.passkey", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(stub.credentialID), Summary: "A replacement passkey was enrolled during account recovery.", CreatedAt: stub.now} + if err = commit(ctx, credential, audit); err != nil { + return authwebauthn.Credential{}, err + } + return credential, nil +} + type counterReader struct{ value byte } func (reader *counterReader) Read(target []byte) (int, error) { diff --git a/authsqlite/recovery.go b/authsqlite/recovery.go index 3813afe..eafa45f 100644 --- a/authsqlite/recovery.go +++ b/authsqlite/recovery.go @@ -3,8 +3,10 @@ package authsqlite import ( + "bytes" "context" "database/sql" + "encoding/base64" "errors" "time" @@ -38,6 +40,100 @@ func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, dig return tx.Commit() } +func (store *Store) RecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) { + if zeroDigest(digest) || now.IsZero() { + return auth.User{}, authrecovery.ErrGrantNotFound + } + user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at FROM gwf_recovery_grants g JOIN gwf_users u ON u.id=g.user_id WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix())) + if errors.Is(err, auth.ErrUserNotFound) { + return auth.User{}, authrecovery.ErrGrantNotFound + } + return user, err +} + +func (store *Store) CompletePasskeyRecovery(ctx context.Context, completion authrecovery.PasskeyCompletion) error { + credential := completion.Credential + credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID) + if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || !validAuditEvent(completion.RecoveryAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID { + return errors.New("authsqlite: invalid passkey recovery completion") + } + seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests)) + for _, digest := range completion.RecoveryDigests { + if zeroDigest(digest) { + return errors.New("authsqlite: invalid recovery-code digest") + } + if _, exists := seen[digest]; exists { + return errors.New("authsqlite: duplicate recovery-code digest") + } + seen[digest] = struct{}{} + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var userID string + err = tx.QueryRowContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + return authrecovery.ErrGrantNotFound + } + if err != nil { + return err + } + if userID != credential.UserID { + return errors.New("authsqlite: passkey recovery identity mismatch") + } + var active, pending int + if err = tx.QueryRowContext(ctx, `SELECT status='active',registration_pending FROM gwf_users WHERE id=?`, userID).Scan(&active, &pending); err != nil || active != 1 || pending != 0 { + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + return auth.ErrInactiveUser + } + existing, err := tx.QueryContext(ctx, `SELECT credential_id FROM gwf_passkey_credentials WHERE user_id=?`, userID) + if err != nil { + return err + } + for existing.Next() { + var id []byte + if err = existing.Scan(&id); err != nil { + existing.Close() + return err + } + if bytes.Equal(id, credential.ID) { + existing.Close() + return errors.New("authsqlite: passkey credential already exists") + } + } + if err = existing.Close(); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil { + return err + } + for _, digest := range completion.RecoveryDigests { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil { + return err + } + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, userID); err != nil { + return err + } + if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil { + return err + } + if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil { + return err + } + return tx.Commit() +} + func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error { if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID { return errors.New("authsqlite: invalid recovery attempt") diff --git a/authwebauthn/service.go b/authwebauthn/service.go index 76e935f..26adc7a 100644 --- a/authwebauthn/service.go +++ b/authwebauthn/service.go @@ -218,6 +218,23 @@ func (service *Service) BeginAccountRegistration(ctx context.Context, userID, la return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true) } +// BeginRecoveryRegistration starts a replacement-passkey ceremony bound to a +// short-lived recovery grant selected by the application. The grant itself is +// never persisted in ceremony state; only its digest is retained. +func (service *Service) BeginRecoveryRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) { + if len(binding) < 16 || len(binding) > 4096 { + return BeginResult{}, ErrOperationBinding + } + user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID)) + if err != nil { + return BeginResult{}, err + } + if user.RegistrationPending || user.Status != "active" { + return BeginResult{}, auth.ErrInactiveUser + } + return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), false) +} + // BeginPasswordMigration starts registration for an already authenticated // password-backed user. Completion atomically retires the password and revokes // all sessions, including the session that authorized this ceremony. @@ -289,6 +306,20 @@ func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyT return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit) } +// FinishRecoveryRegistration verifies a replacement passkey and delegates its +// persistence to commit so recovery-grant consumption, credential storage, and +// recovery-code replacement can share one transaction. +func (service *Service) FinishRecoveryRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) { + if len(binding) < 16 || len(binding) > 4096 || commit == nil { + return Credential{}, ErrOperationBinding + } + return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, false, func(commitContext context.Context, credential Credential, audit auth.AuditEvent) error { + audit.Action = "auth.recovery.passkey" + audit.Summary = "A replacement passkey was enrolled during account recovery." + return commit(commitContext, credential, audit) + }) +} + // FinishPasswordMigration verifies the new passkey and persists it together // with password retirement and session revocation in one storage transaction. func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) { diff --git a/authwebauthn/service_test.go b/authwebauthn/service_test.go index 1845f26..ce7f8a3 100644 --- a/authwebauthn/service_test.go +++ b/authwebauthn/service_test.go @@ -4,6 +4,7 @@ package authwebauthn_test import ( "bytes" + "context" "crypto/sha256" "encoding/json" "errors" @@ -147,6 +148,30 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) { } } +func TestRecoveryRegistrationIsBoundAndConsumesMismatchedCeremony(t *testing.T) { + now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC) + store, authService, service := newService(t, &now, &counterReader{}) + defer store.Close() + user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.bound", Email: "recover-bound@example.test", DisplayName: "Recover Bound", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + binding := bytes.Repeat([]byte("restricted recovery grant "), 2) + begin, err := service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", binding) + if err != nil { + t.Fatal(err) + } + if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrOperationBinding) { + t.Fatalf("tampered recovery binding err=%v", err) + } + if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, binding, []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) { + t.Fatalf("mismatched completion did not consume recovery ceremony: %v", err) + } + if _, err = service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", []byte("short")); !errors.Is(err, authwebauthn.ErrOperationBinding) { + t.Fatalf("short recovery binding err=%v", err) + } +} + func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) { now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) store, err := authsqlite.Open(t.TempDir() + "/auth.db") diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index b290221..35ecc39 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -43,3 +43,9 @@ application concern belongs in the shared module. non-personal organization, membership, direct owner binding, and audits together. Applications must seed their owner role first and must write the returned raw token only to a newly created private file. +- Recovery-code consumption alone is not a complete recovery path. The + restricted grant must survive an interrupted authenticator prompt yet be + consumed in the same transaction that stores the verified replacement + passkey and replacement code digests. `authrecovery.BeginPasskey` and + `FinishPasskey` now provide that boundary without creating an authenticated + session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f64fb6e..cf506e6 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,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.12 +go get gamertan.com/web/requestmeta@v0.1.0-preview.13 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index 1d1eb35..f47887e 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.12 +go get gamertan.com/web/requestmeta@v0.1.0-preview.13 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/PASSKEYS.md b/docs/PASSKEYS.md index 5eb1da3..440168c 100644 --- a/docs/PASSKEYS.md +++ b/docs/PASSKEYS.md @@ -56,13 +56,29 @@ JavaScript, or set sessions automatically. ## Recovery and credential lifecycle -Recovery is deliberately host-local and should never be reachable through an -HTTP handler. It revokes all user sessions and pending ceremonies, replaces -prior enrollment tokens, appends a secret-free audit event, and returns one -15-minute token. It does not delete existing passkeys. After enrolling a -replacement, the operator reviews credential labels and removes lost keys with -a fresh passkey-bound removal ceremony. The final passkey cannot be removed -remotely. +Administrator-assisted `authwebauthn.Recover` is deliberately host-local and +must never be reachable through an HTTP handler. It revokes all user sessions +and pending ceremonies, replaces prior enrollment tokens, appends a +secret-free audit event, and returns one 15-minute token. It does not delete +existing passkeys. After enrolling a replacement, the operator reviews +credential labels and removes lost keys with a fresh passkey-bound removal +ceremony. The final passkey cannot be removed remotely. + +An account may separately expose self-service password-plus-recovery-code +recovery through `authrecovery`. `Begin` verifies the password, consumes one +printable code, revokes sessions, and returns a short-lived grant—not a normal +session. Keep that grant in a narrowly scoped, Secure, HttpOnly, SameSite cookie +and never place it in a URL. `BeginPasskey` binds its digest into the WebAuthn +ceremony. `FinishPasskey` atomically consumes the grant, stores the verified +replacement passkey, replaces the entire recovery-code set, revokes any +sessions or ceremonies created during recovery, and returns the new plaintext +codes exactly once. It does not issue a session; return the user to normal +login after displaying and saving the new codes. + +A failed storage commit leaves the restricted grant available for a fresh +ceremony until expiry. A binding mismatch consumes the mismatched ceremony. +Applications must use generic failure responses and the same credential-attempt +rate limiting as login. Before enabling production mutations, applications should require at least two independent passkeys and complete a local recovery drill.