Compare commits

...
Author SHA1 Message Date
gamertan b1710e08b8 Verify passkey algorithms from COSE keys
verify / verify (push) Successful in 3m38s
2026-09-04 12:19:15 -04:00
gamertan 3fe1547a5b Protect owner invitation authority
verify / verify (push) Successful in 3m40s
2026-09-04 00:30:29 -04:00
gamertan d54d6a4ad1 Protect owner administration authority
verify / verify (push) Successful in 3m42s
2026-09-04 00:20:28 -04:00
gamertan 6f0b597943 Add owner-assisted account recovery
verify / verify (push) Successful in 3m39s
2026-09-03 23:56:42 -04:00
20 changed files with 952 additions and 34 deletions
+51
View File
@@ -2,6 +2,57 @@
# Changelog
## v0.1.0-preview.21 — 2026-09-04
- Derive the registered credential algorithm from the verified COSE public key
embedded in authenticator data instead of the optional browser
`publicKeyAlgorithm` convenience member.
- Preserve the ES256-only policy while accepting standards-compliant response
serializers that omit redundant response conveniences, including the
Bitwarden/Vaultwarden passkey flow exercised through Gamertan.
- Add regression coverage for an ES256 credential whose convenience algorithm
is absent, plus malformed and non-ES256 credential rejection.
## v0.1.0-preview.20 — 2026-09-04
- Extend the direct-owner transaction boundary to invitations. Creating or
revoking an invitation that grants the configured owner role now requires
the actor to remain an active direct owner after the SQLite write lock is
acquired.
- Preserve application-owned permission policy for ordinary invitations while
preventing a broad access-management role, stale ceremony, or alternate
repository call from creating or cancelling owner access.
- Pass the configured owner role explicitly through invitation repository
mutations so non-SQLite adapters cannot silently omit the invariant.
## v0.1.0-preview.19 — 2026-09-04
- Require a current active direct owner for every direct-role transition to or
from the configured owner role. The SQLite adapter rechecks that authority
after acquiring its write lock, preventing a role manager from promoting
itself or changing an owner through a stale application authorization.
- Apply the same transactional owner-authority boundary to membership
suspension, reactivation, and removal, including the legacy lifecycle
methods. Non-owner administrators may still manage non-owner members while
last-owner protection remains a separate invariant.
- Expose stable owner-authority errors so applications can distinguish an
authorization drift conflict from malformed input or storage failure.
## v0.1.0-preview.18 — 2026-09-04
- Add owner-assisted account recovery for a documented human-review path when
normal password, passkey, and recovery-code authentication is unavailable.
Issuance requires an active direct organization owner and returns a bounded,
single-use, 15-minute secret while persisting and auditing only its digest.
- Invalidate the recovered member's existing password, passkeys, recovery
codes, sessions, ceremonies, and older recovery grants when the reviewed
enrollment is issued. Completion atomically installs one replacement
password, passkey, and recovery-code set without issuing a normal session.
- Keep identity and organization-visible recovery audits in the same SQLite
transactions as their credential changes, and document the application
boundary for fresh passkey authorization, secret-fragment delivery, and
human evidence review.
## v0.1.0-preview.17 — 2026-09-04
- Add optimistic organization-membership suspension, reactivation, and
+7 -4
View File
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import.
> **Public preview:** `v0.1.0-preview.17`. APIs may change before a stable
> **Public preview:** `v0.1.0-preview.21`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
@@ -41,7 +41,7 @@ packages you import.
| Atomic password-plus-passkey registration | [`account`](account) |
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
| Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Organizations, teams, and invitations | [`organizations`](organizations) |
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
Pin the preview in an application module:
```bash
go get gamertan.com/web@v0.1.0-preview.17
go get gamertan.com/web@v0.1.0-preview.21
go mod verify
```
An application may name the first package it intends to adopt:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.17
go get gamertan.com/web/requestmeta@v0.1.0-preview.21
```
The version belongs to the `gamertan.com/web` module. See the
@@ -102,6 +102,9 @@ JSONL logging.
request context without owning login routes or pages.
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
enrollment, operation-bound fresh approval, and bounded recovery.
- [`authrecovery`](authrecovery) supports printable self-service recovery and
a separate owner-assisted flow that atomically replaces compromised account
credentials while writing both identity and organization-visible audits.
- [`organizations`](organizations) and [`access`](access) keep platform
operation separate from organization-data authority while supporting teams,
invitations, scoped roles, and audited temporary access.
+4 -2
View File
@@ -19,6 +19,7 @@ import (
var (
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
ErrOwnerAuthority = errors.New("access: a current direct owner must approve owner role changes")
ErrRoleChangeConflict = errors.New("access: role binding changed")
ErrRoleUnchanged = errors.New("access: role is unchanged")
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
@@ -218,8 +219,9 @@ type OrganizationUserRoleChange struct {
// ReplaceOrganizationUserRole atomically replaces every current direct,
// organization-wide role for one active member with exactly one role. The
// expected binding IDs make concurrent administration fail closed. When an
// owner role is configured, the repository also protects the final active
// direct owner in the same transaction.
// owner role is configured, the repository also requires a current active
// direct owner for any change to or from that role and protects the final
// active direct owner in the same transaction.
func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
if service.ownerRole == "" {
return Binding{}, errors.New("access: owner role is required for role replacement")
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MPL-2.0
package authrecovery_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/organizations"
)
func TestOwnerAssistedRecoveryInvalidatesAndAtomicallyReplacesAccountCredentials(t *testing.T) {
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
random := &counterReader{}
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "home.owner", Email: "owner@example.test", DisplayName: "Home Owner", Password: "owner password for assisted recovery"})
if err != nil {
t.Fatal(err)
}
target, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.member", Email: "member@example.test", DisplayName: "Recover Member", Password: "old member password before recovery"})
if err != nil {
t.Fatal(err)
}
organizationsService, err := organizations.New(store, organizations.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
home, err := organizationsService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "assisted-home", Name: "Assisted Home", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
invitation, _, err := organizationsService.Invite(t.Context(), home.ID, target.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationsService.AcceptInvitation(t.Context(), invitation, target.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Organization owner", "viewer": "Organization viewer"},
Permissions: map[string]string{"account.recover": "Recover an organization member"},
Grants: map[string][]string{"owner": {"account.recover"}, "viewer": {}},
}
accessService, err := access.New(store, policy, access.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: target.ID, Role: "viewer", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
existingID := bytes.Repeat([]byte{7}, 32)
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
if err != nil {
t.Fatal(err)
}
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: target.ID, Label: "Old passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "old-passkey-audit-id", ActorUserID: target.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Old passkey fixture", CreatedAt: now}); err != nil {
t.Fatal(err)
}
passkeys := &passkeyRecoveryStub{now: now, credentialID: bytes.Repeat([]byte{8}, 32)}
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
oldCodes, err := recovery.ReplaceCodes(t.Context(), target.ID, target.ID)
if err != nil {
t.Fatal(err)
}
oldSession, _, err := authService.IssueSession(t.Context(), target.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if _, _, err = recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: target.ID, TargetUserID: owner.ID, RequestID: "request-denied-123", Reason: "Target asked for recovery after identity review"}); !errors.Is(err, authrecovery.ErrAssistedDenied) {
t.Fatalf("non-owner assisted recovery err=%v", err)
}
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); err != nil {
t.Fatalf("denied recovery changed password: %v", err)
}
loaded, grant, err := recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: owner.ID, TargetUserID: target.ID, RequestID: "request-assisted-123", Reason: "Member verified ownership through the documented support review"})
if err != nil || loaded.ID != target.ID || grant == "" {
t.Fatalf("loaded=%+v grant_present=%v err=%v", loaded, grant != "", err)
}
if _, err = authService.Session(t.Context(), oldSession); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("old session survived assisted recovery issue: %v", err)
}
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old password survived assisted recovery issue: %v", err)
}
credentials, err := store.CredentialsByUserID(t.Context(), target.ID)
if err != nil || len(credentials) != 0 {
t.Fatalf("old passkeys survived issue: credentials=%+v err=%v", credentials, err)
}
if _, _, err = recovery.Begin(t.Context(), target.Email, "old member password before recovery", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old recovery path survived issue: %v", err)
}
begin, err := recovery.BeginAssistedPasskey(t.Context(), grant, "Recovered passkey")
if err != nil || begin.CeremonyToken == "" || passkeys.userID != target.ID || passkeys.beginBinding != grant {
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
}
result, err := recovery.FinishAssistedRecovery(t.Context(), grant, begin.CeremonyToken, "new member password after recovery", []byte(`{"fixture":true}`))
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount {
t.Fatalf("result=%+v err=%v", result, err)
}
if _, err = authService.VerifyPassword(t.Context(), target.Email, "new member password after recovery"); err != nil {
t.Fatalf("replacement password unavailable: %v", err)
}
credentials, err = store.CredentialsByUserID(t.Context(), target.ID)
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, passkeys.credentialID) {
t.Fatalf("replacement credentials=%+v err=%v", credentials, err)
}
if _, err = recovery.BeginAssistedPasskey(t.Context(), grant, "Replay"); !errors.Is(err, authrecovery.ErrAssistedNotFound) {
t.Fatalf("assisted grant replay err=%v", err)
}
if _, nextGrant, beginErr := recovery.Begin(t.Context(), target.Email, "new member password after recovery", result.RecoveryCodes[0]); beginErr != nil || nextGrant == "" {
t.Fatalf("replacement recovery material unavailable: grant_present=%v err=%v", nextGrant != "", beginErr)
}
audits, err := accessService.Audit(t.Context(), home.ID, 20)
if err != nil {
t.Fatal(err)
}
seenIssue, seenComplete := false, false
for _, audit := range audits {
seenIssue = seenIssue || audit.Action == "access.account-recovery.issue" && audit.ActorUserID == owner.ID && audit.ResourceID == target.ID && audit.RequestID == "request-assisted-123"
seenComplete = seenComplete || audit.Action == "access.account-recovery.complete" && audit.ActorUserID == target.ID && audit.ResourceID == target.ID
}
if !seenIssue || !seenComplete {
t.Fatalf("organization recovery audits issue=%v complete=%v events=%+v", seenIssue, seenComplete, audits)
}
}
+198 -14
View File
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
@@ -25,6 +26,8 @@ const DefaultCodeCount = 10
var (
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found")
ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized")
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
)
@@ -49,6 +52,47 @@ type PasskeyRepository interface {
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
}
// AssistedGrant is the digest-only authority created by an organization
// owner after a human recovery review. The plaintext token is returned once
// to the caller and never persisted or audited.
type AssistedGrant struct {
Digest [32]byte
OrganizationID, UserID string
IssuedByUserID string
CreatedAt, ExpiresAt time.Time
}
// AssistedIssue binds an owner-reviewed recovery to one organization member.
// Reason is deliberately bounded and must not contain credential material.
type AssistedIssue struct {
OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string
}
// AssistedRepository provides the two transactional boundaries for delegated
// recovery. Issuance invalidates all existing account authenticators and
// sessions while recording both identity and organization-visible audits.
// Completion consumes the grant exactly once and installs the replacement
// password, passkey, and recovery-code set atomically.
type AssistedRepository interface {
Repository
IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error)
AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error)
CompleteAssistedRecovery(context.Context, AssistedCompletion) error
}
// AssistedCompletion contains only the password hash, public passkey
// credential, digest-only recovery codes, and secret-free audit material.
type AssistedCompletion struct {
GrantDigest [32]byte
Credential authwebauthn.Credential
PasswordHash string
RecoveryDigests [][32]byte
PasskeyAudit auth.AuditEvent
RecoveryAudit auth.AuditEvent
AccessAudit access.AuditEvent
CompletedAt time.Time
}
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
type Passkeys interface {
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
@@ -78,21 +122,25 @@ type PasswordVerifier interface {
}
type Options struct {
Random io.Reader
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
Passkeys Passkeys
Random io.Reader
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
AssistedGrantLifetime time.Duration
OwnerRole string
Passkeys Passkeys
}
type Service struct {
repository Repository
passwords PasswordVerifier
random io.Reader
now func() time.Time
count int
grantTTL time.Duration
passkeys Passkeys
repository Repository
passwords PasswordVerifier
random io.Reader
now func() time.Time
count int
grantTTL time.Duration
assistedTTL time.Duration
ownerRole string
passkeys Passkeys
}
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
@@ -111,10 +159,120 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S
if options.GrantLifetime == 0 {
options.GrantLifetime = 10 * time.Minute
}
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
if options.AssistedGrantLifetime == 0 {
options.AssistedGrantLifetime = 15 * time.Minute
}
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) {
return nil, errors.New("authrecovery: invalid recovery policy")
}
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, passkeys: options.Passkeys}, nil
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil
}
// IssueAssistedRecovery creates one owner-authorized, single-use recovery
// token. The repository immediately invalidates the target's previous
// password, passkeys, recovery codes, sessions, and pending ceremonies so the
// reviewed recovery cannot race an older authenticator.
func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) {
repository, ok := service.repository.(AssistedRepository)
input.OrganizationID = strings.TrimSpace(input.OrganizationID)
input.ActorUserID = strings.TrimSpace(input.ActorUserID)
input.TargetUserID = strings.TrimSpace(input.TargetUserID)
input.RequestID = strings.TrimSpace(input.RequestID)
input.Reason = strings.TrimSpace(input.Reason)
if !ok || service.passkeys == nil || service.ownerRole == "" {
return auth.User{}, "", ErrPasskeyUnavailable
}
if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") {
return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request")
}
raw, err := token(service.random, 32)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)}
authAuditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
accessAuditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason
authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit)
if err != nil {
return auth.User{}, "", err
}
return user, raw, nil
}
// BeginAssistedPasskey starts a replacement ceremony without issuing a normal
// session. The grant remains reusable for ceremony restart until completion or
// expiry; only completion consumes it.
func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
repository, ok := service.repository.(AssistedRepository)
if !ok || service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return authwebauthn.BeginResult{}, ErrAssistedNotFound
}
_, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
}
// FinishAssistedRecovery consumes a reviewed grant only inside the transaction
// that installs every replacement credential and both audit trails. No normal
// session is issued; the recovered user signs in with the new credentials.
func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) {
repository, ok := service.repository.(AssistedRepository)
if !ok || service.passkeys == nil {
return PasskeyFinishResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return PasskeyFinishResult{}, ErrAssistedNotFound
}
grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return PasskeyFinishResult{}, err
}
passwordHash, err := auth.HashPasswordWithRandom(password, service.random)
if err != nil {
return PasskeyFinishResult{}, err
}
codes, digests, err := GenerateCodeSet(service.random, service.count)
if err != nil {
return PasskeyFinishResult{}, err
}
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
if verified.UserID != user.ID {
return errors.New("authrecovery: assisted recovery identity mismatch")
}
completedAt := service.now().UTC()
recoveryAuditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
accessAuditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt}
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt}
return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt})
})
if err != nil {
return PasskeyFinishResult{}, err
}
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
}
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
@@ -290,3 +448,29 @@ func token(random io.Reader, size int) (string, error) {
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func opaqueID(value string) bool {
if len(value) < 8 || len(value) > 128 {
return false
}
for _, character := range value {
if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
continue
}
return false
}
return true
}
func safeRole(value string) bool {
if len(value) < 1 || len(value) > 96 {
return false
}
for _, character := range value {
if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
continue
}
return false
}
return true
}
+9
View File
@@ -234,6 +234,15 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
return access.ErrRoleUnchanged
}
if replacement.Role == ownerRole || slices.Contains(currentRoles, ownerRole) {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return access.ErrOwnerAuthority
}
}
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
var otherOwners int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
)
func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) {
if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) {
return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
// Take the SQLite write lock before checking owner authority so a role or
// membership mutation cannot race the reviewed recovery decision.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)
AND EXISTS (SELECT 1 FROM gwf_access_bindings b WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id=? AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole)
if err != nil {
return auth.User{}, err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return auth.User{}, authrecovery.ErrAssistedDenied
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
FROM gwf_users u JOIN gwf_organization_memberships m ON m.user_id=u.id
WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authrecovery.ErrAssistedDenied
}
if err != nil {
return auth.User{}, err
}
for _, statement := range []string{
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
} {
if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil {
return auth.User{}, err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return auth.User{}, err
}
if err = appendAudit(ctx, tx, authAudit); err != nil {
return auth.User{}, err
}
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
}
var grant authrecovery.AssistedGrant
var user auth.User
var created, expires, userCreated, userUpdated int64
var passwordChangeRequired, registrationPending int
err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
FROM gwf_assisted_recovery_grants g
JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0
JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active'
JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active'
WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &userCreated, &userUpdated)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
}
if err != nil {
return authrecovery.AssistedGrant{}, auth.User{}, err
}
grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC()
return grant, user, nil
}
func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error {
credential := completion.Credential
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) {
return errors.New("authsqlite: invalid assisted recovery completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, digest := range completion.RecoveryDigests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid assisted recovery-code digest")
}
if _, duplicate := seen[digest]; duplicate {
return errors.New("authsqlite: duplicate assisted recovery-code digest")
}
seen[digest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var userID, organizationID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.ErrAssistedNotFound
}
if err != nil {
return err
}
if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID {
return errors.New("authsqlite: assisted recovery identity mismatch")
}
var active int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u
JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active'
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil {
return err
}
if active != 1 {
return auth.ErrInactiveUser
}
for _, statement := range []string{
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
} {
if _, err = tx.ExecContext(ctx, statement, userID); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
for _, digest := range completion.RecoveryDigests {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
func validAssistedGrant(grant authrecovery.AssistedGrant) bool {
return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute
}
+79 -4
View File
@@ -123,8 +123,8 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
return nil
}
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
return errors.New("authsqlite: invalid invitation")
}
teamIDs, err := json.Marshal(invitation.TeamIDs)
@@ -136,6 +136,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
return err
}
if ownerRole != "" && invitation.DirectRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
return err
}
@@ -430,6 +442,12 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
return err
}
if status != "active" {
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err
@@ -472,6 +490,9 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati
if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
return err
}
if input.Status == "suspended" {
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err
@@ -504,6 +525,12 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
return err
}
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err
}
@@ -545,6 +572,9 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
return err
}
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err
}
@@ -597,6 +627,32 @@ func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID st
return status, nil
}
func requireOwnerAuthorityForOwnerTarget(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, targetUserID, ownerRole string) error {
targetIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, targetUserID, ownerRole)
if err != nil || !targetIsOwner {
return err
}
actorIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole)
if err != nil {
return err
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
return nil
}
func hasDirectOwnerRole(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) (bool, error) {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings
WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error {
var targetIsOwner int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&targetIsOwner); err != nil {
@@ -699,8 +755,8 @@ func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID str
return nil
}
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
@@ -708,6 +764,25 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
var directRole string
if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
return err
}
if ownerRole != "" && directRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
if err != nil {
return err
+6 -1
View File
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
return store, nil
}
const SchemaVersion = 8
const SchemaVersion = 9
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
var exists int
@@ -136,6 +136,8 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`,
`CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_assisted_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, issued_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_assisted_recovery_grants_expiry ON gwf_assisted_recovery_grants(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
@@ -234,6 +236,9 @@ func (store *Store) Migrate(ctx context.Context) error {
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(8,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
+92
View File
@@ -548,6 +548,24 @@ func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
if err != nil || len(direct) != 2 {
t.Fatalf("direct=%+v err=%v", direct, err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-self-promote", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner self-promotion err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-demote-owner", ExpectedBindingIDs: []string{ownerBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-demotion err=%v", err)
}
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: member.ID, RequestID: "request-suspend-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-suspension err=%v", err)
}
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-remove-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-removal err=%v", err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", member.ID, "request-legacy-suspend-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy non-owner owner-suspension err=%v", err)
}
if err = organizationService.RemoveMembership(t.Context(), organization.ID, owner.ID, member.ID, "request-legacy-remove-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy non-owner owner-removal err=%v", err)
}
type replacementResult struct {
binding access.Binding
@@ -635,6 +653,80 @@ func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
}
func TestOwnerInvitationsRequireDirectOwnerAuthority(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.owner", Email: "invitation-owner@example.test", DisplayName: "Invitation Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
manager, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.manager", Email: "invitation-manager@example.test", DisplayName: "Invitation Manager", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "invitation-authority", Name: "Invitation Authority", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, manager.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, manager.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "site-admin": "Site administrator", "viewer": "Viewer"},
Permissions: map[string]string{"site.access.manage": "Manage site access"},
Grants: map[string][]string{"owner": {"site.access.manage"}, "site-admin": {"site.access.manage"}, "viewer": {}},
}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: manager.ID, Role: "site-admin", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, _, err = organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "blocked-owner@example.test", InvitedByUserID: manager.ID, DirectRole: "owner", Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation err=%v", err)
}
_, viewerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "viewer@example.test", InvitedByUserID: manager.ID, DirectRole: "viewer", Lifetime: time.Hour})
if err != nil {
t.Fatalf("non-owner ordinary invitation err=%v", err)
}
_, ownerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "new-owner@example.test", InvitedByUserID: owner.ID, DirectRole: "owner", Lifetime: time.Hour})
if err != nil {
t.Fatalf("owner invitation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, manager.ID, "request-manager-owner-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, viewerInvitation.ID, manager.ID, "request-manager-viewer-revoke"); err != nil {
t.Fatalf("ordinary invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, owner.ID, "request-owner-owner-revoke"); err != nil {
t.Fatalf("owner invitation revocation err=%v", err)
}
}
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"crypto/ecdh"
"crypto/rand"
"errors"
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) {
privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
publicKey := privateKey.PublicKey().Bytes()
encoded, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.EllipticKey),
3: int64(webauthncose.AlgES256),
-1: int64(webauthncose.P256),
-2: publicKey[1:33],
-3: publicKey[33:65],
})
if err != nil {
t.Fatal(err)
}
credential := &wa.Credential{
PublicKey: encoded,
// This value is absent when a standards-compliant client serializes the
// mandatory attestation object without optional response conveniences.
Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0},
}
if err = enforceCredentialAlgorithm(credential); err != nil {
t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err)
}
}
func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) {
rsaKey, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.RSAKey),
3: int64(webauthncose.AlgRS256),
-1: []byte{0xff},
-2: []byte{0x01, 0x00, 0x01},
})
if err != nil {
t.Fatal(err)
}
for name, credential := range map[string]*wa.Credential{
"nil": nil,
"malformed": {PublicKey: []byte("not-cose")},
"rsa": {PublicKey: rsaKey},
} {
t.Run(name, func(t *testing.T) {
if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) {
t.Fatalf("error=%v", err)
}
})
}
}
+21 -1
View File
@@ -375,7 +375,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
if err != nil {
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
}
if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) {
if err = enforceCredentialAlgorithm(verified); err != nil {
return Credential{}, ErrUnsupportedCredential
}
encoded, err := json.Marshal(verified)
@@ -407,6 +407,26 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
return record, nil
}
// enforceCredentialAlgorithm derives the algorithm from the verified COSE key
// carried inside authenticator data. AuthenticatorAttestationResponse's
// publicKeyAlgorithm member is an optional browser convenience value: clients
// that serialize the mandatory attestation object directly may omit it, and it
// is not the cryptographically authoritative representation.
func enforceCredentialAlgorithm(credential *wa.Credential) error {
if credential == nil {
return ErrUnsupportedCredential
}
parsed, err := webauthncose.ParsePublicKey(credential.PublicKey)
if err != nil {
return ErrUnsupportedCredential
}
key, ok := parsed.(webauthncose.EC2PublicKeyData)
if !ok || key.Algorithm != int64(webauthncose.AlgES256) {
return ErrUnsupportedCredential
}
return nil
}
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
challenge, err := service.randomBytes(32)
if err != nil {
+28
View File
@@ -65,3 +65,31 @@ application concern belongs in the shared module.
optimistic extension serializes on the active administrator membership,
rechecks the exact state bound into the passkey assertion, applies team and
direct-binding consequences, and writes the audit in one transaction.
- Human-assisted recovery cannot safely be expressed as a root command behind
an HTTP button. Preview 18 adds a distinct owner-assisted protocol: the
application performs the human review and fresh operation-bound passkey
ceremony, while the SQLite transaction rechecks an active direct owner,
invalidates every old account authenticator, stores only the grant digest,
and writes identity plus organization audits. Grant completion installs the
replacement password, passkey, and recovery-code set atomically and never
issues a session.
- Gamertan's distinction between Site Admin and Owner exposed a second
composition boundary: permission to manage ordinary staff must not imply
permission to create, demote, suspend, or remove an Owner. Preview 19 moves
that invariant into the same SQLite transactions as direct-role and
membership changes, while leaving the application's role vocabulary and UI
policy application-owned.
- Gamertan's invitation work found the same authority boundary before a route
was exposed: Site Admin must be able to invite ordinary staff without being
able to grant or cancel Owner access. Preview 20 passes the configured owner
role into invitation mutations and rechecks a current active direct Owner
after acquiring the SQLite write lock. The application still owns fresh
authentication, recipient delivery, and the one-time secret presentation.
- A real Bitwarden/Vaultwarden owner enrollment reached successful WebAuthn
verification but was rejected by a redundant algorithm check because the
application's direct response serializer omitted the optional browser
`publicKeyAlgorithm` convenience member. Preview 21 keeps ES256-only policy
enforcement but derives it from the verified COSE key embedded in
authenticator data. This makes the server independent of serializer-specific
convenience fields without weakening origin, challenge, user-verification,
or algorithm validation.
+1 -1
View File
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.17
go get gamertan.com/web/requestmeta@v0.1.0-preview.21
go mod verify
```
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.17
go get gamertan.com/web/requestmeta@v0.1.0-preview.21
```
Only imported packages are compiled and linked. The packages nevertheless
+13
View File
@@ -12,6 +12,10 @@ expiring, single-use invitations. An invitation may carry one direct role and
up to sixteen reviewed team memberships. Acceptance verifies that the
authenticated user's normalized email matches and applies the membership,
role, teams, consumption marker, and audit event in one transaction.
When `OwnerRole` is configured, creating or revoking an invitation carrying
that role additionally requires a current active direct owner inside the same
SQLite transaction. A broad access-management permission may administer
ordinary invitations but cannot create or cancel owner access.
Applications own invitation pages, email or out-of-band delivery, active-source
checks before archival, and account recovery.
@@ -64,6 +68,15 @@ grant organization-data access. If an operator must inspect tenant data during
an incident, use a reasoned break-glass grant. It expires within one hour and
creates an append-only audit event in the same transaction.
An application that offers owner-assisted account recovery must not infer that
authority from a broad administration page. Use the dedicated
`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey
assertion. The SQLite adapter requires a current active direct owner binding
and active target membership in the same transaction that invalidates the old
credentials and records the organization-visible recovery audit. Team,
break-glass, platform, and merely descriptive roles do not satisfy this owner
check.
The SQLite adapter namespaces all tables, enforces active organization and team
membership plus resource ancestry before accepting or evaluating a binding,
and keeps invitations and sessions as digests. Applications remain responsible
+21
View File
@@ -19,6 +19,10 @@ authorization decisions, session cookie, HTML, and local recovery command.
- Request no attestation conveyance.
- Permit ES256 only until another algorithm has explicit interoperability and
security evidence.
- Enforce that policy from the verified COSE public key embedded in
authenticator data. Do not rely on the optional browser
`publicKeyAlgorithm` convenience member: direct standards-compliant response
serializers may omit it even when the attested credential is ES256.
- Store random challenges and verifier session data only behind opaque,
single-use ceremony tokens.
- Treat clone warnings as audit signals rather than automatic lockout for
@@ -85,5 +89,22 @@ ceremony until expiry. A binding mismatch consumes the mismatched ceremony.
Applications must use generic failure responses and the same credential-attempt
rate limiting as login.
Owner-assisted recovery is a third, deliberately separate path. Configure
`authrecovery.Options.OwnerRole`, authorize an active direct organization owner,
and bind that owner's fresh passkey assertion to the exact organization,
target user, request identifier, and bounded human-review reason before calling
`IssueAssistedRecovery`. The SQLite transaction rechecks the active direct
owner and target membership, invalidates the target's password, passkeys,
recovery codes, sessions, and pending ceremonies, then stores only a digest of
the 15-minute grant with identity and organization-visible audits.
Deliver the returned grant exactly once in a URL fragment. A public recovery
page can pass it to `BeginAssistedPasskey` and `FinishAssistedRecovery` while
keeping it out of request URLs, referrers, and access logs. Completion consumes
the grant atomically with one replacement password, passkey, recovery-code set,
and both audit trails. It issues no session. Losing the fragment after issuance
requires another reviewed owner or root-local recovery; old authenticators
must not become valid again as a fallback.
Before enabling production mutations, applications should require at least two
independent passkeys and complete a local recovery drill.
+5 -4
View File
@@ -27,6 +27,7 @@ var (
ErrRevisionConflict = errors.New("organizations: revision conflict")
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
)
@@ -103,10 +104,10 @@ type Repository interface {
CreateProject(context.Context, Project) error
CreateEnvironment(context.Context, Environment) error
CreateApplicationService(context.Context, ApplicationService) error
CreateInvitation(context.Context, Invitation, AuditEvent) error
CreateInvitation(context.Context, Invitation, string, AuditEvent) error
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
MembershipsForUser(context.Context, string) ([]Membership, error)
@@ -313,7 +314,7 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
if err != nil {
return "", Invitation{}, err
}
if err = service.repository.CreateInvitation(ctx, invitation, audit); err != nil {
if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil {
return "", Invitation{}, err
}
return raw, invitation, nil
@@ -558,7 +559,7 @@ func (service *Service) RevokeInvitation(ctx context.Context, organizationID, in
if err != nil {
return err
}
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, now, audit)
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, service.ownerRole, now, audit)
}
func (service *Service) Repository() Repository { return service.repository }
+2 -2
View File
@@ -100,7 +100,7 @@ func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { r
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
return nil
}
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ AuditEvent) error {
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ string, _ AuditEvent) error {
repository.invitation = invitation
return nil
}
@@ -113,7 +113,7 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
return nil, nil
}
func (*repositoryStub) RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error {
func (*repositoryStub) RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error {
return nil
}
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
+2
View File
@@ -30,6 +30,7 @@ auth/password.go
auth/password_test.go
authrecovery/recovery.go
authrecovery/recovery_test.go
authrecovery/assisted_test.go
auth/service_test.go
authhttp/authhttp.go
authhttp/authhttp_test.go
@@ -40,6 +41,7 @@ authsqlite/store_test.go
authsqlite/account.go
authsqlite/account_test.go
authsqlite/access.go
authsqlite/assisted_recovery.go
authsqlite/bootstrap.go
authsqlite/bootstrap_test.go
authsqlite/organizations.go