auth: publish audited password recovery
verify / verify (push) Successful in 3m7s

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 <crspeelman@gmail.com>
This commit is contained in:
2026-08-18 09:31:08 -04:00
parent 5905fe6fb2
commit fb6bbd0dad
11 changed files with 241 additions and 9 deletions
+11
View File
@@ -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 13 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
+6 -5
View File
@@ -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.
+6
View File
@@ -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.
+55
View File
@@ -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")
+3
View File
@@ -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) {
+3
View File
@@ -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) {
+46 -2
View File
@@ -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 {
+92
View File
@@ -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)
+9 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+9
View File
@@ -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