This commit is contained in:
+116
-6
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user