diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a8f08..90f4c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ # Changelog +## v0.1.0-preview.18 — 2026-09-04 + +- Add owner-assisted account recovery for a documented human-review path when + normal password, passkey, and recovery-code authentication is unavailable. + Issuance requires an active direct organization owner and returns a bounded, + single-use, 15-minute secret while persisting and auditing only its digest. +- Invalidate the recovered member's existing password, passkeys, recovery + codes, sessions, ceremonies, and older recovery grants when the reviewed + enrollment is issued. Completion atomically installs one replacement + password, passkey, and recovery-code set without issuing a normal session. +- Keep identity and organization-visible recovery audits in the same SQLite + transactions as their credential changes, and document the application + boundary for fresh passkey authorization, secret-fragment delivery, and + human evidence review. + ## v0.1.0-preview.17 — 2026-09-04 - Add optimistic organization-membership suspension, reactivation, and diff --git a/README.md b/README.md index d2ab48e..7e6b480 100644 --- a/README.md +++ b/README.md @@ -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 packages you import. -> **Public preview:** `v0.1.0-preview.17`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.18`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -41,7 +41,7 @@ packages you import. | Atomic password-plus-passkey registration | [`account`](account) | | Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) | | Atomic first-owner and organization setup | [`bootstrap`](bootstrap) | -| Printable single-use recovery codes | [`authrecovery`](authrecovery) | +| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) | | Private SQLite persistence | [`authsqlite`](authsqlite) | | Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) | | Organizations, teams, and invitations | [`organizations`](organizations) | @@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.17 +go get gamertan.com/web@v0.1.0-preview.18 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.17 +go get gamertan.com/web/requestmeta@v0.1.0-preview.18 ``` The version belongs to the `gamertan.com/web` module. See the @@ -102,6 +102,9 @@ JSONL logging. request context without owning login routes or pages. - [`authwebauthn`](authwebauthn) provides discoverable passkey login, enrollment, operation-bound fresh approval, and bounded recovery. +- [`authrecovery`](authrecovery) supports printable self-service recovery and + a separate owner-assisted flow that atomically replaces compromised account + credentials while writing both identity and organization-visible audits. - [`organizations`](organizations) and [`access`](access) keep platform operation separate from organization-data authority while supporting teams, invitations, scoped roles, and audited temporary access. diff --git a/authrecovery/assisted_test.go b/authrecovery/assisted_test.go new file mode 100644 index 0000000..49dad92 --- /dev/null +++ b/authrecovery/assisted_test.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authrecovery_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/auth" + "gamertan.com/web/authrecovery" + "gamertan.com/web/authsqlite" + "gamertan.com/web/authwebauthn" + wa "gamertan.com/web/internal/webauthnvendored/webauthn" + "gamertan.com/web/organizations" +) + +func TestOwnerAssistedRecoveryInvalidatesAndAtomicallyReplacesAccountCredentials(t *testing.T) { + now := time.Date(2026, 9, 4, 12, 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) + } + owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "home.owner", Email: "owner@example.test", DisplayName: "Home Owner", Password: "owner password for assisted recovery"}) + if err != nil { + t.Fatal(err) + } + target, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.member", Email: "member@example.test", DisplayName: "Recover Member", Password: "old member password before recovery"}) + if err != nil { + t.Fatal(err) + } + organizationsService, err := organizations.New(store, organizations.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"}) + if err != nil { + t.Fatal(err) + } + home, err := organizationsService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "assisted-home", Name: "Assisted Home", OwnerUserID: owner.ID}) + if err != nil { + t.Fatal(err) + } + invitation, _, err := organizationsService.Invite(t.Context(), home.ID, target.Email, owner.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + if err = organizationsService.AcceptInvitation(t.Context(), invitation, target.ID); err != nil { + t.Fatal(err) + } + policy := access.Policy{ + Roles: map[string]string{"owner": "Organization owner", "viewer": "Organization viewer"}, + Permissions: map[string]string{"account.recover": "Recover an organization member"}, + Grants: map[string][]string{"owner": {"account.recover"}, "viewer": {}}, + } + accessService, err := access.New(store, policy, access.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"}) + if err != nil { + t.Fatal(err) + } + if err = accessService.Seed(t.Context()); err != nil { + t.Fatal(err) + } + if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil { + t.Fatal(err) + } + if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: target.ID, Role: "viewer", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); 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: target.ID, Label: "Old passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "old-passkey-audit-id", ActorUserID: target.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Old passkey fixture", CreatedAt: now}); err != nil { + t.Fatal(err) + } + passkeys := &passkeyRecoveryStub{now: now, credentialID: bytes.Repeat([]byte{8}, 32)} + recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys, OwnerRole: "owner"}) + if err != nil { + t.Fatal(err) + } + oldCodes, err := recovery.ReplaceCodes(t.Context(), target.ID, target.ID) + if err != nil { + t.Fatal(err) + } + oldSession, _, err := authService.IssueSession(t.Context(), target.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + + if _, _, err = recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: target.ID, TargetUserID: owner.ID, RequestID: "request-denied-123", Reason: "Target asked for recovery after identity review"}); !errors.Is(err, authrecovery.ErrAssistedDenied) { + t.Fatalf("non-owner assisted recovery err=%v", err) + } + if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); err != nil { + t.Fatalf("denied recovery changed password: %v", err) + } + + loaded, grant, err := recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: owner.ID, TargetUserID: target.ID, RequestID: "request-assisted-123", Reason: "Member verified ownership through the documented support review"}) + if err != nil || loaded.ID != target.ID || grant == "" { + t.Fatalf("loaded=%+v grant_present=%v err=%v", loaded, grant != "", err) + } + if _, err = authService.Session(t.Context(), oldSession); !errors.Is(err, auth.ErrSessionNotFound) { + t.Fatalf("old session survived assisted recovery issue: %v", err) + } + if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("old password survived assisted recovery issue: %v", err) + } + credentials, err := store.CredentialsByUserID(t.Context(), target.ID) + if err != nil || len(credentials) != 0 { + t.Fatalf("old passkeys survived issue: credentials=%+v err=%v", credentials, err) + } + if _, _, err = recovery.Begin(t.Context(), target.Email, "old member password before recovery", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("old recovery path survived issue: %v", err) + } + + begin, err := recovery.BeginAssistedPasskey(t.Context(), grant, "Recovered passkey") + if err != nil || begin.CeremonyToken == "" || passkeys.userID != target.ID || passkeys.beginBinding != grant { + t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err) + } + result, err := recovery.FinishAssistedRecovery(t.Context(), grant, begin.CeremonyToken, "new member password after recovery", []byte(`{"fixture":true}`)) + if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount { + t.Fatalf("result=%+v err=%v", result, err) + } + if _, err = authService.VerifyPassword(t.Context(), target.Email, "new member password after recovery"); err != nil { + t.Fatalf("replacement password unavailable: %v", err) + } + credentials, err = store.CredentialsByUserID(t.Context(), target.ID) + if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, passkeys.credentialID) { + t.Fatalf("replacement credentials=%+v err=%v", credentials, err) + } + if _, err = recovery.BeginAssistedPasskey(t.Context(), grant, "Replay"); !errors.Is(err, authrecovery.ErrAssistedNotFound) { + t.Fatalf("assisted grant replay err=%v", err) + } + if _, nextGrant, beginErr := recovery.Begin(t.Context(), target.Email, "new member password after recovery", result.RecoveryCodes[0]); beginErr != nil || nextGrant == "" { + t.Fatalf("replacement recovery material unavailable: grant_present=%v err=%v", nextGrant != "", beginErr) + } + audits, err := accessService.Audit(t.Context(), home.ID, 20) + if err != nil { + t.Fatal(err) + } + seenIssue, seenComplete := false, false + for _, audit := range audits { + seenIssue = seenIssue || audit.Action == "access.account-recovery.issue" && audit.ActorUserID == owner.ID && audit.ResourceID == target.ID && audit.RequestID == "request-assisted-123" + seenComplete = seenComplete || audit.Action == "access.account-recovery.complete" && audit.ActorUserID == target.ID && audit.ResourceID == target.ID + } + if !seenIssue || !seenComplete { + t.Fatalf("organization recovery audits issue=%v complete=%v events=%+v", seenIssue, seenComplete, audits) + } +} diff --git a/authrecovery/recovery.go b/authrecovery/recovery.go index 8229602..1d68742 100644 --- a/authrecovery/recovery.go +++ b/authrecovery/recovery.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "gamertan.com/web/access" "gamertan.com/web/auth" "gamertan.com/web/authwebauthn" ) @@ -25,6 +26,8 @@ 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") ) @@ -49,6 +52,47 @@ type PasskeyRepository interface { 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) @@ -78,21 +122,25 @@ type PasswordVerifier interface { } type Options struct { - Random io.Reader - Now func() time.Time - CodeCount int - GrantLifetime time.Duration - Passkeys Passkeys + 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 - passkeys Passkeys + 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) { @@ -111,10 +159,120 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S 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 { + 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, passkeys: options.Passkeys}, nil + 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 @@ -290,3 +448,29 @@ func token(random io.Reader, size int) (string, error) { } 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 +} diff --git a/authsqlite/assisted_recovery.go b/authsqlite/assisted_recovery.go new file mode 100644 index 0000000..fd67136 --- /dev/null +++ b/authsqlite/assisted_recovery.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/auth" + "gamertan.com/web/authrecovery" +) + +func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) { + if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) { + return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return auth.User{}, err + } + defer tx.Rollback() + + // Take the SQLite write lock before checking owner authority so a role or + // membership mutation cannot race the reviewed recovery decision. + result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status + WHERE organization_id=? AND user_id=? AND status='active' + AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active') + AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0) + AND EXISTS (SELECT 1 FROM gwf_access_bindings b WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id=? AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole) + if err != nil { + return auth.User{}, err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return auth.User{}, authrecovery.ErrAssistedDenied + } + + user, err := scanPasskeyUser(tx.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_users u JOIN gwf_organization_memberships m ON m.user_id=u.id + WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID)) + if errors.Is(err, auth.ErrUserNotFound) { + return auth.User{}, authrecovery.ErrAssistedDenied + } + if err != nil { + return auth.User{}, err + } + + for _, statement := range []string{ + `DELETE FROM gwf_auth_sessions WHERE user_id=?`, + `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, + `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, + `DELETE FROM gwf_recovery_grants WHERE user_id=?`, + `DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`, + `DELETE FROM gwf_password_credentials WHERE user_id=?`, + `DELETE FROM gwf_passkey_credentials WHERE user_id=?`, + `DELETE FROM gwf_recovery_codes WHERE user_id=?`, + } { + if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil { + return auth.User{}, err + } + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil { + return auth.User{}, err + } + if err = appendAudit(ctx, tx, authAudit); err != nil { + return auth.User{}, err + } + if err = appendAccessAudit(ctx, tx, accessAudit); err != nil { + return auth.User{}, err + } + if err = tx.Commit(); err != nil { + return auth.User{}, err + } + return user, nil +} + +func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) { + if zeroDigest(digest) || now.IsZero() { + return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound + } + var grant authrecovery.AssistedGrant + var user auth.User + var created, expires, userCreated, userUpdated int64 + var passwordChangeRequired, registrationPending int + err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at + FROM gwf_assisted_recovery_grants g + JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0 + JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active' + JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active' + WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &userCreated, &userUpdated) + if errors.Is(err, sql.ErrNoRows) { + return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound + } + if err != nil { + return authrecovery.AssistedGrant{}, auth.User{}, err + } + grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC() + user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1 + user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC() + return grant, user, nil +} + +func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error { + credential := completion.Credential + credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID) + if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) { + return errors.New("authsqlite: invalid assisted recovery completion") + } + seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests)) + for _, digest := range completion.RecoveryDigests { + if zeroDigest(digest) { + return errors.New("authsqlite: invalid assisted recovery-code digest") + } + if _, duplicate := seen[digest]; duplicate { + return errors.New("authsqlite: duplicate assisted recovery-code digest") + } + seen[digest] = struct{}{} + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var userID, organizationID string + err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID) + if errors.Is(err, sql.ErrNoRows) { + return authrecovery.ErrAssistedNotFound + } + if err != nil { + return err + } + if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID { + return errors.New("authsqlite: assisted recovery identity mismatch") + } + var active int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u + JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active' + JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' + WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil { + return err + } + if active != 1 { + return auth.ErrInactiveUser + } + for _, statement := range []string{ + `DELETE FROM gwf_auth_sessions WHERE user_id=?`, + `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, + `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, + `DELETE FROM gwf_recovery_grants WHERE user_id=?`, + `DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`, + `DELETE FROM gwf_password_credentials WHERE user_id=?`, + `DELETE FROM gwf_passkey_credentials WHERE user_id=?`, + `DELETE FROM gwf_recovery_codes WHERE user_id=?`, + } { + if _, err = tx.ExecContext(ctx, statement, userID); err != nil { + return err + } + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); 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 + } + 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, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), 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 + } + if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil { + return err + } + return tx.Commit() +} + +func validAssistedGrant(grant authrecovery.AssistedGrant) bool { + return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute +} diff --git a/authsqlite/store.go b/authsqlite/store.go index 606341f..7f3ba7f 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) { return store, nil } -const SchemaVersion = 8 +const SchemaVersion = 9 func (store *Store) CurrentSchema(ctx context.Context) (int, error) { var exists int @@ -136,6 +136,8 @@ func (store *Store) Migrate(ctx context.Context) error { `CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`, `CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`, `CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`, + `CREATE TABLE IF NOT EXISTS gwf_assisted_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, issued_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS gwf_assisted_recovery_grants_expiry ON gwf_assisted_recovery_grants(expires_at)`, `CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`, `CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`, `CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, @@ -234,6 +236,9 @@ func (store *Store) Migrate(ctx context.Context) error { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(8,?)`, time.Now().UTC().Unix()); err != nil { return err } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil { + return err + } return tx.Commit() } diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index d03ce53..565607e 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -65,3 +65,11 @@ application concern belongs in the shared module. optimistic extension serializes on the active administrator membership, rechecks the exact state bound into the passkey assertion, applies team and direct-binding consequences, and writes the audit in one transaction. +- Human-assisted recovery cannot safely be expressed as a root command behind + an HTTP button. Preview 18 adds a distinct owner-assisted protocol: the + application performs the human review and fresh operation-bound passkey + ceremony, while the SQLite transaction rechecks an active direct owner, + invalidates every old account authenticator, stores only the grant digest, + and writes identity plus organization audits. Grant completion installs the + replacement password, passkey, and recovery-code set atomically and never + issues a session. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 40cd413..4dd1cfe 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.17 +go get gamertan.com/web/requestmeta@v0.1.0-preview.18 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index 0e5c710..ac77b55 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.17 +go get gamertan.com/web/requestmeta@v0.1.0-preview.18 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index 4dc6763..4c94ba4 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -64,6 +64,15 @@ grant organization-data access. If an operator must inspect tenant data during an incident, use a reasoned break-glass grant. It expires within one hour and creates an append-only audit event in the same transaction. +An application that offers owner-assisted account recovery must not infer that +authority from a broad administration page. Use the dedicated +`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey +assertion. The SQLite adapter requires a current active direct owner binding +and active target membership in the same transaction that invalidates the old +credentials and records the organization-visible recovery audit. Team, +break-glass, platform, and merely descriptive roles do not satisfy this owner +check. + The SQLite adapter namespaces all tables, enforces active organization and team membership plus resource ancestry before accepting or evaluating a binding, and keeps invitations and sessions as digests. Applications remain responsible diff --git a/docs/PASSKEYS.md b/docs/PASSKEYS.md index 3ab8e4e..96f2a6c 100644 --- a/docs/PASSKEYS.md +++ b/docs/PASSKEYS.md @@ -85,5 +85,22 @@ 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. +Owner-assisted recovery is a third, deliberately separate path. Configure +`authrecovery.Options.OwnerRole`, authorize an active direct organization owner, +and bind that owner's fresh passkey assertion to the exact organization, +target user, request identifier, and bounded human-review reason before calling +`IssueAssistedRecovery`. The SQLite transaction rechecks the active direct +owner and target membership, invalidates the target's password, passkeys, +recovery codes, sessions, and pending ceremonies, then stores only a digest of +the 15-minute grant with identity and organization-visible audits. + +Deliver the returned grant exactly once in a URL fragment. A public recovery +page can pass it to `BeginAssistedPasskey` and `FinishAssistedRecovery` while +keeping it out of request URLs, referrers, and access logs. Completion consumes +the grant atomically with one replacement password, passkey, recovery-code set, +and both audit trails. It issues no session. Losing the fragment after issuance +requires another reviewed owner or root-local recovery; old authenticators +must not become valid again as a fallback. + Before enabling production mutations, applications should require at least two independent passkeys and complete a local recovery drill. diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index 4a227b4..9efd6de 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -30,6 +30,7 @@ auth/password.go auth/password_test.go authrecovery/recovery.go authrecovery/recovery_test.go +authrecovery/assisted_test.go auth/service_test.go authhttp/authhttp.go authhttp/authhttp_test.go @@ -40,6 +41,7 @@ authsqlite/store_test.go authsqlite/account.go authsqlite/account_test.go authsqlite/access.go +authsqlite/assisted_recovery.go authsqlite/bootstrap.go authsqlite/bootstrap_test.go authsqlite/organizations.go