From 92ef63ba00e3bc3698aa74d7c407414ebbd3cc3f Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Thu, 3 Sep 2026 11:51:53 -0400 Subject: [PATCH] Add atomic account registration and local media --- CHANGELOG.md | 20 ++ README.md | 16 +- account/account.go | 338 ++++++++++++++++++++++++++++++++++ auth/auth.go | 52 ++++-- auth/service_test.go | 41 +++++ authrecovery/recovery.go | 182 ++++++++++++++++++ authrecovery/recovery_test.go | 72 ++++++++ authsqlite/account.go | 160 ++++++++++++++++ authsqlite/account_test.go | 155 ++++++++++++++++ authsqlite/passkey.go | 19 +- authsqlite/recovery.go | 99 ++++++++++ authsqlite/store.go | 75 +++++++- authsqlite/store_test.go | 18 ++ authwebauthn/service.go | 65 +++++-- authwebauthn/types.go | 15 +- docs/DOGFOOD.md | 36 ++++ docs/GETTING_STARTED.md | 2 +- docs/MODULES.md | 8 +- media/media.go | 190 +++++++++++++++++++ media/media_test.go | 52 ++++++ medialocal/store.go | 187 +++++++++++++++++++ medialocal/store_test.go | 65 +++++++ scripts/public-snapshot.allow | 11 ++ 23 files changed, 1816 insertions(+), 62 deletions(-) create mode 100644 account/account.go create mode 100644 authrecovery/recovery.go create mode 100644 authrecovery/recovery_test.go create mode 100644 authsqlite/account.go create mode 100644 authsqlite/account_test.go create mode 100644 authsqlite/recovery.go create mode 100644 docs/DOGFOOD.md create mode 100644 media/media.go create mode 100644 media/media_test.go create mode 100644 medialocal/store.go create mode 100644 medialocal/store_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 660c98c..6ea7ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ # Changelog +## v0.1.0-preview.10 — 2026-09-03 + +- Add atomic public-account registration with required canonical email, + password authentication, printable recovery codes, a personal organization, + direct owner access, and an optional initial passkey. Pending registrations + cannot authenticate, and abandoned drafts expire without reserving identity + fields indefinitely. +- Add password verification without session issuance plus operation-bound + WebAuthn completion hooks, allowing applications to require fresh passkeys + for sensitive actions without imposing passkeys on ordinary customer use. +- Add digest-only recovery-code persistence and short-lived, single-use + recovery grants that consume a code and revoke existing sessions atomically. +- Add bounded raster/PDF media preparation and a hardened content-addressed + local filesystem adapter with atomic writes, private modes, and symlink + rejection. +- Add explicit SQLite open-without-migration and schema-requirement APIs while + preserving the historical migrating `Open` behavior for existing adopters. +- Record application dogfood findings and the independent future commerce + module boundary. + ## v0.1.0-preview.9 — 2026-09-03 - Add a documented root package and executable composition example so the diff --git a/README.md b/README.md index bdf47b3..c62688d 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.9`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.10`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -38,8 +38,11 @@ packages you import. | Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) | | Browser and HTTP security primitives | [`websec`](websec) | | Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) | -| Passkey-only authentication | [`authwebauthn`](authwebauthn) | +| Atomic password-plus-passkey registration | [`account`](account) | +| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) | +| Printable single-use recovery codes | [`authrecovery`](authrecovery) | | Private SQLite persistence | [`authsqlite`](authsqlite) | +| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) | | Organizations, teams, and invitations | [`organizations`](organizations) | | Organization-scoped roles and temporary access | [`access`](access) | | Application-classified request abuse | [`abuse`](abuse) | @@ -53,14 +56,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.9 +go get gamertan.com/web@v0.1.0-preview.10 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.9 +go get gamertan.com/web/requestmeta@v0.1.0-preview.10 ``` The version belongs to the `gamertan.com/web` module. See the @@ -91,10 +94,13 @@ JSONL logging. - [`auth`](auth) defines storage-neutral users, password credentials, opaque sessions, platform permissions, and audit events. +- [`account`](account) composes the first password, printable recovery codes, + personal organization, and owner access as one registration transaction, + optionally including an initial passkey. - [`authhttp`](authhttp) connects those sessions to secure browser cookies and request context without owning login routes or pages. - [`authwebauthn`](authwebauthn) provides discoverable passkey login, - passkey-only enrollment, fresh-operation approval, and bounded recovery. + enrollment, operation-bound fresh approval, and bounded recovery. - [`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/account/account.go b/account/account.go new file mode 100644 index 0000000..bc60d59 --- /dev/null +++ b/account/account.go @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package account orchestrates atomic account registration. Email is the +// canonical sign-in identifier; username remains the stable public/profile +// identity. Applications may finish with password-only base access or include +// an initial passkey when their onboarding policy requires one. +package account + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "net/mail" + "regexp" + "strings" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/auth" + "gamertan.com/web/authrecovery" + "gamertan.com/web/authwebauthn" + "gamertan.com/web/organizations" +) + +var ( + ErrRegistrationNotFound = errors.New("account: registration not found") + ErrPasskeysUnavailable = errors.New("account: passkeys are unavailable") + usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`) +) + +type Registration struct { + Digest [32]byte + User auth.User + CreatedAt, ExpiresAt time.Time +} + +type RegistrationCompletion struct { + Credential *authwebauthn.Credential + RecoveryDigests [][32]byte + Organization organizations.Organization + Membership organizations.Membership + OwnerBinding access.Binding + AuthAudit auth.AuditEvent + OrganizationAudit organizations.AuditEvent + AccessAudit access.AuditEvent + CompletedAt time.Time +} + +type Repository interface { + CreateRegistration(context.Context, Registration, string, auth.AuditEvent) error + Registration(context.Context, [32]byte, time.Time) (Registration, error) + CompleteRegistration(context.Context, [32]byte, RegistrationCompletion) error +} + +type Passkeys interface { + BeginAccountRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error) + FinishAccountRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) +} + +type Sessions interface { + IssueSession(context.Context, string, time.Duration) (string, auth.Principal, error) +} + +type Options struct { + Random io.Reader + Now func() time.Time + RegistrationTTL time.Duration + SessionLifetime time.Duration + RecoveryCodes int + OwnerRole string +} + +type Service struct { + repository Repository + passkeys Passkeys + sessions Sessions + random io.Reader + now func() time.Time + draftTTL time.Duration + sessionTTL time.Duration + codeCount int + ownerRole string +} + +func New(repository Repository, passkeys Passkeys, sessions Sessions, options Options) (*Service, error) { + if repository == nil || sessions == nil { + return nil, errors.New("account: repository and sessions are required") + } + if options.Random == nil { + options.Random = rand.Reader + } + if options.Now == nil { + options.Now = time.Now + } + if options.RegistrationTTL == 0 { + options.RegistrationTTL = 15 * time.Minute + } + if options.SessionLifetime == 0 { + options.SessionLifetime = 12 * time.Hour + } + if options.RecoveryCodes == 0 { + options.RecoveryCodes = authrecovery.DefaultCodeCount + } + if options.OwnerRole == "" { + options.OwnerRole = "owner" + } + if options.RegistrationTTL < 5*time.Minute || options.RegistrationTTL > time.Hour || options.SessionLifetime < 5*time.Minute || options.SessionLifetime > 30*24*time.Hour || options.RecoveryCodes < 5 || options.RecoveryCodes > 20 || !roleName(options.OwnerRole) { + return nil, errors.New("account: invalid registration policy") + } + return &Service{repository: repository, passkeys: passkeys, sessions: sessions, random: options.Random, now: options.Now, draftTTL: options.RegistrationTTL, sessionTTL: options.SessionLifetime, codeCount: options.RecoveryCodes, ownerRole: options.OwnerRole}, nil +} + +type StartInput struct { + Email, Username, DisplayName, Password string +} + +type StartResult struct { + RegistrationToken string + User auth.User + ExpiresAt time.Time +} + +// Start validates and stores a bounded pending registration. The returned +// secret is displayed only to the same browser flow and binds every following +// ceremony to this draft. +func (service *Service) Start(ctx context.Context, input StartInput) (StartResult, error) { + email, err := canonicalEmail(input.Email) + if err != nil { + return StartResult{}, err + } + username := strings.TrimSpace(input.Username) + displayName := strings.TrimSpace(input.DisplayName) + if !usernamePattern.MatchString(username) || displayName == "" || len(displayName) > 128 || strings.ContainsAny(displayName, "\x00\r\n") { + return StartResult{}, errors.New("account: invalid profile") + } + passwordHash, err := auth.HashPasswordWithRandom(input.Password, service.random) + if err != nil { + return StartResult{}, err + } + userID, err := service.token(18) + if err != nil { + return StartResult{}, err + } + rawToken, err := service.token(32) + if err != nil { + return StartResult{}, err + } + now := service.now().UTC() + user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", RegistrationPending: true, CreatedAt: now, UpdatedAt: now} + registration := Registration{Digest: sha256.Sum256([]byte(rawToken)), User: user, CreatedAt: now, ExpiresAt: now.Add(service.draftTTL)} + audit, err := service.authAudit(user.ID, "auth.account.registration.start", "A public account registration was started.") + if err != nil { + return StartResult{}, err + } + if err = service.repository.CreateRegistration(ctx, registration, passwordHash, audit); err != nil { + return StartResult{}, err + } + return StartResult{RegistrationToken: rawToken, User: user, ExpiresAt: registration.ExpiresAt}, nil +} + +func (service *Service) BeginPasskey(ctx context.Context, registrationToken, label string) (authwebauthn.BeginResult, error) { + if service.passkeys == nil { + return authwebauthn.BeginResult{}, ErrPasskeysUnavailable + } + registration, err := service.registration(ctx, registrationToken) + if err != nil { + return authwebauthn.BeginResult{}, err + } + return service.passkeys.BeginAccountRegistration(ctx, registration.User.ID, label, []byte(registrationToken)) +} + +type FinishResult struct { + User auth.User + Organization organizations.Organization + RecoveryCodes []string + SessionToken string + Principal auth.Principal + PasskeyCredential authwebauthn.Credential +} + +// FinishPassword activates a base account without requiring WebAuthn. The +// application can require an operation-bound passkey assertion later for +// sensitive permissions. +func (service *Service) FinishPassword(ctx context.Context, registrationToken string) (FinishResult, error) { + registration, err := service.registration(ctx, registrationToken) + if err != nil { + return FinishResult{}, err + } + codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount) + if err != nil { + return FinishResult{}, err + } + completion, err := service.completion(registration, recoveryDigests) + if err != nil { + return FinishResult{}, err + } + completion.AuthAudit, err = service.authAudit(registration.User.ID, "auth.account.registration.complete", "The password-authenticated account registration was completed.") + if err != nil { + return FinishResult{}, err + } + if err = service.repository.CompleteRegistration(ctx, registration.Digest, completion); err != nil { + return FinishResult{}, err + } + return service.finishSession(ctx, registration, completion, codes, authwebauthn.Credential{}) +} + +// FinishWithPasskey completes the same atomic account transaction while also +// storing a verified initial passkey. +func (service *Service) FinishWithPasskey(ctx context.Context, registrationToken, ceremonyToken string, response []byte) (FinishResult, error) { + if service.passkeys == nil { + return FinishResult{}, ErrPasskeysUnavailable + } + registration, err := service.registration(ctx, registrationToken) + if err != nil { + return FinishResult{}, err + } + codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount) + if err != nil { + return FinishResult{}, err + } + completion, err := service.completion(registration, recoveryDigests) + if err != nil { + return FinishResult{}, err + } + credential, err := service.passkeys.FinishAccountRegistration(ctx, ceremonyToken, []byte(registrationToken), response, func(commitCtx context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error { + completion.Credential = &verified + completion.AuthAudit = passkeyAudit + return service.repository.CompleteRegistration(commitCtx, registration.Digest, completion) + }) + if err != nil { + return FinishResult{}, err + } + return service.finishSession(ctx, registration, completion, codes, credential) +} + +func (service *Service) finishSession(ctx context.Context, registration Registration, completion RegistrationCompletion, codes []string, credential authwebauthn.Credential) (FinishResult, error) { + user := registration.User + user.RegistrationPending = false + user.UpdatedAt = completion.CompletedAt + result := FinishResult{User: user, Organization: completion.Organization, RecoveryCodes: codes, PasskeyCredential: credential} + sessionToken, principal, err := service.sessions.IssueSession(ctx, user.ID, service.sessionTTL) + if err != nil { + // Registration is already durable. Preserve the one-time recovery codes + // in the returned result so an application can display them while asking + // the user to sign in again. + return result, fmt.Errorf("account: registration completed but session issuance failed: %w", err) + } + result.SessionToken, result.Principal = sessionToken, principal + return result, nil +} + +func (service *Service) completion(registration Registration, recoveryDigests [][32]byte) (RegistrationCompletion, error) { + organizationID, err := service.token(18) + if err != nil { + return RegistrationCompletion{}, err + } + bindingID, err := service.token(18) + if err != nil { + return RegistrationCompletion{}, err + } + slugBytes := make([]byte, 6) + if _, err = io.ReadFull(service.random, slugBytes); err != nil { + return RegistrationCompletion{}, fmt.Errorf("account: secure randomness unavailable: %w", err) + } + now := service.now().UTC() + organization := organizations.Organization{ID: organizationID, Slug: "personal-" + hex.EncodeToString(slugBytes), Name: registration.User.DisplayName + " — Personal", Status: "active", Personal: true, Revision: 1, CreatedAt: now, UpdatedAt: now} + membership := organizations.Membership{OrganizationID: organizationID, UserID: registration.User.ID, Status: "active", JoinedAt: now} + binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: registration.User.ID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: registration.User.ID, GrantedAt: now} + organizationAuditID, err := service.token(18) + if err != nil { + return RegistrationCompletion{}, err + } + accessAuditID, err := service.token(18) + if err != nil { + return RegistrationCompletion{}, err + } + return RegistrationCompletion{ + RecoveryDigests: recoveryDigests, + Organization: organization, + Membership: membership, + OwnerBinding: binding, + OrganizationAudit: organizations.AuditEvent{ID: organizationAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "organization.personal.create", ResourceType: "organization", ResourceID: organizationID, Summary: "Personal organization created during account registration.", CreatedAt: now}, + AccessAudit: access.AuditEvent{ID: accessAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "access.owner.grant", ResourceType: "user", ResourceID: registration.User.ID, Summary: "Initial personal-organization owner access granted.", CreatedAt: now}, + CompletedAt: now, + }, nil +} + +func (service *Service) registration(ctx context.Context, raw string) (Registration, error) { + if len(raw) < 32 || len(raw) > 128 { + return Registration{}, ErrRegistrationNotFound + } + if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil { + return Registration{}, ErrRegistrationNotFound + } + return service.repository.Registration(ctx, sha256.Sum256([]byte(raw)), service.now().UTC()) +} + +func (service *Service) authAudit(userID, action, summary string) (auth.AuditEvent, error) { + id, err := service.token(18) + if err != nil { + return auth.AuditEvent{}, err + } + return auth.AuditEvent{ID: id, ActorUserID: userID, Action: action, ResourceType: "user", ResourceID: userID, Summary: summary, CreatedAt: service.now().UTC()}, nil +} + +func (service *Service) token(size int) (string, error) { + value := make([]byte, size) + if _, err := io.ReadFull(service.random, value); err != nil { + return "", fmt.Errorf("account: secure randomness unavailable: %w", err) + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func canonicalEmail(value string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + parsed, err := mail.ParseAddress(value) + if err != nil || parsed.Address != value || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") { + return "", errors.New("account: a valid email address is required") + } + return value, nil +} + +func roleName(value string) bool { + if len(value) < 2 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' { + return false + } + for _, character := range value[1:] { + if character < 'a' || character > 'z' && (character < '0' || character > '9') && character != '.' && character != '_' && character != '-' { + return false + } + } + return true +} diff --git a/auth/auth.go b/auth/auth.go index f1c4054..57d88fa 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -31,8 +31,14 @@ type User struct { ID, Username, Email, DisplayName, Status string CreatedAt, UpdatedAt time.Time PasswordChangeRequired bool + // RegistrationPending keeps a partially completed public registration + // ineligible for authentication until its credentials, personal scope, and + // recovery material have been committed atomically. + RegistrationPending bool } +func (user User) Active() bool { return user.Status == "active" && !user.RegistrationPending } + type Principal struct { User User Roles []string @@ -167,7 +173,7 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw if !VerifyPassword(currentHash, currentPassword) { return ErrInvalidCredentials } - if user.Status != "active" { + if !user.Active() { return ErrInactiveUser } if currentPassword == newPassword { @@ -199,7 +205,7 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP if err != nil { return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err) } - if user.Status != "active" { + if !user.Active() { return User{}, ErrInactiveUser } if VerifyPassword(currentHash, input.TemporaryPassword) { @@ -233,24 +239,36 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP return user, nil } +// VerifyPassword verifies the password credential for an active account +// without creating a session. Applications use it as the first step of a +// bounded multi-factor ceremony and must not treat success as an authenticated +// browser session on its own. +func (service *Service) VerifyPassword(ctx context.Context, identifier, password string) (User, error) { + user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier)) + if errors.Is(err, ErrUserNotFound) { + _ = VerifyPassword(dummyPasswordHash, password) + return User{}, ErrInvalidCredentials + } + if err != nil { + _ = VerifyPassword(dummyPasswordHash, password) + return User{}, fmt.Errorf("auth: load credentials: %w", err) + } + if !VerifyPassword(hash, password) { + return User{}, ErrInvalidCredentials + } + if !user.Active() { + return User{}, ErrInactiveUser + } + 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") } - user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier)) - if errors.Is(err, ErrUserNotFound) { - _ = VerifyPassword(dummyPasswordHash, password) - return "", Principal{}, ErrInvalidCredentials - } + user, err := service.VerifyPassword(ctx, identifier, password) if err != nil { - _ = VerifyPassword(dummyPasswordHash, password) - return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err) - } - if !VerifyPassword(hash, password) { - return "", Principal{}, ErrInvalidCredentials - } - if user.Status != "active" { - return "", Principal{}, ErrInactiveUser + return "", Principal{}, err } return service.IssueSession(ctx, user.ID, lifetime) } @@ -282,7 +300,7 @@ func (service *Service) IssueSession(ctx context.Context, userID string, lifetim _ = service.repository.DeleteSession(ctx, digest) return "", Principal{}, err } - if principal.User.Status != "active" { + if !principal.User.Active() { _ = service.repository.DeleteSession(ctx, digest) return "", Principal{}, ErrInactiveUser } @@ -302,7 +320,7 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e if err != nil { return Principal{}, fmt.Errorf("auth: load session: %w", err) } - if principal.User.Status != "active" { + if !principal.User.Active() { _ = service.repository.DeleteSession(ctx, digest) return Principal{}, ErrInactiveUser } diff --git a/auth/service_test.go b/auth/service_test.go index 75a735c..1a45d2e 100644 --- a/auth/service_test.go +++ b/auth/service_test.go @@ -57,6 +57,31 @@ func TestIssueSessionRejectsInactiveRepositoryPrincipal(t *testing.T) { } } +func TestVerifyPasswordDoesNotIssueSession(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatal(err) + } + repository := &credentialRepository{ + user: User{ID: "valid-user-id", Username: "person", Email: "person@example.test", Status: "active"}, + hash: hash, + } + service, err := New(repository, Options{}) + if err != nil { + t.Fatal(err) + } + user, err := service.VerifyPassword(t.Context(), "person@example.test", "correct horse battery staple") + if err != nil || user.ID != repository.user.ID { + t.Fatalf("user=%+v err=%v", user, err) + } + if repository.sessionCreated { + t.Fatal("password verification issued a session") + } + if _, err = service.VerifyPassword(t.Context(), "person@example.test", "wrong password"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("wrong password err=%v", err) + } +} + type recordingRepository struct { repositoryStub deleted bool @@ -68,6 +93,22 @@ type activeSessionRepository struct { deleted bool } +type credentialRepository struct { + repositoryStub + user User + hash string + sessionCreated bool +} + +func (repository *credentialRepository) CredentialByIdentifier(context.Context, string) (User, string, error) { + return repository.user, repository.hash, nil +} + +func (repository *credentialRepository) CreateSession(context.Context, Session) error { + repository.sessionCreated = true + return nil +} + func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) { return repository.principal, Session{}, nil } diff --git a/authrecovery/recovery.go b/authrecovery/recovery.go new file mode 100644 index 0000000..91e9afc --- /dev/null +++ b/authrecovery/recovery.go @@ -0,0 +1,182 @@ +// 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/auth" +) + +const DefaultCodeCount = 10 + +var ( + ErrCodeNotFound = errors.New("authrecovery: recovery code not found") + ErrGrantNotFound = errors.New("authrecovery: recovery grant not found") +) + +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) +} + +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 +} + +type Service struct { + repository Repository + passwords PasswordVerifier + random io.Reader + now func() time.Time + count int + grantTTL time.Duration +} + +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.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute { + 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}, 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) { + if len(raw) < 32 || len(raw) > 128 { + return auth.User{}, ErrGrantNotFound + } + return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC()) +} + +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 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 +} diff --git a/authrecovery/recovery_test.go b/authrecovery/recovery_test.go new file mode 100644 index 0000000..1b8641c --- /dev/null +++ b/authrecovery/recovery_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authrecovery_test + +import ( + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "gamertan.com/web/auth" + "gamertan.com/web/authrecovery" + "gamertan.com/web/authsqlite" +) + +func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) { + now := time.Date(2026, 9, 3, 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) + } + user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.person", Email: "recover@example.test", DisplayName: "Recover Person", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + codes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID) + if err != nil || len(codes) != authrecovery.DefaultCodeCount { + t.Fatalf("codes=%d err=%v", len(codes), err) + } + session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + loaded, grant, err := recovery.Begin(t.Context(), strings.ToUpper(user.Email), "correct horse battery staple", strings.ToLower(codes[0])) + if err != nil || loaded.ID != user.ID || grant == "" { + t.Fatalf("loaded=%+v grant=%q err=%v", loaded, grant, err) + } + if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) { + t.Fatalf("session survived recovery: %v", err) + } + if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", codes[0]); !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("code replay err=%v", err) + } + loaded, err = recovery.TakeGrant(t.Context(), grant) + if err != nil || loaded.ID != user.ID { + t.Fatalf("grant user=%+v err=%v", loaded, err) + } + if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) { + t.Fatalf("grant replay err=%v", err) + } +} + +type counterReader struct{ value byte } + +func (reader *counterReader) Read(target []byte) (int, error) { + for index := range target { + reader.value++ + target[index] = reader.value + } + return len(target), nil +} diff --git a/authsqlite/account.go b/authsqlite/account.go new file mode 100644 index 0000000..32fdbc4 --- /dev/null +++ b/authsqlite/account.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/account" + "gamertan.com/web/auth" +) + +func (store *Store) CreateRegistration(ctx context.Context, registration account.Registration, passwordHash string, audit auth.AuditEvent) error { + user := registration.User + if zeroDigest(registration.Digest) || !validPendingUser(user) || !registration.CreatedAt.Equal(user.CreatedAt) || !registration.ExpiresAt.After(registration.CreatedAt) || registration.ExpiresAt.Sub(registration.CreatedAt) > time.Hour || !text(passwordHash, 1024, false) || !validAuditEvent(audit) || audit.ActorUserID != user.ID || audit.ResourceID != user.ID { + return errors.New("authsqlite: invalid account registration") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + // A bounded abandoned registration must not reserve its email or username + // forever. Deleting the pending user cascades every private draft artifact. + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_users WHERE registration_pending=1 AND id IN (SELECT user_id FROM gwf_account_registrations WHERE expires_at<=?)`, registration.CreatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,0,1,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, user.ID, passwordHash, user.CreatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_registrations(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, registration.Digest[:], user.ID, registration.CreatedAt.Unix(), registration.ExpiresAt.Unix()); err != nil { + return err + } + if err = appendAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) Registration(ctx context.Context, digest [32]byte, now time.Time) (account.Registration, error) { + if zeroDigest(digest) || now.IsZero() { + return account.Registration{}, account.ErrRegistrationNotFound + } + var registration account.Registration + var passwordChangeRequired, pending int + var created, updated, draftCreated, expires int64 + err := store.db.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,r.created_at,r.expires_at FROM gwf_account_registrations r JOIN gwf_users u ON u.id=r.user_id WHERE r.token_hash=? AND r.expires_at>? AND u.registration_pending=1`, digest[:], now.Unix()).Scan(®istration.User.ID, ®istration.User.Username, ®istration.User.Email, ®istration.User.DisplayName, ®istration.User.Status, &passwordChangeRequired, &pending, &created, &updated, &draftCreated, &expires) + if errors.Is(err, sql.ErrNoRows) { + return account.Registration{}, account.ErrRegistrationNotFound + } + if err != nil { + return account.Registration{}, err + } + registration.Digest = digest + registration.User.PasswordChangeRequired = passwordChangeRequired == 1 + registration.User.RegistrationPending = pending == 1 + registration.User.CreatedAt = time.Unix(created, 0).UTC() + registration.User.UpdatedAt = time.Unix(updated, 0).UTC() + registration.CreatedAt = time.Unix(draftCreated, 0).UTC() + registration.ExpiresAt = time.Unix(expires, 0).UTC() + return registration, nil +} + +func (store *Store) CompleteRegistration(ctx context.Context, digest [32]byte, completion account.RegistrationCompletion) error { + userID := completion.Membership.UserID + validOptionalCredential := completion.Credential == nil || validCredential(*completion.Credential, true) && completion.Credential.UserID == userID + if zeroDigest(digest) || !validOptionalCredential || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || !validOrganization(completion.Organization) || !completion.Organization.Personal || completion.Membership.OrganizationID != completion.Organization.ID || !opaqueID(userID) || completion.Membership.Status != "active" || completion.Membership.JoinedAt.IsZero() || !validOwnerBinding(completion.OwnerBinding, completion.Organization.ID, userID) || !validAuditEvent(completion.AuthAudit) || completion.AuthAudit.ActorUserID != userID || !validOrganizationAudit(completion.OrganizationAudit, completion.Organization.ID) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.OrganizationID != completion.Organization.ID || completion.CompletedAt.IsZero() { + return errors.New("authsqlite: invalid account registration completion") + } + seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests)) + for _, recoveryDigest := range completion.RecoveryDigests { + if zeroDigest(recoveryDigest) { + return errors.New("authsqlite: invalid recovery code digest") + } + if _, exists := seen[recoveryDigest]; exists { + return errors.New("authsqlite: duplicate recovery code digest") + } + seen[recoveryDigest] = struct{}{} + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var registeredUserID string + err = tx.QueryRowContext(ctx, `DELETE FROM gwf_account_registrations WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], completion.CompletedAt.Unix()).Scan(®isteredUserID) + if errors.Is(err, sql.ErrNoRows) { + return account.ErrRegistrationNotFound + } + if err != nil { + return err + } + if registeredUserID != userID { + return account.ErrRegistrationNotFound + } + var pending int + if err = tx.QueryRowContext(ctx, `SELECT registration_pending FROM gwf_users WHERE id=? AND status='active'`, userID).Scan(&pending); err != nil || pending != 1 { + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + return account.ErrRegistrationNotFound + } + if completion.Credential != nil { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, completion.Credential.ID, userID, completion.Credential.Label, []byte(completion.Credential.Data), completion.Credential.CreatedAt.Unix()); err != nil { + return err + } + } + for _, recoveryDigest := range completion.RecoveryDigests { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, recoveryDigest[:], completion.CompletedAt.Unix()); err != nil { + return err + } + } + organization := completion.Organization + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,1,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, userID, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, completion.Membership.OrganizationID, userID, completion.Membership.Status, completion.Membership.JoinedAt.Unix()); err != nil { + return err + } + binding := completion.OwnerBinding + result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, binding.ID, organization.ID, userID, binding.Role, userID, binding.GrantedAt.Unix(), binding.Role) + if err != nil { + return err + } + if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 { + if rowsErr != nil { + return rowsErr + } + return errors.New("authsqlite: account owner role has not been seeded") + } + if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET registration_pending=0,updated_at=? WHERE id=? AND registration_pending=1`, completion.CompletedAt.Unix(), userID); err != nil { + return err + } + if err = appendAudit(ctx, tx, completion.AuthAudit); err != nil { + return err + } + if err = appendOrganizationAudit(ctx, tx, completion.OrganizationAudit); err != nil { + return err + } + if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil { + return err + } + return tx.Commit() +} + +func validPendingUser(user auth.User) bool { + return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && user.RegistrationPending && !user.PasswordChangeRequired && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero() +} + +func validOwnerBinding(binding access.Binding, organizationID, userID string) bool { + return opaqueID(binding.ID) && binding.SubjectKind == access.User && binding.SubjectID == userID && safeName(binding.Role) && binding.Scope.OrganizationID == organizationID && binding.Scope.ProjectID == "" && binding.Scope.EnvironmentID == "" && binding.Scope.ServiceID == "" && binding.GrantedBy == userID && !binding.GrantedAt.IsZero() +} + +var _ account.Repository = (*Store)(nil) diff --git a/authsqlite/account_test.go b/authsqlite/account_test.go new file mode 100644 index 0000000..930f622 --- /dev/null +++ b/authsqlite/account_test.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/account" + "gamertan.com/web/auth" + "gamertan.com/web/authwebauthn" +) + +func TestAccountRegistrationCommitsEveryRequiredArtifact(t *testing.T) { + store, authService, accountService, passkeys := accountFixture(t, true) + started, err := accountService.Start(t.Context(), account.StartInput{Email: "PERSON@example.test", Username: "person.one", DisplayName: "Person One", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + if started.User.Email != "person@example.test" || !started.User.RegistrationPending { + t.Fatalf("pending user=%+v", started.User) + } + if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) { + t.Fatalf("pending password verification err=%v", err) + } + if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil { + t.Fatal(err) + } + finished, err := accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`)) + if err != nil { + t.Fatal(err) + } + if finished.User.RegistrationPending || finished.User.ID != started.User.ID || len(finished.RecoveryCodes) != 10 || finished.SessionToken == "" || !finished.Organization.Personal { + t.Fatalf("finish=%+v code-count=%d", finished, len(finished.RecoveryCodes)) + } + if passkeys.userID != started.User.ID || passkeys.binding != started.RegistrationToken { + t.Fatalf("passkey binding user=%q binding=%q", passkeys.userID, passkeys.binding) + } + assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 1) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 1) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND role_name='owner'`, started.User.ID, 1) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 0) + if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); err != nil { + t.Fatalf("completed password verification: %v", err) + } +} + +func TestPasswordAccountCanFinishWithoutPasskey(t *testing.T) { + store, authService, accountService, _ := accountFixture(t, true) + started, err := accountService.Start(t.Context(), account.StartInput{Email: "reader@example.test", Username: "reader.one", DisplayName: "Reader One", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + finished, err := accountService.FinishPassword(t.Context(), started.RegistrationToken) + if err != nil { + t.Fatal(err) + } + if finished.SessionToken == "" || len(finished.RecoveryCodes) != 10 || len(finished.PasskeyCredential.ID) != 0 { + t.Fatalf("password finish=%+v code-count=%d", finished, len(finished.RecoveryCodes)) + } + assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10) + if _, err = authService.VerifyPassword(t.Context(), "reader@example.test", "correct horse battery staple"); err != nil { + t.Fatalf("password account not active: %v", err) + } +} + +func TestAccountRegistrationRollsBackWhenOwnerPolicyIsMissing(t *testing.T) { + store, authService, accountService, _ := accountFixture(t, false) + started, err := accountService.Start(t.Context(), account.StartInput{Email: "rollback@example.test", Username: "rollback.one", DisplayName: "Rollback One", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil { + t.Fatal(err) + } + if _, err = accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`)); err == nil { + t.Fatal("completion unexpectedly succeeded without seeded owner role") + } + assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 0) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 0) + assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 1) + if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) { + t.Fatalf("rolled-back account became usable: %v", err) + } +} + +func accountFixture(t *testing.T, seedOwner bool) (*Store, *auth.Service, *account.Service, *accountPasskeys) { + t.Helper() + store, err := Open(filepath.Join(t.TempDir(), "identity.sqlite")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if seedOwner { + err = store.SeedAccessPolicy(t.Context(), access.Policy{ + Roles: map[string]string{"owner": "Personal organization owner"}, + Permissions: map[string]string{"account.view": "View the account"}, + Grants: map[string][]string{"owner": {"account.view"}}, + }) + if err != nil { + t.Fatal(err) + } + } + authService, err := auth.New(store, auth.Options{}) + if err != nil { + t.Fatal(err) + } + passkeys := &accountPasskeys{now: time.Now().UTC()} + accountService, err := account.New(store, passkeys, authService, account.Options{}) + if err != nil { + t.Fatal(err) + } + return store, authService, accountService, passkeys +} + +type accountPasskeys struct { + userID, binding string + now time.Time +} + +func (passkeys *accountPasskeys) BeginAccountRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) { + passkeys.userID = userID + passkeys.binding = string(binding) + return authwebauthn.BeginResult{CeremonyToken: "ceremony-token", PublicKey: []byte(`{}`), ExpiresAt: passkeys.now.Add(5 * time.Minute)}, nil +} + +func (passkeys *accountPasskeys) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) { + if ceremonyToken != "ceremony-token" || string(binding) != passkeys.binding { + return authwebauthn.Credential{}, authwebauthn.ErrOperationBinding + } + credential := authwebauthn.Credential{ID: []byte("fixture-credential-id"), UserID: passkeys.userID, Label: "Primary passkey", Data: []byte(`{"id":"fixture-credential-id"}`), CreatedAt: passkeys.now} + audit := auth.AuditEvent{ID: "passkey-audit-id", ActorUserID: passkeys.userID, Action: "auth.account.passkey", ResourceType: "passkey", ResourceID: "fixture-credential-id", Summary: "The initial account passkey was enrolled.", CreatedAt: passkeys.now} + if err := commit(ctx, credential, audit); err != nil { + return authwebauthn.Credential{}, err + } + return credential, nil +} + +func assertCount(t *testing.T, store *Store, query, id string, want int) { + t.Helper() + var got int + if err := store.db.QueryRow(query, id).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("count for %q = %d, want %d", query, got, want) + } +} diff --git a/authsqlite/passkey.go b/authsqlite/passkey.go index f5914c2..8e8f3f7 100644 --- a/authsqlite/passkey.go +++ b/authsqlite/passkey.go @@ -29,7 +29,7 @@ func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrol return err } defer tx.Rollback() - if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil { return err } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil { @@ -45,7 +45,7 @@ func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, err if !opaqueID(userID) { return auth.User{}, auth.ErrUserNotFound } - return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) + return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) } func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) { @@ -53,14 +53,14 @@ func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (au if !text(identifier, 320, false) { return auth.User{}, auth.ErrUserNotFound } - return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) + return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) } func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) { if !boundedCredentialID(credentialID) { return auth.User{}, authwebauthn.ErrCredentialNotFound } - user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID)) + user, err := scanPasskeyUser(store.db.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_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID)) if errors.Is(err, auth.ErrUserNotFound) { return auth.User{}, authwebauthn.ErrCredentialNotFound } @@ -291,7 +291,7 @@ func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte, if err != nil { return auth.User{}, err } - user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) + user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) if err != nil { return auth.User{}, err } @@ -310,7 +310,7 @@ func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollme return auth.User{}, err } defer tx.Rollback() - user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) + user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) if err != nil { return auth.User{}, err } @@ -342,21 +342,22 @@ type rowScanner interface{ Scan(...any) error } func scanPasskeyUser(row rowScanner) (auth.User, error) { var user auth.User - var passwordChangeRequired int + var passwordChangeRequired, registrationPending int var created, updated int64 - if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil { + if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &created, &updated); err != nil { if errors.Is(err, sql.ErrNoRows) { return auth.User{}, auth.ErrUserNotFound } return auth.User{}, err } user.PasswordChangeRequired = passwordChangeRequired == 1 + user.RegistrationPending = registrationPending == 1 user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() return user, nil } func validPasskeyUser(user auth.User) bool { - return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero() + return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.RegistrationPending && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero() } func validEnrollment(token authwebauthn.EnrollmentToken) bool { diff --git a/authsqlite/recovery.go b/authsqlite/recovery.go new file mode 100644 index 0000000..3813afe --- /dev/null +++ b/authsqlite/recovery.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "gamertan.com/web/auth" + "gamertan.com/web/authrecovery" +) + +func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, digests [][32]byte, createdAt time.Time, audit auth.AuditEvent) error { + if !opaqueID(userID) || len(digests) < 5 || len(digests) > 20 || createdAt.IsZero() || !validAuditEvent(audit) || audit.ResourceID != userID { + return errors.New("authsqlite: invalid recovery-code set") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil { + return err + } + for _, digest := range digests { + if zeroDigest(digest) { + return errors.New("authsqlite: invalid recovery-code digest") + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES(?,?,?)`, userID, digest[:], createdAt.Unix()); err != nil { + return err + } + } + if err = appendAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error { + if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID { + return errors.New("authsqlite: invalid recovery attempt") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE gwf_recovery_codes SET used_at=? WHERE user_id=? AND code_hash=? AND used_at IS NULL`, grant.CreatedAt.Unix(), userID, codeDigest[:]) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil || changed != 1 { + return authrecovery.ErrCodeNotFound + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE user_id=?`, userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, grant.Digest[:], userID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil { + return err + } + if err = appendAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) TakeRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) { + if zeroDigest(digest) || now.IsZero() { + return auth.User{}, authrecovery.ErrGrantNotFound + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return auth.User{}, err + } + defer tx.Rollback() + var userID string + if err = tx.QueryRowContext(ctx, `SELECT user_id FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>?`, digest[:], now.Unix()).Scan(&userID); errors.Is(err, sql.ErrNoRows) { + return auth.User{}, authrecovery.ErrGrantNotFound + } else if err != nil { + return auth.User{}, err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=?`, digest[:]); err != nil { + return auth.User{}, err + } + user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) + if err != nil { + return auth.User{}, err + } + if err = tx.Commit(); err != nil { + return auth.User{}, err + } + return user, nil +} diff --git a/authsqlite/store.go b/authsqlite/store.go index 9c03336..606341f 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -24,6 +24,17 @@ import ( type Store struct{ db *sql.DB } func Open(path string) (*Store, error) { + return OpenWithOptions(path, OpenOptions{Migrate: true}) +} + +type OpenOptions struct { + // Migrate preserves the historical Open behavior when true. Applications + // with operator-controlled releases set it false and call Migrate only from + // their explicit migration command. + Migrate bool +} + +func OpenWithOptions(path string, options OpenOptions) (*Store, error) { absolute, err := filepath.Abs(path) if err != nil { return nil, err @@ -56,7 +67,7 @@ func Open(path string) (*Store, error) { store := &Store{db: db} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - if err = db.PingContext(ctx); err == nil { + if err = db.PingContext(ctx); err == nil && options.Migrate { err = store.Migrate(ctx) } if err != nil { @@ -66,6 +77,34 @@ func Open(path string) (*Store, error) { return store, nil } +const SchemaVersion = 8 + +func (store *Store) CurrentSchema(ctx context.Context) (int, error) { + var exists int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='gamertan_web_migrations'`).Scan(&exists); err != nil || exists == 0 { + return 0, err + } + var version sql.NullInt64 + if err := store.db.QueryRowContext(ctx, `SELECT MAX(version) FROM gamertan_web_migrations`).Scan(&version); err != nil { + return 0, err + } + if !version.Valid { + return 0, nil + } + return int(version.Int64), nil +} + +func (store *Store) RequireCurrentSchema(ctx context.Context) error { + version, err := store.CurrentSchema(ctx) + if err != nil { + return err + } + if version != SchemaVersion { + return fmt.Errorf("authsqlite: schema version %d; run migration for version %d", version, SchemaVersion) + } + return nil +} + func (store *Store) Close() error { return store.db.Close() } func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) } @@ -77,7 +116,7 @@ func (store *Store) Migrate(ctx context.Context) error { defer tx.Rollback() statements := []string{ `CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`, - `CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`, + `CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), registration_pending INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`, `CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, @@ -94,6 +133,11 @@ func (store *Store) Migrate(ctx context.Context) error { `CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`, `CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`, `CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`, + `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_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)`, `CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`, `CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`, @@ -132,6 +176,7 @@ func (store *Store) Migrate(ctx context.Context) error { for _, migration := range []struct { table, column, definition string }{ + {"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`}, {"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`}, {"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`}, {"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`}, @@ -180,6 +225,15 @@ func (store *Store) Migrate(ctx context.Context) error { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(5,?)`, time.Now().UTC().Unix()); err != nil { return err } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(6,?)`, time.Now().UTC().Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(7,?)`, time.Now().UTC().Unix()); err != nil { + return err + } + 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 + } return tx.Commit() } @@ -212,7 +266,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash return err } defer tx.Rollback() - _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.CreatedAt.Unix(), user.UpdatedAt.Unix()) + _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix()) if err != nil { return err } @@ -228,9 +282,9 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin } var user auth.User var created, updated int64 - var passwordChangeRequired int + var passwordChangeRequired, registrationPending int var hash string - err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash) + err := store.db.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,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &created, &updated, &hash) if errors.Is(err, sql.ErrNoRows) { return auth.User{}, "", auth.ErrUserNotFound } @@ -238,6 +292,7 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin return auth.User{}, "", err } user.PasswordChangeRequired = passwordChangeRequired == 1 + user.RegistrationPending = registrationPending == 1 user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() return user, hash, nil } @@ -248,9 +303,9 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth } var user auth.User var created, updated int64 - var passwordChangeRequired int + var passwordChangeRequired, registrationPending int var hash string - err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash) + err := store.db.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,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &created, &updated, &hash) if errors.Is(err, sql.ErrNoRows) { return auth.User{}, "", auth.ErrUserNotFound } @@ -258,6 +313,7 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth return auth.User{}, "", err } user.PasswordChangeRequired = passwordChangeRequired == 1 + user.RegistrationPending = registrationPending == 1 user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() return user, hash, nil } @@ -346,12 +402,13 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now var principal auth.Principal var session auth.Session var created, updated, sessionCreated, expires, lastSeen int64 - var passwordChangeRequired int - err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen) + var passwordChangeRequired, registrationPending int + err := store.db.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,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, ®istrationPending, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen) if errors.Is(err, sql.ErrNoRows) { return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound } principal.User.PasswordChangeRequired = passwordChangeRequired == 1 + principal.User.RegistrationPending = registrationPending == 1 if err != nil { return auth.Principal{}, auth.Session{}, err } diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index e9c8e14..bfe8e45 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -17,6 +17,24 @@ import ( "gamertan.com/web/organizations" ) +func TestOpenCanRequireExplicitMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "explicit.db") + store, err := OpenWithOptions(path, OpenOptions{Migrate: false}) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err = store.RequireCurrentSchema(t.Context()); err == nil { + t.Fatal("unmigrated database reported current") + } + if err = store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + if err = store.RequireCurrentSchema(t.Context()); err != nil { + t.Fatal(err) + } +} + func TestServiceRoundTripWithApplicationPolicy(t *testing.T) { store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) if err != nil { diff --git a/authwebauthn/service.go b/authwebauthn/service.go index f76ac12..a0b5f46 100644 --- a/authwebauthn/service.go +++ b/authwebauthn/service.go @@ -190,7 +190,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la if err != nil { return BeginResult{}, err } - return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}) + return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false) } func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) { @@ -198,7 +198,24 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str if err != nil { return BeginResult{}, err } - return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}) + return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false) +} + +// BeginAccountRegistration starts the initial passkey ceremony for a pending +// account. Binding must identify the surrounding single-use registration +// draft; only its digest is retained in ceremony state. +func (service *Service) BeginAccountRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) { + if len(binding) < 16 || len(binding) > 4096 { + return BeginResult{}, ErrOperationBinding + } + user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID)) + if err != nil { + return BeginResult{}, err + } + if !user.RegistrationPending || user.Status != "active" { + return BeginResult{}, auth.ErrInactiveUser + } + return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true) } // BeginPasswordMigration starts registration for an already authenticated @@ -216,15 +233,15 @@ func (service *Service) BeginPasswordMigration(ctx context.Context, userID, labe if !exists { return BeginResult{}, ErrPasswordNotAvailable } - return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID)) + return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID), false) } -func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte) (BeginResult, error) { +func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte, allowPending bool) (BeginResult, error) { label, err := credentialLabel(label) if err != nil { return BeginResult{}, err } - adapter, err := service.user(ctx, user) + adapter, err := service.user(ctx, user, allowPending) if err != nil { return BeginResult{}, err } @@ -245,7 +262,19 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l } func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) { - return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false) + return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false, false, nil) +} + +// FinishAccountRegistration verifies an initial credential and delegates its +// persistence to commit so user activation, personal organization creation, +// owner binding, recovery-code storage, and the passkey can share one +// transaction. A failed commit consumes the WebAuthn ceremony and leaves the +// bounded account draft eligible for a fresh ceremony. +func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) { + if len(binding) < 16 || len(binding) > 4096 || commit == nil { + return Credential{}, ErrOperationBinding + } + return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, BindingDigest(binding), response, false, true, commit) } // FinishPasswordMigration verifies the new passkey and persists it together @@ -255,18 +284,18 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok if err != nil { return Credential{}, err } - return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true) + return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil) } -func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) { +func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) { ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind) if err != nil { return Credential{}, err } - return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword) + return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit) } -func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) { +func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) { if ceremony.BindingDigest != expectedBinding { return Credential{}, ErrOperationBinding } @@ -277,7 +306,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony if err != nil { return Credential{}, err } - adapter, err := service.user(ctx, user) + adapter, err := service.user(ctx, user, allowPending) if err != nil { return Credential{}, err } @@ -312,6 +341,10 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony } if retirePassword { err = service.repository.SaveCredentialAndRetirePassword(ctx, record, audit) + } else if commit != nil { + audit.Action = "auth.account.passkey" + audit.Summary = "The initial account passkey was enrolled." + err = commit(ctx, record, audit) } else { err = service.repository.SaveCredential(ctx, record, audit) } @@ -358,7 +391,7 @@ func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, r if lookupErr != nil || account.ID != string(userHandle) { return nil, ErrCredentialNotFound } - loaded, lookupErr = service.user(ctx, account) + loaded, lookupErr = service.user(ctx, account, false) return loaded, lookupErr }, session, parsed) if err != nil || loaded == nil || user == nil { @@ -385,7 +418,7 @@ func (service *Service) BeginApproval(ctx context.Context, userID string, bindin if err != nil { return BeginResult{}, err } - adapter, err := service.user(ctx, account) + adapter, err := service.user(ctx, account, false) if err != nil { return BeginResult{}, err } @@ -415,7 +448,7 @@ func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string if err != nil { return Approval{}, err } - adapter, err := service.user(ctx, account) + adapter, err := service.user(ctx, account, false) if err != nil { return Approval{}, err } @@ -552,8 +585,8 @@ func (service *Service) takeCeremony(ctx context.Context, token, kind string) (C return ceremony, nil } -func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) { - if account.Status != "active" { +func (service *Service) user(ctx context.Context, account auth.User, allowPending bool) (*passkeyUser, error) { + if account.Status != "active" || account.RegistrationPending && !allowPending { return nil, auth.ErrInactiveUser } records, err := service.repository.CredentialsByUserID(ctx, account.ID) diff --git a/authwebauthn/types.go b/authwebauthn/types.go index fd1800f..bf7bfeb 100644 --- a/authwebauthn/types.go +++ b/authwebauthn/types.go @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MPL-2.0 -// Package authwebauthn provides storage-neutral, passkey-only WebAuthn -// ceremonies. It owns relying-party policy, bounded single-use ceremony state, -// credential lifecycle, and recovery tokens while delegating protocol parsing -// and signature verification to a pinned WebAuthn implementation. +// Package authwebauthn provides storage-neutral WebAuthn ceremonies for +// passkey login, enrollment, and operation-bound step-up. It owns relying-party +// policy, bounded single-use ceremony state, credential lifecycle, and recovery +// tokens while delegating protocol parsing and signature verification to a +// pinned WebAuthn implementation. package authwebauthn import ( @@ -92,6 +93,12 @@ type Approval struct { ApprovedAt time.Time } +// RegistrationCommit lets a higher-level account workflow commit a verified +// initial credential together with the rest of the account state. The +// callback receives only public-key credential material and a secret-free +// audit event. +type RegistrationCommit func(context.Context, Credential, auth.AuditEvent) error + // Repository persists passkey-specific state. Implementations must consume // enrollment tokens and ceremonies atomically and must perform recovery and // credential removal invariants in transactions. diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md new file mode 100644 index 0000000..6616d93 --- /dev/null +++ b/docs/DOGFOOD.md @@ -0,0 +1,36 @@ + + +# Web Foundations dogfood notes + +This living note records concrete pressure discovered while Gamertan services +adopt Web Foundations. It is implementation evidence, not a promise that every +application concern belongs in the shared module. + +## Gamertan accounts and commerce + +- The account email remains required and unique. Gamertan uses normalized + email as the canonical login identifier and keeps username as a stable public + identity. Until a mail package exists, the application must not describe an + address as verified merely because it was entered during registration. +- Password authentication is sufficient for an ordinary customer base + session. Privileged application actions use an exact operation binding with + `authwebauthn.BeginApproval` and `FinishApproval`; that is safer than a broad + long-lived "elevated" session. A user without a passkey can use ordinary + features but must enroll one before performing protected work. +- `auth.Service.VerifyPassword` remains available for flows that truly require + password plus passkey before session issuance. +- Public registration exposed a cross-package transaction boundary. The + `account` package now keeps an unusable bounded registration draft and makes + recovery-code digests, personal organization, membership, owner binding, + activation, audits, and an optional initial passkey one repository commit. + A failed WebAuthn ceremony can be restarted, or an ordinary password account + can finish without it, without persisting a partly privileged account. +- Media belongs behind a storage-neutral interface with a hardened local + adapter. Content workflow, references, and authorization remain application + policy. +- Historical `authsqlite.Open` still migrates for compatibility. Applications + with reviewed deployment gates use `OpenWithOptions` with migration disabled, + require the current schema at startup, and invoke `Migrate` only from an + explicit operator command. +- Commerce remains a separately versioned nested module so payment-provider + policy and catalog evolution do not enlarge the authentication core. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b375399..564a207 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.9 +go get gamertan.com/web/requestmeta@v0.1.0-preview.10 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index b98022f..4f0b0e8 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.9 +go get gamertan.com/web/requestmeta@v0.1.0-preview.10 ``` Only imported packages are compiled and linked. The packages nevertheless @@ -52,6 +52,12 @@ Do not split merely to make an architecture diagram look modular. Package interfaces provide source-level modularity today; modules are introduced only for an independent dependency and release lifecycle. +The `media` package and `medialocal` adapter deliberately remain in the root +module: they use only the standard library, and applications can adopt the core +interface without importing the local adapter. Commerce is different. Its +provider SDK and independently evolving catalog/payment contract justify a +future nested `gamertan.com/web/commerce` module after application dogfood. + ## Session boundaries Authenticated sessions currently belong to three deliberate packages: diff --git a/media/media.go b/media/media.go new file mode 100644 index 0000000..97ddfad --- /dev/null +++ b/media/media.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package media defines bounded media preparation and storage-neutral blob +// interfaces. Applications retain authorization, references, lifecycle, and +// presentation policy. +package media + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "image" + _ "image/gif" + "image/jpeg" + "image/png" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + "time" + "unicode/utf8" +) + +const ( + KindImage = "image" + KindAttachment = "attachment" +) + +var ( + ErrInvalidMedia = errors.New("media: invalid media") + ErrTooLarge = errors.New("media: upload exceeds its size limit") + ErrNotFound = errors.New("media: object not found") +) + +type Limits struct { + MaxBytes int64 + MaxWidth int + MaxHeight int + MaxPixels int64 +} + +func (limits Limits) withDefaults() Limits { + if limits.MaxBytes == 0 { + limits.MaxBytes = 10 << 20 + } + if limits.MaxWidth == 0 { + limits.MaxWidth = 8192 + } + if limits.MaxHeight == 0 { + limits.MaxHeight = 8192 + } + if limits.MaxPixels == 0 { + limits.MaxPixels = 40_000_000 + } + return limits +} + +func (limits Limits) validate() error { + if limits.MaxBytes < 1024 || limits.MaxBytes > 100<<20 || limits.MaxWidth < 1 || limits.MaxWidth > 32768 || limits.MaxHeight < 1 || limits.MaxHeight > 32768 || limits.MaxPixels < 1 || limits.MaxPixels > 250_000_000 { + return errors.New("media: invalid limits") + } + return nil +} + +// Prepared is a sanitized, bounded object ready for durable storage. Raster +// images are decoded and re-encoded so source metadata and unparsed trailing +// bytes are not retained. PDFs are attachments and are never inline media. +type Prepared struct { + Digest [32]byte + Data []byte + MediaType string + Kind string + OriginalName string + Width int + Height int +} + +func (prepared Prepared) Key() string { return hex.EncodeToString(prepared.Digest[:]) } + +type Object struct { + Key string + Size int64 + MediaType string + CreatedAt time.Time +} + +type Store interface { + Put(context.Context, Prepared) (Object, error) + Open(context.Context, string) (io.ReadCloser, Object, error) + Delete(context.Context, string) error +} + +// Prepare reads at most the configured bound and accepts JPEG, PNG, GIF, or a +// PDF attachment. Animated images are deliberately flattened to the decoded +// first frame. The returned byte slice is owned by the caller. +func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, error) { + if reader == nil { + return Prepared{}, ErrInvalidMedia + } + limits = limits.withDefaults() + if err := limits.validate(); err != nil { + return Prepared{}, err + } + name, err := boundedName(originalName) + if err != nil { + return Prepared{}, err + } + data, err := io.ReadAll(io.LimitReader(reader, limits.MaxBytes+1)) + if err != nil { + return Prepared{}, fmt.Errorf("media: read upload: %w", err) + } + if int64(len(data)) > limits.MaxBytes { + return Prepared{}, ErrTooLarge + } + if len(data) == 0 { + return Prepared{}, ErrInvalidMedia + } + + detected := http.DetectContentType(data) + if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) { + result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name} + result.Digest = sha256.Sum256(result.Data) + return result, nil + } + + imageValue, format, err := image.Decode(bytes.NewReader(data)) + if err != nil || format != "jpeg" && format != "png" && format != "gif" { + return Prepared{}, ErrInvalidMedia + } + bounds := imageValue.Bounds() + width, height := bounds.Dx(), bounds.Dy() + if width < 1 || height < 1 || width > limits.MaxWidth || height > limits.MaxHeight || int64(width) > limits.MaxPixels/int64(height) { + return Prepared{}, ErrTooLarge + } + + var output bytes.Buffer + mediaType := "image/png" + if format == "jpeg" { + mediaType = "image/jpeg" + err = jpeg.Encode(&output, imageValue, &jpeg.Options{Quality: 90}) + } else { + err = png.Encode(&output, imageValue) + } + if err != nil { + return Prepared{}, fmt.Errorf("media: sanitize image: %w", err) + } + if int64(output.Len()) > limits.MaxBytes { + return Prepared{}, ErrTooLarge + } + result := Prepared{Data: output.Bytes(), MediaType: mediaType, Kind: KindImage, OriginalName: name, Width: width, Height: height} + result.Digest = sha256.Sum256(result.Data) + return result, nil +} + +func Extension(mediaType string) string { + switch mediaType { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "application/pdf": + return ".pdf" + default: + values, _ := mime.ExtensionsByType(mediaType) + if len(values) > 0 { + return values[0] + } + return "" + } +} + +func ValidKey(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value) +} + +func boundedName(value string) (string, error) { + value = strings.TrimSpace(filepath.Base(value)) + if value == "." || value == "" || !utf8.ValidString(value) || len(value) > 240 || strings.ContainsAny(value, "\x00\r\n") { + return "", ErrInvalidMedia + } + return value, nil +} diff --git a/media/media_test.go b/media/media_test.go new file mode 100644 index 0000000..e8dd9b5 --- /dev/null +++ b/media/media_test.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MPL-2.0 + +package media + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/jpeg" + "strings" + "testing" +) + +func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) { + var source bytes.Buffer + value := image.NewRGBA(image.Rect(0, 0, 3, 2)) + value.Set(1, 1, color.RGBA{R: 220, G: 20, B: 50, A: 255}) + if err := jpeg.Encode(&source, value, &jpeg.Options{Quality: 95}); err != nil { + t.Fatal(err) + } + source.WriteString("secret trailing metadata") + prepared, err := Prepare(bytes.NewReader(source.Bytes()), " portrait.jpg ", Limits{}) + if err != nil { + t.Fatal(err) + } + if prepared.Kind != KindImage || prepared.MediaType != "image/jpeg" || prepared.Width != 3 || prepared.Height != 2 || prepared.OriginalName != "portrait.jpg" { + t.Fatalf("prepared=%+v", prepared) + } + if bytes.Contains(prepared.Data, []byte("secret trailing metadata")) || prepared.Key() == strings.Repeat("0", 64) { + t.Fatal("image source data was not sanitized") + } +} + +func TestPreparePDFIsAttachment(t *testing.T) { + prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{}) + if err != nil { + t.Fatal(err) + } + if prepared.Kind != KindAttachment || prepared.MediaType != "application/pdf" { + t.Fatalf("prepared=%+v", prepared) + } +} + +func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) { + if _, err := Prepare(strings.NewReader(""), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) { + t.Fatalf("svg err=%v", err) + } + if _, err := Prepare(strings.NewReader(strings.Repeat("x", 1025)), "large.png", Limits{MaxBytes: 1024, MaxWidth: 10, MaxHeight: 10, MaxPixels: 100}); !errors.Is(err, ErrTooLarge) { + t.Fatalf("large err=%v", err) + } +} diff --git a/medialocal/store.go b/medialocal/store.go new file mode 100644 index 0000000..02bea32 --- /dev/null +++ b/medialocal/store.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package medialocal stores prepared media in a private content-addressed +// filesystem tree. +package medialocal + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "gamertan.com/web/media" +) + +type Store struct { + root string + now func() time.Time +} + +type Options struct { + Now func() time.Time +} + +func Open(root string, options Options) (*Store, error) { + absolute, err := filepath.Abs(root) + if err != nil || filepath.Clean(absolute) != absolute { + return nil, errors.New("medialocal: root must be a clean absolute path") + } + if err = secureDirectory(absolute, true); err != nil { + return nil, err + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return nil, fmt.Errorf("medialocal: resolve root: %w", err) + } + if options.Now == nil { + options.Now = time.Now + } + return &Store{root: resolved, now: options.Now}, nil +} + +func (store *Store) Put(ctx context.Context, prepared media.Prepared) (media.Object, error) { + if err := ctx.Err(); err != nil { + return media.Object{}, err + } + if len(prepared.Data) == 0 || !media.ValidKey(prepared.Key()) || sha256.Sum256(prepared.Data) != prepared.Digest { + return media.Object{}, media.ErrInvalidMedia + } + shard, target := store.objectPath(prepared.Key()) + if err := secureDirectory(shard, true); err != nil { + return media.Object{}, err + } + if object, ok, err := inspect(target, prepared.MediaType); err != nil { + return media.Object{}, err + } else if ok { + if object.Size != int64(len(prepared.Data)) { + return media.Object{}, errors.New("medialocal: existing digest has an unexpected size") + } + return object, nil + } + temporary, err := os.CreateTemp(shard, ".upload-*") + if err != nil { + return media.Object{}, fmt.Errorf("medialocal: create temporary object: %w", err) + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err = temporary.Chmod(0o640); err == nil { + _, err = temporary.Write(prepared.Data) + } + if err == nil { + err = temporary.Sync() + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return media.Object{}, fmt.Errorf("medialocal: write object: %w", err) + } + if err = os.Link(temporaryName, target); err != nil { + if errors.Is(err, os.ErrExist) { + object, ok, inspectErr := inspect(target, prepared.MediaType) + if inspectErr != nil { + return media.Object{}, inspectErr + } + if ok && object.Size == int64(len(prepared.Data)) { + return object, nil + } + } + return media.Object{}, fmt.Errorf("medialocal: commit object: %w", err) + } + return media.Object{Key: prepared.Key(), Size: int64(len(prepared.Data)), MediaType: prepared.MediaType, CreatedAt: store.now().UTC()}, nil +} + +func (store *Store) Open(ctx context.Context, key string) (io.ReadCloser, media.Object, error) { + if err := ctx.Err(); err != nil { + return nil, media.Object{}, err + } + if !media.ValidKey(key) { + return nil, media.Object{}, media.ErrNotFound + } + shard, target := store.objectPath(key) + if err := secureDirectory(shard, false); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, media.Object{}, media.ErrNotFound + } + return nil, media.Object{}, err + } + before, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + return nil, media.Object{}, media.ErrNotFound + } + if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 { + return nil, media.Object{}, errors.New("medialocal: object is not a regular file") + } + file, err := os.Open(target) + if err != nil { + return nil, media.Object{}, err + } + after, err := file.Stat() + if err != nil || !os.SameFile(before, after) { + file.Close() + return nil, media.Object{}, errors.New("medialocal: object changed while opening") + } + return file, media.Object{Key: key, Size: after.Size(), CreatedAt: after.ModTime().UTC()}, nil +} + +func (store *Store) Delete(ctx context.Context, key string) error { + if err := ctx.Err(); err != nil { + return err + } + if !media.ValidKey(key) { + return media.ErrNotFound + } + _, target := store.objectPath(key) + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + return media.ErrNotFound + } + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("medialocal: refusing to delete a non-regular object") + } + if err = os.Remove(target); errors.Is(err, os.ErrNotExist) { + return media.ErrNotFound + } + return err +} + +func (store *Store) objectPath(key string) (string, string) { + shard := filepath.Join(store.root, key[:2]) + return shard, filepath.Join(shard, key) +} + +func secureDirectory(path string, create bool) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) && create { + if err = os.MkdirAll(path, 0o750); err != nil { + return fmt.Errorf("medialocal: create directory: %w", err) + } + info, err = os.Lstat(path) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("medialocal: storage directory must not be a symlink") + } + return nil +} + +func inspect(path, mediaType string) (media.Object, bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return media.Object{}, false, nil + } + if err != nil { + return media.Object{}, false, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return media.Object{}, false, errors.New("medialocal: existing object is not a regular file") + } + return media.Object{Key: filepath.Base(path), Size: info.Size(), MediaType: mediaType, CreatedAt: info.ModTime().UTC()}, true, nil +} diff --git a/medialocal/store_test.go b/medialocal/store_test.go new file mode 100644 index 0000000..5866333 --- /dev/null +++ b/medialocal/store_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 + +package medialocal + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "gamertan.com/web/media" +) + +func TestStoreRoundTripAndIdempotentPut(t *testing.T) { + now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC) + store, err := Open(filepath.Join(t.TempDir(), "media"), Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{}) + if err != nil { + t.Fatal(err) + } + first, err := store.Put(t.Context(), prepared) + if err != nil { + t.Fatal(err) + } + second, err := store.Put(t.Context(), prepared) + if err != nil || second.Key != first.Key || second.Size != first.Size { + t.Fatalf("second=%+v err=%v", second, err) + } + reader, object, err := store.Open(t.Context(), first.Key) + if err != nil { + t.Fatal(err) + } + data, readErr := io.ReadAll(reader) + closeErr := reader.Close() + if readErr != nil || closeErr != nil || !bytes.Equal(data, prepared.Data) || object.Size != int64(len(data)) { + t.Fatalf("round trip object=%+v read=%v close=%v", object, readErr, closeErr) + } + if err = store.Delete(t.Context(), first.Key); err != nil { + t.Fatal(err) + } + if _, _, err = store.Open(t.Context(), first.Key); !errors.Is(err, media.ErrNotFound) { + t.Fatalf("missing err=%v", err) + } +} + +func TestOpenRejectsSymlinkRoot(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "target") + if err := os.Mkdir(target, 0o750); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := Open(link, Options{}); err == nil { + t.Fatal("symlink root accepted") + } +} diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index e473d29..e3a898f 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -17,6 +17,7 @@ SECURITY.md THIRD_PARTY_NOTICES.md abuse/abuse.go abuse/abuse_test.go +account/account.go access/access.go access/access_test.go analytics/analytics.go @@ -27,6 +28,8 @@ auth/auth.go auth/context.go auth/password.go auth/password_test.go +authrecovery/recovery.go +authrecovery/recovery_test.go auth/service_test.go authhttp/authhttp.go authhttp/authhttp_test.go @@ -34,18 +37,26 @@ authhttp/passkey.go authhttp/passkey_test.go authsqlite/store.go authsqlite/store_test.go +authsqlite/account.go +authsqlite/account_test.go authsqlite/access.go authsqlite/organizations.go authsqlite/passkey.go authsqlite/passkey_test.go +authsqlite/recovery.go authwebauthn/fuzz_test.go authwebauthn/service.go authwebauthn/service_test.go authwebauthn/types.go +media/media.go +media/media_test.go +medialocal/store.go +medialocal/store_test.go internal/webauthnvendored/ docs/ADOPTION.md docs/ARCHITECTURE.md docs/DEPENDENCIES.md +docs/DOGFOOD.md docs/GETTING_STARTED.md docs/MODULES.md docs/ORGANIZATIONS.md