From fb6bbd0dad3932e617ab0438bb5ef0cf12a1b2a4 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Tue, 18 Aug 2026 09:31:08 -0400 Subject: [PATCH] auth: publish audited password recovery Publish the reviewed Gamertan Web Foundations v0.1.0-preview.4 snapshot with local-only administrative reset, atomic Argon2id credential replacement, mandatory rotation, all-session revocation, secret-free audit evidence, rollback coverage, and exact application-boundary guidance. Exported from reviewed private source 403e5f6ef4d0cac683aaa76ed922236571d259a9 after trusted CI run 317 and exact Go 1.26.6 verification. Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow. Signed-off-by: Cole Speelman --- CHANGELOG.md | 11 +++++ README.md | 11 ++--- SECURITY.md | 6 +++ auth/auth.go | 55 +++++++++++++++++++++++ auth/service_test.go | 3 ++ authhttp/authhttp_test.go | 3 ++ authsqlite/store.go | 48 +++++++++++++++++++- authsqlite/store_test.go | 92 +++++++++++++++++++++++++++++++++++++++ docs/GETTING_STARTED.md | 10 ++++- docs/MODULES.md | 2 +- docs/THREAT_MODEL.md | 9 ++++ 11 files changed, 241 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a354de..f81a332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ # Changelog +## v0.1.0-preview.4 — 2026-08-18 + +- Add an explicit local-administrator password recovery operation without + adding a public recovery endpoint or network protocol. +- Atomically install a one-time Argon2id credential, restore mandatory password + rotation, revoke every session, and append a secret-free audit event. +- Prove transaction rollback when the audit event cannot commit and document + private mode-`0600` delivery as application-owned policy. +- Keep Previews 1–3 immutable; applications select Preview 4 explicitly when + adopting administrative recovery. + ## v0.1.0-preview.3 — 2026-08-18 - Add cryptographically generated temporary credentials and an explicit diff --git a/README.md b/README.md index 54e060a..78a9091 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Gamertan Web Foundations -> Status: `v0.1.0-preview.2` public preview. APIs may change before a stable +> Status: `v0.1.0-preview.4` public preview. APIs may change before a stable > release; Linux is the maintained release platform. Small, composable Go packages for the unglamorous boundaries of a careful web @@ -23,14 +23,14 @@ Pin the preview in an application module, then import only the packages that application needs: ```bash -go get gamertan.com/web@v0.1.0-preview.2 +go get gamertan.com/web@v0.1.0-preview.4 go mod verify ``` An application may also name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.2 +go get gamertan.com/web/requestmeta@v0.1.0-preview.4 ``` The version belongs to the `gamertan.com/web` module. Go compiles and links @@ -56,8 +56,9 @@ without turning that portability into a maintained compatibility claim. rate limits. - [`abuse`](abuse): application-classified request abuse with pluggable persistence. - [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite): - passwords, forced first-login rotation, session revocation, platform-level - permissions, cookies, and a no-CGO SQLite adapter. + passwords, forced first-login rotation, local administrative recovery, + session revocation, platform-level permissions, cookies, and a no-CGO SQLite + adapter. - [`organizations`](organizations) and [`access`](access): organizations, teams, invitations, resource hierarchy, scoped roles, and audited temporary access without turning platform operation into tenant-data access. diff --git a/SECURITY.md b/SECURITY.md index 0e5a00f..8115074 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,3 +14,9 @@ service-level agreement. There is no bug bounty. The preview supports only versions explicitly listed in release notes. Security claims stop at the documented trust boundaries and executable tests. + +Password recovery is an explicitly local administrative capability. It must +not be wired directly to a public route. Applications using it are responsible +for local operator authorization and exclusive mode-`0600` credential delivery; +the library transaction requires a new password change, revokes all sessions, +and records a secret-free audit event. diff --git a/auth/auth.go b/auth/auth.go index 12f231a..7386640 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -63,6 +63,7 @@ type Repository interface { CredentialByIdentifier(context.Context, string) (User, string, error) CredentialByUserID(context.Context, string) (User, string, error) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error + ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error UpdateLastLogin(context.Context, string, time.Time) error CreateSession(context.Context, Session) error PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) @@ -111,6 +112,13 @@ type CreateUser struct { RequirePasswordChange bool } +// AdministrativePasswordReset describes a locally authorized recovery. The +// application is responsible for delivering TemporaryPassword through a +// private, one-time channel; the value must never be logged or audited. +type AdministrativePasswordReset struct { + Identifier, TemporaryPassword string +} + func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) { username := strings.TrimSpace(input.Username) email := strings.TrimSpace(input.Email) @@ -178,6 +186,53 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw return nil } +// ResetPassword replaces an active user's credential without requiring the +// current password. It is intended only for a locally authorized +// administrative recovery command. The repository atomically requires another +// password change, revokes all sessions, and appends a secret-free audit event. +func (service *Service) ResetPassword(ctx context.Context, input AdministrativePasswordReset) (User, error) { + identifier := strings.TrimSpace(input.Identifier) + user, currentHash, err := service.repository.CredentialByIdentifier(ctx, identifier) + if errors.Is(err, ErrUserNotFound) { + return User{}, ErrUserNotFound + } + if err != nil { + return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err) + } + if user.Status != "active" { + return User{}, ErrInactiveUser + } + if VerifyPassword(currentHash, input.TemporaryPassword) { + return User{}, ErrPasswordUnchanged + } + newHash, err := HashPasswordWithRandom(input.TemporaryPassword, service.random) + if err != nil { + return User{}, err + } + auditID, err := randomToken(service.random, 18) + if err != nil { + return User{}, err + } + now := service.now().UTC() + audit := AuditEvent{ + ID: auditID, + Action: "auth.password.reset", + ResourceType: "user", + ResourceID: user.ID, + Summary: "A local administrator issued a one-time credential and revoked all sessions.", + CreatedAt: now, + } + if err = service.repository.ResetPasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, now, audit); err != nil { + if errors.Is(err, ErrInvalidCredentials) { + return User{}, ErrInvalidCredentials + } + return User{}, fmt.Errorf("auth: reset password: %w", err) + } + user.PasswordChangeRequired = true + user.UpdatedAt = now + return user, nil +} + func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) { if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour { return "", Principal{}, errors.New("auth: invalid session lifetime") diff --git a/auth/service_test.go b/auth/service_test.go index ebd8e35..2cf38e0 100644 --- a/auth/service_test.go +++ b/auth/service_test.go @@ -65,6 +65,9 @@ func (repositoryStub) CredentialByUserID(context.Context, string) (User, string, func (repositoryStub) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error { return nil } +func (repositoryStub) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error { + return nil +} func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil } func (repositoryStub) CreateSession(context.Context, Session) error { return nil } func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) { diff --git a/authhttp/authhttp_test.go b/authhttp/authhttp_test.go index 16f64f2..8059fcb 100644 --- a/authhttp/authhttp_test.go +++ b/authhttp/authhttp_test.go @@ -115,6 +115,9 @@ func (authHTTPRepository) CredentialByUserID(context.Context, string) (auth.User func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error { return nil } +func (authHTTPRepository) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, auth.AuditEvent) error { + return nil +} func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil } func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil } func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) { diff --git a/authsqlite/store.go b/authsqlite/store.go index 9d0726a..be9bffc 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -243,6 +243,38 @@ func (store *Store) ReplacePasswordAndRevokeSessions(ctx context.Context, userID return tx.Commit() } +func (store *Store) ResetPasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time, audit auth.AuditEvent) error { + if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() || !validAuditEvent(audit) || audit.ActorUserID != "" || audit.ResourceType != "user" || audit.ResourceID != userID || !audit.CreatedAt.Equal(changedAt) { + return auth.ErrInvalidCredentials + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed != 1 { + return auth.ErrInvalidCredentials + } + if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=1,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil { + return err + } + if err = appendAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error { if !opaqueID(userID) || when.IsZero() { return errors.New("authsqlite: invalid login update") @@ -379,13 +411,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim return err } func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error { - if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() { + if !validAuditEvent(event) { return errors.New("authsqlite: invalid audit event") } - _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix()) + return appendAudit(ctx, store.db, event) +} + +type auditExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func appendAudit(ctx context.Context, execer auditExecer, event auth.AuditEvent) error { + _, err := execer.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix()) return err } +func validAuditEvent(event auth.AuditEvent) bool { + return opaqueID(event.ID) && (event.ActorUserID == "" || opaqueID(event.ActorUserID)) && safeName(event.Action) && safeName(event.ResourceType) && text(event.ResourceID, 256, false) && text(event.RequestID, 128, true) && text(event.Summary, 1024, true) && !event.CreatedAt.IsZero() +} + func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func safeName(value string) bool { if value == "" || len(value) > 128 { diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index a0556ab..907a201 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -100,6 +100,98 @@ func TestRequiredPasswordChangeRotatesCredentialAndRevokesSessions(t *testing.T) } } +func TestAdministrativePasswordResetIsAtomicAndAudited(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(4000, 0).UTC() + service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recovery Test", Password: "original permanent credential"}) + if err != nil { + t.Fatal(err) + } + token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour) + if err != nil { + t.Fatal(err) + } + reset, err := service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Email, TemporaryPassword: "one-time recovery credential"}) + if err != nil || !reset.PasswordChangeRequired { + t.Fatalf("reset=%+v err=%v", reset, err) + } + if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) { + t.Fatalf("session survived reset: %v", err) + } + if _, _, err = service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("old credential survived reset: %v", err) + } + _, principal, err := service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour) + if err != nil || !principal.User.PasswordChangeRequired { + t.Fatalf("recovery principal=%+v err=%v", principal, err) + } + var action, summary string + var events int + if err = store.db.QueryRow(`SELECT COUNT(*),action,summary FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&events, &action, &summary); err != nil { + t.Fatal(err) + } + if events != 1 || action != "auth.password.reset" || strings.Contains(summary, "one-time recovery credential") || !strings.Contains(summary, "revoked all sessions") { + t.Fatalf("events=%d action=%q summary=%q", events, action, summary) + } + if _, err = service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Username, TemporaryPassword: "one-time recovery credential"}); !errors.Is(err, auth.ErrPasswordUnchanged) { + t.Fatalf("same credential err=%v", err) + } +} + +func TestAdministrativePasswordResetRollsBackWhenAuditCannotCommit(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(5000, 0).UTC() + service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "rollback.me", Email: "rollback@example.test", DisplayName: "Rollback Test", Password: "original permanent credential"}) + if err != nil { + t.Fatal(err) + } + token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour) + if err != nil { + t.Fatal(err) + } + user, currentHash, err := store.CredentialByUserID(t.Context(), user.ID) + if err != nil { + t.Fatal(err) + } + newHash, err := auth.HashPassword("one-time recovery credential") + if err != nil { + t.Fatal(err) + } + audit := auth.AuditEvent{ID: "duplicate-audit-id", Action: "auth.password.reset", ResourceType: "user", ResourceID: user.ID, Summary: "A local administrator issued a one-time credential and revoked all sessions.", CreatedAt: now} + if err = store.AppendAudit(t.Context(), audit); err != nil { + t.Fatal(err) + } + if err = store.ResetPasswordAndRevokeSessions(t.Context(), user.ID, currentHash, newHash, now, audit); err == nil { + t.Fatal("duplicate audit unexpectedly committed reset") + } + if _, err = service.Session(t.Context(), token); err != nil { + t.Fatalf("rollback revoked session: %v", err) + } + _, principal, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour) + if err != nil || principal.User.PasswordChangeRequired { + t.Fatalf("original credential not restored: principal=%+v err=%v", principal, err) + } + if _, _, err = service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("uncommitted recovery credential accepted: %v", err) + } +} + func TestMigrationAddsPasswordRequirementWithoutChangingExistingUsers(t *testing.T) { path := filepath.Join(t.TempDir(), "accounts.db") database, err := sql.Open("sqlite", path) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 2cb5e42..f0d6fa3 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -25,7 +25,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.3 +go get gamertan.com/web/requestmeta@v0.1.0-preview.4 go mod verify ``` @@ -67,6 +67,14 @@ adapter. Clear the browser cookie and require a fresh login after success. Do not treat a redirect alone as enforcement; apply the restriction before every protected handler. +For operator-led recovery, expose `auth.ResetPassword` only through a local +administrative command—not a public HTTP endpoint. The operation installs an +application-generated one-time credential, sets `PasswordChangeRequired`, +revokes every existing session, and appends a secret-free audit event in the +same repository transaction. Deliver that credential through an exclusive +root-owned mode-`0600` file, delete it after successful rotation, and never put +it in command arguments, stdout, logs, manifests, or deployment state. + ## Add HTML without merging responsibilities Handlers should convert request and service state into typed display data. diff --git a/docs/MODULES.md b/docs/MODULES.md index 56792b9..c03799e 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.2 +go get gamertan.com/web/requestmeta@v0.1.0-preview.4 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 0323358..7abdeb7 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -23,6 +23,15 @@ the library does not infer route policy. Temporary credentials must be written to a private channel or mode-`0600` file and must never be printed into logs, manifests, process arguments, or deployment state. +Administrative recovery is deliberately a separate capability. The storage +adapter atomically replaces the credential, restores the password-change +requirement, revokes all sessions, and appends a generic audit event. The core +library does not expose a recovery HTTP handler, deliver the credential, or +authorize the local operator. Applications must keep that command local, +generate the credential cryptographically, and write it only to a newly created +private file. A recovery must not reveal whether an account exists through a +public request surface. + Unsafe methods without an exact Origin or trustworthy same-origin Fetch Metadata fail the origin check. Authentication middleware fails closed when its service or `__Host-` cookie policy is invalid. Imported request records have