// 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/access" "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") ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found") ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized") 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 } // AssistedGrant is the digest-only authority created by an organization // owner after a human recovery review. The plaintext token is returned once // to the caller and never persisted or audited. type AssistedGrant struct { Digest [32]byte OrganizationID, UserID string IssuedByUserID string CreatedAt, ExpiresAt time.Time } // AssistedIssue binds an owner-reviewed recovery to one organization member. // Reason is deliberately bounded and must not contain credential material. type AssistedIssue struct { OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string } // AssistedRepository provides the two transactional boundaries for delegated // recovery. Issuance invalidates all existing account authenticators and // sessions while recording both identity and organization-visible audits. // Completion consumes the grant exactly once and installs the replacement // password, passkey, and recovery-code set atomically. type AssistedRepository interface { Repository IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error) AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error) CompleteAssistedRecovery(context.Context, AssistedCompletion) error } // AssistedCompletion contains only the password hash, public passkey // credential, digest-only recovery codes, and secret-free audit material. type AssistedCompletion struct { GrantDigest [32]byte Credential authwebauthn.Credential PasswordHash string RecoveryDigests [][32]byte PasskeyAudit auth.AuditEvent RecoveryAudit auth.AuditEvent AccessAudit access.AuditEvent CompletedAt time.Time } // 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 AssistedGrantLifetime time.Duration OwnerRole string Passkeys Passkeys } type Service struct { repository Repository passwords PasswordVerifier random io.Reader now func() time.Time count int grantTTL time.Duration assistedTTL time.Duration ownerRole string 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.AssistedGrantLifetime == 0 { options.AssistedGrantLifetime = 15 * time.Minute } if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) { 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, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil } // IssueAssistedRecovery creates one owner-authorized, single-use recovery // token. The repository immediately invalidates the target's previous // password, passkeys, recovery codes, sessions, and pending ceremonies so the // reviewed recovery cannot race an older authenticator. func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) { repository, ok := service.repository.(AssistedRepository) input.OrganizationID = strings.TrimSpace(input.OrganizationID) input.ActorUserID = strings.TrimSpace(input.ActorUserID) input.TargetUserID = strings.TrimSpace(input.TargetUserID) input.RequestID = strings.TrimSpace(input.RequestID) input.Reason = strings.TrimSpace(input.Reason) if !ok || service.passkeys == nil || service.ownerRole == "" { return auth.User{}, "", ErrPasskeyUnavailable } if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") { return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request") } raw, err := token(service.random, 32) if err != nil { return auth.User{}, "", err } now := service.now().UTC() grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)} authAuditID, err := token(service.random, 18) if err != nil { return auth.User{}, "", err } accessAuditID, err := token(service.random, 18) if err != nil { return auth.User{}, "", err } summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now} accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now} user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit) if err != nil { return auth.User{}, "", err } return user, raw, nil } // BeginAssistedPasskey starts a replacement ceremony without issuing a normal // session. The grant remains reusable for ceremony restart until completion or // expiry; only completion consumes it. func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) { repository, ok := service.repository.(AssistedRepository) if !ok || service.passkeys == nil { return authwebauthn.BeginResult{}, ErrPasskeyUnavailable } digest, err := grantDigest(rawGrant) if err != nil { return authwebauthn.BeginResult{}, ErrAssistedNotFound } _, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC()) if err != nil { return authwebauthn.BeginResult{}, err } return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant)) } // FinishAssistedRecovery consumes a reviewed grant only inside the transaction // that installs every replacement credential and both audit trails. No normal // session is issued; the recovered user signs in with the new credentials. func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) { repository, ok := service.repository.(AssistedRepository) if !ok || service.passkeys == nil { return PasskeyFinishResult{}, ErrPasskeyUnavailable } digest, err := grantDigest(rawGrant) if err != nil { return PasskeyFinishResult{}, ErrAssistedNotFound } grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC()) if err != nil { return PasskeyFinishResult{}, err } passwordHash, err := auth.HashPasswordWithRandom(password, service.random) 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: assisted recovery identity mismatch") } completedAt := service.now().UTC() recoveryAuditID, auditErr := token(service.random, 18) if auditErr != nil { return auditErr } accessAuditID, auditErr := token(service.random, 18) if auditErr != nil { return auditErr } recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt} accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt} return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt}) }) if err != nil { return PasskeyFinishResult{}, err } return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, 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 } func opaqueID(value string) bool { if len(value) < 8 || len(value) > 128 { return false } for _, character := range value { if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { continue } return false } return true } func safeRole(value string) bool { if len(value) < 1 || len(value) > 96 { return false } for _, character := range value { if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' { continue } return false } return true }