Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b09c8ae5 | ||
|
|
95d50f0888 | ||
|
|
1f54c75501 |
@@ -2,6 +2,37 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.1.0-preview.14 — 2026-09-03
|
||||
|
||||
- Reject header-only, truncated, and structurally invalid PDF uploads in the
|
||||
bounded media preparer. Accepted attachments now require a supported PDF
|
||||
version, terminal EOF marker, numeric in-range `startxref`, and either a
|
||||
traditional xref/trailer or xref-stream object at the declared offset.
|
||||
- Keep PDF handling storage-neutral and non-rendering: applications still own
|
||||
authorization, reference tracking, attachment disposition, and lifecycle.
|
||||
|
||||
## v0.1.0-preview.13 — 2026-09-03
|
||||
|
||||
- Complete the password-plus-recovery-code flow with a short-lived restricted
|
||||
grant bound into a replacement-passkey ceremony. Completion atomically
|
||||
consumes the grant, stores the verified passkey, replaces every recovery
|
||||
code, revokes any intervening sessions and ceremonies, and records both
|
||||
audits without issuing a normal session.
|
||||
- Keep failed completion retryable until grant expiry: a duplicate credential
|
||||
or other transaction failure rolls back grant consumption and recovery-code
|
||||
replacement, while a mismatched WebAuthn binding consumes only the affected
|
||||
ceremony.
|
||||
|
||||
## v0.1.0-preview.12 — 2026-09-03
|
||||
|
||||
- Add a root-local bootstrap transaction that creates the first passkey-only
|
||||
application owner, non-personal organization, active membership, direct
|
||||
owner binding, one-time enrollment digest, and secret-free audit records
|
||||
atomically.
|
||||
- Fail closed and roll back the entire bootstrap when the application has not
|
||||
seeded the configured owner role. The raw enrollment token is returned only
|
||||
after commit and never enters repository state or audit records.
|
||||
|
||||
## v0.1.0-preview.11 — 2026-09-03
|
||||
|
||||
- Add expected-user completion for authenticated self-service passkey
|
||||
|
||||
@@ -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.11`. APIs may change before a stable
|
||||
> **Public preview:** `v0.1.0-preview.13`. APIs may change before a stable
|
||||
> release. Linux is the maintained release platform.
|
||||
|
||||
## Why Web Foundations?
|
||||
@@ -40,6 +40,7 @@ packages you import.
|
||||
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
|
||||
| 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) |
|
||||
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||
@@ -56,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.11
|
||||
go get gamertan.com/web@v0.1.0-preview.13
|
||||
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.11
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
|
||||
```
|
||||
|
||||
The version belongs to the `gamertan.com/web` module. See the
|
||||
|
||||
+116
-6
@@ -17,13 +17,15 @@ import (
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
)
|
||||
|
||||
const DefaultCodeCount = 10
|
||||
|
||||
var (
|
||||
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
|
||||
)
|
||||
|
||||
type Grant struct {
|
||||
@@ -39,6 +41,38 @@ type Repository interface {
|
||||
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
|
||||
}
|
||||
|
||||
// PasskeyRepository adds the transactional boundary required to finish a
|
||||
// password-plus-recovery-code flow without issuing a normal session.
|
||||
type PasskeyRepository interface {
|
||||
Repository
|
||||
RecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
|
||||
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
|
||||
}
|
||||
|
||||
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
|
||||
type Passkeys interface {
|
||||
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
||||
FinishRecoveryRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
|
||||
}
|
||||
|
||||
// PasskeyCompletion contains the public credential, digest-only replacement
|
||||
// codes, and secret-free audits committed after a recovery ceremony.
|
||||
type PasskeyCompletion struct {
|
||||
GrantDigest [32]byte
|
||||
Credential authwebauthn.Credential
|
||||
RecoveryDigests [][32]byte
|
||||
PasskeyAudit auth.AuditEvent
|
||||
RecoveryAudit auth.AuditEvent
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
// PasskeyFinishResult returns the verified credential and the new plaintext
|
||||
// recovery codes. Applications must display the codes once and retain none.
|
||||
type PasskeyFinishResult struct {
|
||||
Credential authwebauthn.Credential
|
||||
RecoveryCodes []string
|
||||
}
|
||||
|
||||
type PasswordVerifier interface {
|
||||
VerifyPassword(context.Context, string, string) (auth.User, error)
|
||||
}
|
||||
@@ -48,6 +82,7 @@ type Options struct {
|
||||
Now func() time.Time
|
||||
CodeCount int
|
||||
GrantLifetime time.Duration
|
||||
Passkeys Passkeys
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -57,6 +92,7 @@ type Service struct {
|
||||
now func() time.Time
|
||||
count int
|
||||
grantTTL time.Duration
|
||||
passkeys Passkeys
|
||||
}
|
||||
|
||||
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
||||
@@ -78,7 +114,7 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S
|
||||
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
|
||||
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, passkeys: options.Passkeys}, nil
|
||||
}
|
||||
|
||||
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
||||
@@ -133,10 +169,74 @@ func (service *Service) Begin(ctx context.Context, identifier, password, code st
|
||||
}
|
||||
|
||||
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
|
||||
if len(raw) < 32 || len(raw) > 128 {
|
||||
return auth.User{}, ErrGrantNotFound
|
||||
digest, err := grantDigest(raw)
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
|
||||
return service.repository.TakeRecoveryGrant(ctx, digest, service.now().UTC())
|
||||
}
|
||||
|
||||
// BeginPasskey starts a ceremony only for a live restricted recovery grant.
|
||||
// The raw grant remains application-held so a failed or interrupted ceremony
|
||||
// can be restarted until the grant expires.
|
||||
func (service *Service) BeginPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
|
||||
repository, ok := service.repository.(PasskeyRepository)
|
||||
if !ok || service.passkeys == nil {
|
||||
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
|
||||
}
|
||||
digest, err := grantDigest(rawGrant)
|
||||
if err != nil {
|
||||
return authwebauthn.BeginResult{}, err
|
||||
}
|
||||
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
|
||||
if err != nil {
|
||||
return authwebauthn.BeginResult{}, err
|
||||
}
|
||||
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
|
||||
}
|
||||
|
||||
// FinishPasskey consumes the grant only inside the transaction that stores the
|
||||
// verified passkey and a fresh recovery-code set. It never issues a session.
|
||||
func (service *Service) FinishPasskey(ctx context.Context, rawGrant, ceremonyToken string, response []byte) (PasskeyFinishResult, error) {
|
||||
repository, ok := service.repository.(PasskeyRepository)
|
||||
if !ok || service.passkeys == nil {
|
||||
return PasskeyFinishResult{}, ErrPasskeyUnavailable
|
||||
}
|
||||
digest, err := grantDigest(rawGrant)
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
|
||||
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: recovery identity mismatch")
|
||||
}
|
||||
completedAt := service.now().UTC()
|
||||
auditID, auditErr := token(service.random, 18)
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
recoveryAudit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Account recovery enrolled a replacement passkey and replaced the recovery-code set.", CreatedAt: completedAt}
|
||||
return repository.CompletePasskeyRecovery(commitContext, PasskeyCompletion{
|
||||
GrantDigest: digest,
|
||||
Credential: verified,
|
||||
RecoveryDigests: digests,
|
||||
PasskeyAudit: passkeyAudit,
|
||||
RecoveryAudit: recoveryAudit,
|
||||
CompletedAt: completedAt,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
|
||||
}
|
||||
|
||||
func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) {
|
||||
@@ -173,6 +273,16 @@ func DigestCode(code string) ([32]byte, error) {
|
||||
return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil
|
||||
}
|
||||
|
||||
func grantDigest(raw string) ([32]byte, error) {
|
||||
if len(raw) < 32 || len(raw) > 128 {
|
||||
return [32]byte{}, ErrGrantNotFound
|
||||
}
|
||||
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
|
||||
return [32]byte{}, ErrGrantNotFound
|
||||
}
|
||||
return sha256.Sum256([]byte(raw)), nil
|
||||
}
|
||||
|
||||
func token(random io.Reader, size int) (string, error) {
|
||||
value := make([]byte, size)
|
||||
if _, err := io.ReadFull(random, value); err != nil {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
package authrecovery_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +16,8 @@ import (
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authrecovery"
|
||||
"gamertan.com/web/authsqlite"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||
)
|
||||
|
||||
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
|
||||
@@ -61,6 +67,112 @@ func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyRecoveryAtomicallyReplacesCodesWithoutIssuingSession(t *testing.T) {
|
||||
now := time.Date(2026, 9, 3, 13, 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.passkey", Email: "recover-passkey@example.test", DisplayName: "Recover Passkey", Password: "correct horse battery staple"})
|
||||
if 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: user.ID, Label: "Existing passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "existing-passkey-audit", ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Existing passkey fixture.", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passkeys := &passkeyRecoveryStub{now: now, credentialID: existingID}
|
||||
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldCodes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, grant, err := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
begin, err := recovery.BeginPasskey(t.Context(), grant, "Replacement passkey")
|
||||
if err != nil || begin.CeremonyToken == "" || passkeys.userID != user.ID || passkeys.beginBinding != grant {
|
||||
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
|
||||
}
|
||||
if _, err = recovery.FinishPasskey(t.Context(), grant, begin.CeremonyToken, []byte(`{"fixture":true}`)); err == nil {
|
||||
t.Fatal("duplicate credential unexpectedly committed")
|
||||
}
|
||||
if _, err = recovery.BeginPasskey(t.Context(), grant, "Retry replacement"); err != nil {
|
||||
t.Fatalf("failed completion consumed recovery grant: %v", err)
|
||||
}
|
||||
lateSession, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
passkeys.credentialID = bytes.Repeat([]byte{8}, 32)
|
||||
result, err := recovery.FinishPasskey(t.Context(), grant, "retry-ceremony-token", []byte(`{"fixture":true}`))
|
||||
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount || !bytes.Equal(result.Credential.ID, passkeys.credentialID) {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
if passkeys.finishBinding != grant {
|
||||
t.Fatal("finish ceremony was not bound to the restricted recovery grant")
|
||||
}
|
||||
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
|
||||
t.Fatalf("completed grant replay err=%v", err)
|
||||
}
|
||||
if _, err = authService.Session(t.Context(), lateSession); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||
t.Fatalf("session created during recovery survived completion: %v", err)
|
||||
}
|
||||
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("old recovery-code set survived completion: %v", err)
|
||||
}
|
||||
if _, newGrant, beginErr := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", result.RecoveryCodes[0]); beginErr != nil || newGrant == "" {
|
||||
t.Fatalf("new recovery code unavailable: grant=%q err=%v", newGrant, beginErr)
|
||||
}
|
||||
credentials, err := store.CredentialsByUserID(t.Context(), user.ID)
|
||||
if err != nil || len(credentials) != 2 {
|
||||
t.Fatalf("credentials=%+v err=%v", credentials, err)
|
||||
}
|
||||
}
|
||||
|
||||
type passkeyRecoveryStub struct {
|
||||
now time.Time
|
||||
userID string
|
||||
credentialID []byte
|
||||
beginBinding string
|
||||
finishBinding string
|
||||
}
|
||||
|
||||
func (stub *passkeyRecoveryStub) BeginRecoveryRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
|
||||
stub.userID = userID
|
||||
stub.beginBinding = string(binding)
|
||||
return authwebauthn.BeginResult{CeremonyToken: "recovery-ceremony-token", PublicKey: json.RawMessage(`{"challenge":"fixture"}`), ExpiresAt: stub.now.Add(5 * time.Minute)}, nil
|
||||
}
|
||||
|
||||
func (stub *passkeyRecoveryStub) FinishRecoveryRegistration(ctx context.Context, _ string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
|
||||
stub.finishBinding = string(binding)
|
||||
encoded, err := json.Marshal(wa.Credential{ID: stub.credentialID, PublicKey: []byte{1, 2, 3}})
|
||||
if err != nil {
|
||||
return authwebauthn.Credential{}, err
|
||||
}
|
||||
credential := authwebauthn.Credential{ID: append([]byte(nil), stub.credentialID...), UserID: stub.userID, Label: "Replacement passkey", Data: encoded, CreatedAt: stub.now}
|
||||
audit := auth.AuditEvent{ID: "recovery-passkey-audit", ActorUserID: stub.userID, Action: "auth.recovery.passkey", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(stub.credentialID), Summary: "A replacement passkey was enrolled during account recovery.", CreatedAt: stub.now}
|
||||
if err = commit(ctx, credential, audit); err != nil {
|
||||
return authwebauthn.Credential{}, err
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
type counterReader struct{ value byte }
|
||||
|
||||
func (reader *counterReader) Read(target []byte) (int, error) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gamertan.com/web/bootstrap"
|
||||
)
|
||||
|
||||
// CreateInitialOwner commits the root-local bootstrap across identity,
|
||||
// enrollment, organization, membership, owner access, and all audit records.
|
||||
func (store *Store) CreateInitialOwner(ctx context.Context, setup bootstrap.Setup) error {
|
||||
user := setup.User
|
||||
organization := setup.Organization
|
||||
membership := setup.Membership
|
||||
binding := setup.OwnerBinding
|
||||
if !validPasskeyUser(user) || !validEnrollment(setup.Enrollment) || setup.Enrollment.UserID != user.ID ||
|
||||
!validOrganization(organization) || organization.Personal || organization.Status != "active" || organization.Revision != 1 ||
|
||||
membership.OrganizationID != organization.ID || membership.UserID != user.ID || membership.Status != "active" || membership.JoinedAt.IsZero() ||
|
||||
!validOwnerBinding(binding, organization.ID, user.ID) ||
|
||||
!validAuditEvent(setup.AuthAudit) || setup.AuthAudit.ActorUserID != user.ID || setup.AuthAudit.Action != "auth.passkey.bootstrap" || setup.AuthAudit.ResourceType != "user" || setup.AuthAudit.ResourceID != user.ID ||
|
||||
!validOrganizationAudit(setup.OrganizationAudit, organization.ID) || setup.OrganizationAudit.ActorUserID != user.ID || setup.OrganizationAudit.Action != "organization.bootstrap" || setup.OrganizationAudit.ResourceType != "organization" || setup.OrganizationAudit.ResourceID != organization.ID ||
|
||||
!validAccessAudit(setup.AccessAudit) || setup.AccessAudit.OrganizationID != organization.ID || setup.AccessAudit.ActorUserID != user.ID || setup.AccessAudit.Action != "access.binding.grant" || setup.AccessAudit.ResourceType != "binding" || setup.AccessAudit.ResourceID != binding.ID {
|
||||
return errors.New("authsqlite: invalid initial owner bootstrap")
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
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,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, 0, 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(?,?,?,?)`, setup.Enrollment.Digest[:], user.ID, setup.Enrollment.CreatedAt.Unix(), setup.Enrollment.ExpiresAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,0,NULL,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, 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(?,?,?,?)`, organization.ID, user.ID, membership.Status, membership.JoinedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
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, user.ID, binding.Role, user.ID, 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: initial owner role has not been seeded")
|
||||
}
|
||||
if err = appendAudit(ctx, tx, setup.AuthAudit); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendOrganizationAudit(ctx, tx, setup.OrganizationAudit); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAccessAudit(ctx, tx, setup.AccessAudit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var _ bootstrap.Repository = (*Store)(nil)
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
"gamertan.com/web/bootstrap"
|
||||
)
|
||||
|
||||
func TestInitialOwnerBootstrapCommitsEveryBoundary(t *testing.T) {
|
||||
store, err := Open(t.TempDir() + "/bootstrap.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
policy := access.Policy{Roles: map[string]string{"home.owner": "Own the home organization"}, Permissions: map[string]string{"home.manage": "Manage the home organization"}, Grants: map[string][]string{"home.owner": {"home.manage"}}}
|
||||
accessService, err := access.New(store, policy, access.Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = accessService.Seed(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
|
||||
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, err := store.UserByID(t.Context(), created.User.ID)
|
||||
if err != nil || user.Email != "cole@example.test" {
|
||||
t.Fatalf("user=%+v err=%v", user, err)
|
||||
}
|
||||
organization, err := store.OrganizationByID(t.Context(), created.Organization.ID)
|
||||
if err != nil || organization.Personal || organization.Slug != "gamertan" {
|
||||
t.Fatalf("organization=%+v err=%v", organization, err)
|
||||
}
|
||||
memberships, err := store.MembershipsForUser(t.Context(), user.ID)
|
||||
if err != nil || len(memberships) != 1 || memberships[0].OrganizationID != organization.ID {
|
||||
t.Fatalf("memberships=%+v err=%v", memberships, err)
|
||||
}
|
||||
decision, err := accessService.Authorize(t.Context(), user.ID, access.Scope{OrganizationID: organization.ID}, "home.manage")
|
||||
if err != nil || !decision.Allowed || decision.Role != "home.owner" {
|
||||
t.Fatalf("decision=%+v err=%v", decision, err)
|
||||
}
|
||||
passkeyService := testBootstrapPasskeyService(t, store, now)
|
||||
begin, err := passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Initial passkey")
|
||||
if err != nil || begin.CeremonyToken == "" {
|
||||
t.Fatalf("begin=%+v err=%v", begin, err)
|
||||
}
|
||||
if _, err = passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
|
||||
t.Fatalf("enrollment replay err=%v", err)
|
||||
}
|
||||
var authAudits, accessAudits int
|
||||
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&authAudits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&accessAudits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if authAudits != 1 || accessAudits != 2 {
|
||||
t.Fatalf("auth audits=%d access audits=%d", authAudits, accessAudits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialOwnerBootstrapRollsBackWithoutSeededRole(t *testing.T) {
|
||||
store, err := Open(t.TempDir() + "/bootstrap.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
|
||||
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}); err == nil {
|
||||
t.Fatal("bootstrap succeeded without seeded role")
|
||||
}
|
||||
for _, table := range []string{"gwf_users", "gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_passkey_enrollment_tokens", "gwf_audit_events", "gwf_access_audit_events"} {
|
||||
var count int
|
||||
if queryErr := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); queryErr != nil || count != 0 {
|
||||
t.Fatalf("table=%s count=%d err=%v", table, count, queryErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testBootstrapPasskeyService(t *testing.T, store *Store, now time.Time) *authwebauthn.Service {
|
||||
t.Helper()
|
||||
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "example.test", RPDisplayName: "Example", Origin: "https://example.test", Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +40,100 @@ func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, dig
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) RecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
|
||||
if zeroDigest(digest) || now.IsZero() {
|
||||
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||
}
|
||||
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_recovery_grants g JOIN gwf_users u ON u.id=g.user_id WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()))
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (store *Store) CompletePasskeyRecovery(ctx context.Context, completion authrecovery.PasskeyCompletion) error {
|
||||
credential := completion.Credential
|
||||
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || !validAuditEvent(completion.RecoveryAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID {
|
||||
return errors.New("authsqlite: invalid passkey recovery completion")
|
||||
}
|
||||
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
||||
for _, digest := range completion.RecoveryDigests {
|
||||
if zeroDigest(digest) {
|
||||
return errors.New("authsqlite: invalid recovery-code digest")
|
||||
}
|
||||
if _, exists := seen[digest]; exists {
|
||||
return errors.New("authsqlite: duplicate recovery-code digest")
|
||||
}
|
||||
seen[digest] = struct{}{}
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID string
|
||||
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return authrecovery.ErrGrantNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userID != credential.UserID {
|
||||
return errors.New("authsqlite: passkey recovery identity mismatch")
|
||||
}
|
||||
var active, pending int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT status='active',registration_pending FROM gwf_users WHERE id=?`, userID).Scan(&active, &pending); err != nil || active != 1 || pending != 0 {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
return auth.ErrInactiveUser
|
||||
}
|
||||
existing, err := tx.QueryContext(ctx, `SELECT credential_id FROM gwf_passkey_credentials WHERE user_id=?`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for existing.Next() {
|
||||
var id []byte
|
||||
if err = existing.Scan(&id); err != nil {
|
||||
existing.Close()
|
||||
return err
|
||||
}
|
||||
if bytes.Equal(id, credential.ID) {
|
||||
existing.Close()
|
||||
return errors.New("authsqlite: passkey credential already exists")
|
||||
}
|
||||
}
|
||||
if err = existing.Close(); 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
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); 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, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, 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
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -218,6 +218,23 @@ func (service *Service) BeginAccountRegistration(ctx context.Context, userID, la
|
||||
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true)
|
||||
}
|
||||
|
||||
// BeginRecoveryRegistration starts a replacement-passkey ceremony bound to a
|
||||
// short-lived recovery grant selected by the application. The grant itself is
|
||||
// never persisted in ceremony state; only its digest is retained.
|
||||
func (service *Service) BeginRecoveryRegistration(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), false)
|
||||
}
|
||||
|
||||
// BeginPasswordMigration starts registration for an already authenticated
|
||||
// password-backed user. Completion atomically retires the password and revokes
|
||||
// all sessions, including the session that authorized this ceremony.
|
||||
@@ -289,6 +306,20 @@ func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyT
|
||||
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit)
|
||||
}
|
||||
|
||||
// FinishRecoveryRegistration verifies a replacement passkey and delegates its
|
||||
// persistence to commit so recovery-grant consumption, credential storage, and
|
||||
// recovery-code replacement can share one transaction.
|
||||
func (service *Service) FinishRecoveryRegistration(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, false, func(commitContext context.Context, credential Credential, audit auth.AuditEvent) error {
|
||||
audit.Action = "auth.recovery.passkey"
|
||||
audit.Summary = "A replacement passkey was enrolled during account recovery."
|
||||
return commit(commitContext, credential, audit)
|
||||
})
|
||||
}
|
||||
|
||||
// FinishPasswordMigration verifies the new passkey and persists it together
|
||||
// with password retirement and session revocation in one storage transaction.
|
||||
func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ package authwebauthn_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -147,6 +148,30 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRegistrationIsBoundAndConsumesMismatchedCeremony(t *testing.T) {
|
||||
now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC)
|
||||
store, authService, service := newService(t, &now, &counterReader{})
|
||||
defer store.Close()
|
||||
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.bound", Email: "recover-bound@example.test", DisplayName: "Recover Bound", Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding := bytes.Repeat([]byte("restricted recovery grant "), 2)
|
||||
begin, err := service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", binding)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||
t.Fatalf("tampered recovery binding err=%v", err)
|
||||
}
|
||||
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, binding, []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
|
||||
t.Fatalf("mismatched completion did not consume recovery ceremony: %v", err)
|
||||
}
|
||||
if _, err = service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", []byte("short")); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||
t.Fatalf("short recovery binding err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) {
|
||||
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package bootstrap creates the first application owner and non-personal
|
||||
// organization as one storage transaction. It is intended for a root-local
|
||||
// operator command, not for public registration or a network administration
|
||||
// endpoint.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
const defaultEnrollmentLifetime = 15 * time.Minute
|
||||
|
||||
var (
|
||||
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
rolePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||
)
|
||||
|
||||
// Input is the reviewed, non-secret identity and organization metadata from a
|
||||
// local operator command.
|
||||
type Input struct {
|
||||
Username string
|
||||
Email string
|
||||
DisplayName string
|
||||
OrganizationSlug string
|
||||
OrganizationName string
|
||||
}
|
||||
|
||||
// Setup is the complete secret-free state a repository must commit atomically.
|
||||
// Enrollment contains only a digest; the raw token remains in the Result.
|
||||
type Setup struct {
|
||||
User auth.User
|
||||
Enrollment authwebauthn.EnrollmentToken
|
||||
Organization organizations.Organization
|
||||
Membership organizations.Membership
|
||||
OwnerBinding access.Binding
|
||||
AuthAudit auth.AuditEvent
|
||||
OrganizationAudit organizations.AuditEvent
|
||||
AccessAudit access.AuditEvent
|
||||
}
|
||||
|
||||
// Result contains the created public records and the one-time enrollment
|
||||
// secret. Applications must deliver EnrollmentToken through a private channel
|
||||
// and must never log it.
|
||||
type Result struct {
|
||||
User auth.User
|
||||
Organization organizations.Organization
|
||||
EnrollmentToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Repository owns the single transaction spanning identity, enrollment,
|
||||
// organization membership, owner access, and their audit events.
|
||||
type Repository interface {
|
||||
CreateInitialOwner(context.Context, Setup) error
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
OwnerRole string
|
||||
EnrollmentLifetime time.Duration
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
ownerRole string
|
||||
enrollmentLifetime time.Duration
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository, options Options) (*Service, error) {
|
||||
if repository == nil {
|
||||
return nil, errors.New("bootstrap: repository is required")
|
||||
}
|
||||
if !rolePattern.MatchString(options.OwnerRole) {
|
||||
return nil, errors.New("bootstrap: owner role is invalid")
|
||||
}
|
||||
if options.EnrollmentLifetime == 0 {
|
||||
options.EnrollmentLifetime = defaultEnrollmentLifetime
|
||||
}
|
||||
if options.EnrollmentLifetime < time.Minute || options.EnrollmentLifetime > time.Hour {
|
||||
return nil, errors.New("bootstrap: enrollment lifetime is invalid")
|
||||
}
|
||||
if options.Random == nil {
|
||||
options.Random = rand.Reader
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return &Service{repository: repository, ownerRole: options.OwnerRole, enrollmentLifetime: options.EnrollmentLifetime, random: options.Random, now: options.Now}, nil
|
||||
}
|
||||
|
||||
// Start atomically creates one active passkey-only owner, one active
|
||||
// non-personal organization, direct owner access, and a single-use enrollment
|
||||
// token. It does not create a session or expose a network bootstrap surface.
|
||||
func (service *Service) Start(ctx context.Context, input Input) (Result, error) {
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.OrganizationSlug = strings.ToLower(strings.TrimSpace(input.OrganizationSlug))
|
||||
input.OrganizationName = strings.TrimSpace(input.OrganizationName)
|
||||
if !identifierPattern.MatchString(input.Username) || !canonicalEmail(input.Email) || !bounded(input.DisplayName, 128) || !slugPattern.MatchString(input.OrganizationSlug) || !bounded(input.OrganizationName, 128) {
|
||||
return Result{}, errors.New("bootstrap: invalid owner or organization")
|
||||
}
|
||||
values, err := service.randomValues(7)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
userID, organizationID, bindingID := values[0], values[1], values[2]
|
||||
rawToken := values[3]
|
||||
user := auth.User{ID: userID, Username: input.Username, Email: input.Email, DisplayName: input.DisplayName, Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||
organization := organizations.Organization{ID: organizationID, Slug: input.OrganizationSlug, Name: input.OrganizationName, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
enrollment := authwebauthn.EnrollmentToken{Digest: sha256.Sum256([]byte(rawToken)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.enrollmentLifetime)}
|
||||
membership := organizations.Membership{OrganizationID: organizationID, UserID: userID, Status: "active", JoinedAt: now}
|
||||
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: userID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: userID, GrantedAt: now}
|
||||
setup := Setup{
|
||||
User: user,
|
||||
Enrollment: enrollment,
|
||||
Organization: organization,
|
||||
Membership: membership,
|
||||
OwnerBinding: binding,
|
||||
AuthAudit: auth.AuditEvent{ID: values[4], ActorUserID: userID, Action: "auth.passkey.bootstrap", ResourceType: "user", ResourceID: userID, Summary: "A local operator created the initial passkey-only owner and one-time enrollment token.", CreatedAt: now},
|
||||
OrganizationAudit: organizations.AuditEvent{ID: values[5], OrganizationID: organizationID, ActorUserID: userID, Action: "organization.bootstrap", ResourceType: "organization", ResourceID: organizationID, Summary: "A local operator created the initial organization.", CreatedAt: now},
|
||||
AccessAudit: access.AuditEvent{ID: values[6], OrganizationID: organizationID, ActorUserID: userID, Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID, Summary: "The initial owner received direct organization access.", CreatedAt: now},
|
||||
}
|
||||
if err = service.repository.CreateInitialOwner(ctx, setup); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{User: user, Organization: organization, EnrollmentToken: rawToken, ExpiresAt: enrollment.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
func (service *Service) randomValues(count int) ([]string, error) {
|
||||
values := make([]string, count)
|
||||
for index := range values {
|
||||
bytes := make([]byte, 24)
|
||||
if _, err := io.ReadFull(service.random, bytes); err != nil {
|
||||
return nil, fmt.Errorf("bootstrap: secure randomness unavailable: %w", err)
|
||||
}
|
||||
values[index] = base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func canonicalEmail(value string) bool {
|
||||
if value == "" || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
|
||||
return false
|
||||
}
|
||||
address, err := mail.ParseAddress(value)
|
||||
return err == nil && address.Name == "" && address.Address == value
|
||||
}
|
||||
|
||||
func bounded(value string, maximum int) bool {
|
||||
return value != "" && len(value) <= maximum && !strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingRepository struct {
|
||||
setup Setup
|
||||
err error
|
||||
}
|
||||
|
||||
func (repository *recordingRepository) CreateInitialOwner(_ context.Context, setup Setup) error {
|
||||
repository.setup = setup
|
||||
return repository.err
|
||||
}
|
||||
|
||||
func TestStartBuildsAtomicInitialOwnerSetup(t *testing.T) {
|
||||
repository := new(recordingRepository)
|
||||
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
|
||||
service, err := New(repository, Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.Start(t.Context(), Input{Username: "cole.owner", Email: "COLE@EXAMPLE.TEST", DisplayName: "Cole Speelman", OrganizationSlug: "Gamertan", OrganizationName: "Gamertan"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setup := repository.setup
|
||||
if result.EnrollmentToken == "" || setup.Enrollment.Digest == [32]byte{} || result.User.Email != "cole@example.test" || result.Organization.Personal || result.Organization.Status != "active" {
|
||||
t.Fatalf("result=%+v setup=%+v", result, setup)
|
||||
}
|
||||
if setup.Membership.UserID != result.User.ID || setup.Membership.OrganizationID != result.Organization.ID || setup.OwnerBinding.Role != "home.owner" || setup.OwnerBinding.GrantedBy != result.User.ID {
|
||||
t.Fatalf("membership=%+v binding=%+v", setup.Membership, setup.OwnerBinding)
|
||||
}
|
||||
if setup.AuthAudit.ID == setup.OrganizationAudit.ID || setup.OrganizationAudit.ID == setup.AccessAudit.ID || setup.AuthAudit.Summary == "" || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID {
|
||||
t.Fatalf("audits=%+v %+v %+v", setup.AuthAudit, setup.OrganizationAudit, setup.AccessAudit)
|
||||
}
|
||||
if !result.ExpiresAt.Equal(now.Add(15 * time.Minute)) {
|
||||
t.Fatalf("expires=%v", result.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRejectsUnsafeInputAndDoesNotCommit(t *testing.T) {
|
||||
repository := new(recordingRepository)
|
||||
service, err := New(repository, Options{OwnerRole: "home.owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, input := range []Input{
|
||||
{Username: "x", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
|
||||
{Username: "owner.user", Email: "Owner <owner@example.test>", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
|
||||
{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "bad/slug", OrganizationName: "Gamertan"},
|
||||
} {
|
||||
if _, startErr := service.Start(t.Context(), input); startErr == nil {
|
||||
t.Fatalf("unsafe input accepted: %+v", input)
|
||||
}
|
||||
}
|
||||
if repository.setup.User.ID != "" {
|
||||
t.Fatal("repository was called for rejected input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartDoesNotReturnSecretAfterRepositoryFailure(t *testing.T) {
|
||||
repository := &recordingRepository{err: errors.New("commit failed")}
|
||||
service, err := New(repository, Options{OwnerRole: "home.owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.Start(t.Context(), Input{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
|
||||
if err == nil || result.EnrollmentToken != "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,14 @@ application concern belongs in the shared module.
|
||||
ceremony and checking its user only after persistence is too late.
|
||||
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
|
||||
the application-authenticated user before storing a credential.
|
||||
- First-owner provisioning exposed another cross-package transaction boundary.
|
||||
`bootstrap` now commits the passkey-only user, enrollment digest,
|
||||
non-personal organization, membership, direct owner binding, and audits
|
||||
together. Applications must seed their owner role first and must write the
|
||||
returned raw token only to a newly created private file.
|
||||
- Recovery-code consumption alone is not a complete recovery path. The
|
||||
restricted grant must survive an interrupted authenticator prompt yet be
|
||||
consumed in the same transaction that stores the verified replacement
|
||||
passkey and replacement code digests. `authrecovery.BeginPasskey` and
|
||||
`FinishPasskey` now provide that boundary without creating an authenticated
|
||||
session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie.
|
||||
|
||||
+13
-1
@@ -19,13 +19,14 @@ install an imagined framework lifecycle around it.
|
||||
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
|
||||
| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy |
|
||||
| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement |
|
||||
| First passkey-only owner and home organization | `bootstrap`, `authsqlite` | Root-local command, private token file, enrollment page, and owner-role policy |
|
||||
| Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention |
|
||||
|
||||
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.11
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
|
||||
go mod verify
|
||||
```
|
||||
|
||||
@@ -61,6 +62,17 @@ quietly changing identity or policy.
|
||||
|
||||
## Bootstrap an account without inventing a permanent password
|
||||
|
||||
For the first application owner, prefer `bootstrap.Start`. After explicitly
|
||||
seeding the application's access policy, it creates the active passkey-only
|
||||
user, non-personal home organization, membership, direct owner binding,
|
||||
enrollment digest, and audit events in one repository transaction. A missing
|
||||
owner role or duplicate identity rolls back every row. The application-owned
|
||||
root-local command writes the returned raw enrollment token once to an
|
||||
exclusive mode-`0600` file and must never print or log it.
|
||||
|
||||
For applications that still require a temporary password bootstrap,
|
||||
`auth.GenerateTemporaryPassword` remains available:
|
||||
|
||||
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
|
||||
entropy. An application can store that value in a newly created private file
|
||||
and provision an account with `RequirePasswordChange: true`. The library does
|
||||
|
||||
+1
-1
@@ -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.11
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
|
||||
```
|
||||
|
||||
Only imported packages are compiled and linked. The packages nevertheless
|
||||
|
||||
+28
-9
@@ -26,8 +26,11 @@ timestamp, UUID, or counter for the random challenge.
|
||||
|
||||
## Application flow
|
||||
|
||||
1. A local command calls `Bootstrap` or `Recover` and writes the returned
|
||||
enrollment token once to a newly created mode-`0600` file.
|
||||
1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or
|
||||
`bootstrap.Start` and writes the returned enrollment token once to a newly
|
||||
created mode-`0600` file. Use `bootstrap.Start` for the first application
|
||||
owner so identity, organization membership, direct owner access, and audits
|
||||
cannot be partially committed.
|
||||
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
|
||||
`navigator.credentials.create` with the returned `public_key` value.
|
||||
3. The browser posts the credential and opaque ceremony token to a bounded JSON
|
||||
@@ -53,13 +56,29 @@ JavaScript, or set sessions automatically.
|
||||
|
||||
## Recovery and credential lifecycle
|
||||
|
||||
Recovery is deliberately host-local and should never be reachable through an
|
||||
HTTP handler. It revokes all user sessions and pending ceremonies, replaces
|
||||
prior enrollment tokens, appends a secret-free audit event, and returns one
|
||||
15-minute token. It does not delete existing passkeys. After enrolling a
|
||||
replacement, the operator reviews credential labels and removes lost keys with
|
||||
a fresh passkey-bound removal ceremony. The final passkey cannot be removed
|
||||
remotely.
|
||||
Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
|
||||
must never be reachable through an HTTP handler. It revokes all user sessions
|
||||
and pending ceremonies, replaces prior enrollment tokens, appends a
|
||||
secret-free audit event, and returns one 15-minute token. It does not delete
|
||||
existing passkeys. After enrolling a replacement, the operator reviews
|
||||
credential labels and removes lost keys with a fresh passkey-bound removal
|
||||
ceremony. The final passkey cannot be removed remotely.
|
||||
|
||||
An account may separately expose self-service password-plus-recovery-code
|
||||
recovery through `authrecovery`. `Begin` verifies the password, consumes one
|
||||
printable code, revokes sessions, and returns a short-lived grant—not a normal
|
||||
session. Keep that grant in a narrowly scoped, Secure, HttpOnly, SameSite cookie
|
||||
and never place it in a URL. `BeginPasskey` binds its digest into the WebAuthn
|
||||
ceremony. `FinishPasskey` atomically consumes the grant, stores the verified
|
||||
replacement passkey, replaces the entire recovery-code set, revokes any
|
||||
sessions or ceremonies created during recovery, and returns the new plaintext
|
||||
codes exactly once. It does not issue a session; return the user to normal
|
||||
login after displaying and saving the new codes.
|
||||
|
||||
A failed storage commit leaves the restricted grant available for a fresh
|
||||
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.
|
||||
|
||||
Before enabling production mutations, applications should require at least two
|
||||
independent passkeys and complete a local recovery drill.
|
||||
|
||||
+57
-1
@@ -20,6 +20,7 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
|
||||
}
|
||||
|
||||
detected := http.DetectContentType(data)
|
||||
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
if detected == "application/pdf" && validPDF(data) {
|
||||
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
|
||||
result.Digest = sha256.Sum256(result.Data)
|
||||
return result, nil
|
||||
@@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validPDF performs a deliberately bounded structural check without trying to
|
||||
// render or interpret document content. It rejects header-only spoofing and
|
||||
// truncated uploads by requiring a supported header, terminal EOF marker, a
|
||||
// numeric startxref offset, and either a traditional xref table with trailer
|
||||
// or an xref-stream object at that offset.
|
||||
func validPDF(data []byte) bool {
|
||||
if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
return false
|
||||
}
|
||||
headerEnd := bytes.IndexAny(data, "\r\n")
|
||||
if headerEnd < 8 || headerEnd > 32 {
|
||||
return false
|
||||
}
|
||||
header := string(bytes.TrimSpace(data[:headerEnd]))
|
||||
if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" {
|
||||
return false
|
||||
}
|
||||
trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ")
|
||||
if !bytes.HasSuffix(trimmed, []byte("%%EOF")) {
|
||||
return false
|
||||
}
|
||||
eof := len(trimmed) - len("%%EOF")
|
||||
start := bytes.LastIndex(trimmed[:eof], []byte("startxref"))
|
||||
if start < headerEnd {
|
||||
return false
|
||||
}
|
||||
cursor := start + len("startxref")
|
||||
for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') {
|
||||
cursor++
|
||||
}
|
||||
digits := cursor
|
||||
for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 {
|
||||
cursor++
|
||||
}
|
||||
if cursor == digits {
|
||||
return false
|
||||
}
|
||||
if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 {
|
||||
return false
|
||||
}
|
||||
offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64)
|
||||
if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) {
|
||||
return false
|
||||
}
|
||||
target := trimmed[int(offset):start]
|
||||
if bytes.HasPrefix(target, []byte("xref")) {
|
||||
return bytes.Contains(target, []byte("trailer"))
|
||||
}
|
||||
lineEnd := bytes.IndexByte(target, '\n')
|
||||
if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) {
|
||||
return false
|
||||
}
|
||||
return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef"))
|
||||
}
|
||||
|
||||
func Extension(mediaType string) string {
|
||||
switch mediaType {
|
||||
case "image/jpeg":
|
||||
|
||||
+21
-1
@@ -5,6 +5,7 @@ package media
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
@@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPreparePDFIsAttachment(t *testing.T) {
|
||||
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
|
||||
pdf := minimalPDF()
|
||||
prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -42,6 +44,18 @@ func TestPreparePDFIsAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRejectsMalformedPDF(t *testing.T) {
|
||||
for _, source := range []string{
|
||||
"%PDF-1.7\nsmall fixture",
|
||||
"%PDF-9.9\nxref\ntrailer\nstartxref\n9\n%%EOF",
|
||||
"%PDF-1.7\nxref\ntrailer\nstartxref\n999999\n%%EOF",
|
||||
} {
|
||||
if _, err := Prepare(strings.NewReader(source), "broken.pdf", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
||||
t.Fatalf("malformed PDF error=%v source=%q", err, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
||||
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
||||
t.Fatalf("svg err=%v", err)
|
||||
@@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
||||
t.Fatalf("large err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func minimalPDF() []byte {
|
||||
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
|
||||
offset := len(prefix)
|
||||
return append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", offset))...)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package medialocal
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
|
||||
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
|
||||
pdf := append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(prefix)))...)
|
||||
prepared, err := media.Prepare(bytes.NewReader(pdf), "fixture.pdf", media.Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ authsqlite/store_test.go
|
||||
authsqlite/account.go
|
||||
authsqlite/account_test.go
|
||||
authsqlite/access.go
|
||||
authsqlite/bootstrap.go
|
||||
authsqlite/bootstrap_test.go
|
||||
authsqlite/organizations.go
|
||||
authsqlite/passkey.go
|
||||
authsqlite/passkey_test.go
|
||||
@@ -48,6 +50,8 @@ authwebauthn/fuzz_test.go
|
||||
authwebauthn/service.go
|
||||
authwebauthn/service_test.go
|
||||
authwebauthn/types.go
|
||||
bootstrap/bootstrap.go
|
||||
bootstrap/bootstrap_test.go
|
||||
media/media.go
|
||||
media/media_test.go
|
||||
medialocal/store.go
|
||||
|
||||
Reference in New Issue
Block a user