// SPDX-License-Identifier: MPL-2.0 // Package authrecovery provides printable one-time recovery codes and bounded // recovery grants for password-plus-passkey accounts. package authrecovery import ( "context" "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/base64" "errors" "fmt" "io" "strings" "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") ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable") ) type Grant struct { Digest [32]byte UserID string CreatedAt time.Time ExpiresAt time.Time } type Repository interface { ReplaceRecoveryCodes(context.Context, string, [][32]byte, time.Time, auth.AuditEvent) error ConsumeRecoveryCodeAndCreateGrant(context.Context, string, [32]byte, Grant, auth.AuditEvent) 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 { VerifyPassword(context.Context, string, string) (auth.User, error) } type Options struct { Random io.Reader Now func() time.Time CodeCount int GrantLifetime time.Duration Passkeys Passkeys } type Service struct { repository Repository passwords PasswordVerifier random io.Reader now func() time.Time count int grantTTL time.Duration passkeys Passkeys } func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) { if repository == nil || passwords == nil { return nil, errors.New("authrecovery: repository and password verifier are required") } if options.Random == nil { options.Random = rand.Reader } if options.Now == nil { options.Now = time.Now } if options.CodeCount == 0 { options.CodeCount = DefaultCodeCount } if options.GrantLifetime == 0 { options.GrantLifetime = 10 * 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 &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 // once; only domain-separated digests are persisted. func (service *Service) ReplaceCodes(ctx context.Context, userID, actorUserID string) ([]string, error) { codes, digests, err := GenerateCodeSet(service.random, service.count) if err != nil { return nil, err } now := service.now().UTC() auditID, err := token(service.random, 18) if err != nil { return nil, err } audit := auth.AuditEvent{ID: auditID, ActorUserID: actorUserID, Action: "auth.recovery-codes.replace", ResourceType: "user", ResourceID: userID, Summary: "The account recovery-code set was replaced.", CreatedAt: now} if err = service.repository.ReplaceRecoveryCodes(ctx, userID, digests, now, audit); err != nil { return nil, err } return codes, nil } // Begin verifies the password, atomically consumes one code, revokes sessions, // and returns a short-lived grant. Applications bind the grant to the passkey // replacement ceremony and do not issue a normal session from it. func (service *Service) Begin(ctx context.Context, identifier, password, code string) (auth.User, string, error) { user, err := service.passwords.VerifyPassword(ctx, identifier, password) if err != nil { return auth.User{}, "", err } digest, err := DigestCode(code) if err != nil { return auth.User{}, "", auth.ErrInvalidCredentials } rawGrant, err := token(service.random, 32) if err != nil { return auth.User{}, "", err } now := service.now().UTC() grant := Grant{Digest: sha256.Sum256([]byte(rawGrant)), UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(service.grantTTL)} auditID, err := token(service.random, 18) if err != nil { return auth.User{}, "", err } audit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.begin", ResourceType: "user", ResourceID: user.ID, Summary: "A recovery code was consumed and existing sessions were revoked.", CreatedAt: now} if err = service.repository.ConsumeRecoveryCodeAndCreateGrant(ctx, user.ID, digest, grant, audit); err != nil { if errors.Is(err, ErrCodeNotFound) { return auth.User{}, "", auth.ErrInvalidCredentials } return auth.User{}, "", err } return user, rawGrant, nil } func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) { digest, err := grantDigest(raw) if err != nil { return auth.User{}, err } 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) { if random == nil || count < 1 || count > 20 { return nil, nil, errors.New("authrecovery: invalid code-set request") } codes := make([]string, 0, count) digests := make([][32]byte, 0, count) seen := make(map[[32]byte]struct{}, count) for len(codes) < count { value := make([]byte, 16) if _, err := io.ReadFull(random, value); err != nil { return nil, nil, fmt.Errorf("authrecovery: secure randomness unavailable: %w", err) } encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value) code := strings.Join([]string{encoded[0:5], encoded[5:10], encoded[10:15], encoded[15:20], encoded[20:26]}, "-") digest, _ := DigestCode(code) if _, duplicate := seen[digest]; duplicate { continue } seen[digest] = struct{}{} codes = append(codes, code) digests = append(digests, digest) } return codes, digests, nil } func DigestCode(code string) ([32]byte, error) { normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(code), "-", ""), " ", "")) decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized) if err != nil || len(decoded) != 16 { return [32]byte{}, ErrCodeNotFound } 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 { return "", err } return base64.RawURLEncoding.EncodeToString(value), nil }