Files
web/authrecovery/recovery.go
T
gamertan 6f0b597943
verify / verify (push) Successful in 3m39s
Add owner-assisted account recovery
2026-09-03 23:56:42 -04:00

477 lines
19 KiB
Go

// 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
}