Complete passkey recovery transaction
verify / verify (push) Successful in 3m37s

This commit is contained in:
2026-09-03 13:09:22 -04:00
parent 1f54c75501
commit 95d50f0888
11 changed files with 426 additions and 18 deletions
+12
View File
@@ -2,6 +2,18 @@
# Changelog # 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 ## v0.1.0-preview.12 — 2026-09-03
- Add a root-local bootstrap transaction that creates the first passkey-only - Add a root-local bootstrap transaction that creates the first passkey-only
+3 -3
View File
@@ -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 deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import. 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. > release. Linux is the maintained release platform.
## Why Web Foundations? ## Why Web Foundations?
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
Pin the preview in an application module: Pin the preview in an application module:
```bash ```bash
go get gamertan.com/web@v0.1.0-preview.12 go get gamertan.com/web@v0.1.0-preview.13
go mod verify go mod verify
``` ```
An application may name the first package it intends to adopt: An application may name the first package it intends to adopt:
```bash ```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 The version belongs to the `gamertan.com/web` module. See the
+114 -4
View File
@@ -17,6 +17,7 @@ import (
"time" "time"
"gamertan.com/web/auth" "gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
) )
const DefaultCodeCount = 10 const DefaultCodeCount = 10
@@ -24,6 +25,7 @@ const DefaultCodeCount = 10
var ( var (
ErrCodeNotFound = errors.New("authrecovery: recovery code not found") ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found") ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
) )
type Grant struct { type Grant struct {
@@ -39,6 +41,38 @@ type Repository interface {
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error) 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 { type PasswordVerifier interface {
VerifyPassword(context.Context, string, string) (auth.User, error) VerifyPassword(context.Context, string, string) (auth.User, error)
} }
@@ -48,6 +82,7 @@ type Options struct {
Now func() time.Time Now func() time.Time
CodeCount int CodeCount int
GrantLifetime time.Duration GrantLifetime time.Duration
Passkeys Passkeys
} }
type Service struct { type Service struct {
@@ -57,6 +92,7 @@ type Service struct {
now func() time.Time now func() time.Time
count int count int
grantTTL time.Duration grantTTL time.Duration
passkeys Passkeys
} }
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) { 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 { 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 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 // 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) { func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
if len(raw) < 32 || len(raw) > 128 { digest, err := grantDigest(raw)
return auth.User{}, ErrGrantNotFound 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) { 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 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) { func token(random io.Reader, size int) (string, error) {
value := make([]byte, size) value := make([]byte, size)
if _, err := io.ReadFull(random, value); err != nil { if _, err := io.ReadFull(random, value); err != nil {
+112
View File
@@ -3,6 +3,10 @@
package authrecovery_test package authrecovery_test
import ( import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors" "errors"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -12,6 +16,8 @@ import (
"gamertan.com/web/auth" "gamertan.com/web/auth"
"gamertan.com/web/authrecovery" "gamertan.com/web/authrecovery"
"gamertan.com/web/authsqlite" "gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
) )
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) { 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 } type counterReader struct{ value byte }
func (reader *counterReader) Read(target []byte) (int, error) { func (reader *counterReader) Read(target []byte) (int, error) {
+96
View File
@@ -3,8 +3,10 @@
package authsqlite package authsqlite
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/base64"
"errors" "errors"
"time" "time"
@@ -38,6 +40,100 @@ func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, dig
return tx.Commit() 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 { 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 { 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") return errors.New("authsqlite: invalid recovery attempt")
+31
View File
@@ -218,6 +218,23 @@ func (service *Service) BeginAccountRegistration(ctx context.Context, userID, la
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true) 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 // BeginPasswordMigration starts registration for an already authenticated
// password-backed user. Completion atomically retires the password and revokes // password-backed user. Completion atomically retires the password and revokes
// all sessions, including the session that authorized this ceremony. // 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) 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 // FinishPasswordMigration verifies the new passkey and persists it together
// with password retirement and session revocation in one storage transaction. // with password retirement and session revocation in one storage transaction.
func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) { func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
+25
View File
@@ -4,6 +4,7 @@ package authwebauthn_test
import ( import (
"bytes" "bytes"
"context"
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"errors" "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) { func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) {
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
store, err := authsqlite.Open(t.TempDir() + "/auth.db") store, err := authsqlite.Open(t.TempDir() + "/auth.db")
+6
View File
@@ -43,3 +43,9 @@ application concern belongs in the shared module.
non-personal organization, membership, direct owner binding, and audits non-personal organization, membership, direct owner binding, and audits
together. Applications must seed their owner role first and must write the together. Applications must seed their owner role first and must write the
returned raw token only to a newly created private file. 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.
+1 -1
View File
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum: module checksum:
```bash ```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 go mod verify
``` ```
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version: and request the containing module at an exact version:
```bash ```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 Only imported packages are compiled and linked. The packages nevertheless
+23 -7
View File
@@ -56,13 +56,29 @@ JavaScript, or set sessions automatically.
## Recovery and credential lifecycle ## Recovery and credential lifecycle
Recovery is deliberately host-local and should never be reachable through an Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
HTTP handler. It revokes all user sessions and pending ceremonies, replaces must never be reachable through an HTTP handler. It revokes all user sessions
prior enrollment tokens, appends a secret-free audit event, and returns one and pending ceremonies, replaces prior enrollment tokens, appends a
15-minute token. It does not delete existing passkeys. After enrolling a secret-free audit event, and returns one 15-minute token. It does not delete
replacement, the operator reviews credential labels and removes lost keys with existing passkeys. After enrolling a replacement, the operator reviews
a fresh passkey-bound removal ceremony. The final passkey cannot be removed credential labels and removes lost keys with a fresh passkey-bound removal
remotely. 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 Before enabling production mutations, applications should require at least two
independent passkeys and complete a local recovery drill. independent passkeys and complete a local recovery drill.