auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-08-21 17:33:00 -04:00
parent fb6bbd0dad
commit bfe6cfd29e
230 changed files with 44547 additions and 17 deletions
+320
View File
@@ -0,0 +1,320 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
const (
maxPasskeysPerUser = 16
maxCredentialBytes = 64 << 10
maxCeremonySessionBytes = 64 << 10
)
func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) error {
if !validPasskeyUser(user) || !validEnrollment(enrollment) || enrollment.UserID != user.ID || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey 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,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, error) {
if !opaqueID(userID) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
}
func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) {
identifier = strings.TrimSpace(identifier)
if !text(identifier, 320, false) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
}
func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) {
if !boundedCredentialID(credentialID) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
return user, err
}
func (store *Store) CredentialsByUserID(ctx context.Context, userID string) ([]authwebauthn.Credential, error) {
if !opaqueID(userID) {
return nil, auth.ErrUserNotFound
}
rows, err := store.db.QueryContext(ctx, `SELECT credential_id,label,credential_json,created_at,COALESCE(last_used_at,0) FROM gwf_passkey_credentials WHERE user_id=? ORDER BY created_at,credential_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
credentials := make([]authwebauthn.Credential, 0)
for rows.Next() {
var credential authwebauthn.Credential
var created, used int64
if err = rows.Scan(&credential.ID, &credential.Label, &credential.Data, &created, &used); err != nil {
return nil, err
}
credential.UserID = userID
credential.CreatedAt = time.Unix(created, 0).UTC()
if used != 0 {
credential.LastUsedAt = time.Unix(used, 0).UTC()
}
credentials = append(credentials, credential)
}
return credentials, rows.Err()
}
func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error {
if !validCredential(credential, true) || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey credential")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, credential.UserID).Scan(&count); err != nil {
return err
}
if count >= maxPasskeysPerUser {
return errors.New("authsqlite: passkey credential limit reached")
}
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, credential.UserID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UpdateCredential(ctx context.Context, credential authwebauthn.Credential) error {
if !validCredential(credential, false) || credential.LastUsedAt.IsZero() {
return errors.New("authsqlite: invalid passkey credential update")
}
result, err := store.db.ExecContext(ctx, `UPDATE gwf_passkey_credentials SET credential_json=?,last_used_at=? WHERE credential_id=? AND user_id=?`, []byte(credential.Data), credential.LastUsedAt.Unix(), credential.ID, credential.UserID)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return authwebauthn.ErrCredentialNotFound
}
return nil
}
func (store *Store) DeleteCredential(ctx context.Context, userID string, credentialID []byte, minimumRemaining int, audit auth.AuditEvent) error {
if !opaqueID(userID) || !boundedCredentialID(credentialID) || minimumRemaining < 1 || minimumRemaining > maxPasskeysPerUser || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey credential deletion")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count); err != nil {
return err
}
if count <= minimumRemaining {
if minimumRemaining == 1 {
return authwebauthn.ErrLastCredential
}
return authwebauthn.ErrCredentialFloor
}
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_passkey_credentials WHERE user_id=? AND credential_id=?`, userID, credentialID)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return authwebauthn.ErrCredentialNotFound
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) CredentialCount(ctx context.Context, userID string) (int, error) {
if !opaqueID(userID) {
return 0, auth.ErrUserNotFound
}
var count int
err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count)
return count, err
}
func (store *Store) CreateCeremony(ctx context.Context, ceremony authwebauthn.Ceremony) error {
if !validCeremony(ceremony) {
return errors.New("authsqlite: invalid passkey ceremony")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_passkey_ceremonies(token_hash,kind,user_id,label,session_json,binding_hash,created_at,expires_at) VALUES(?,?,NULLIF(?,''),?,?,?,?,?)`, ceremony.Digest[:], ceremony.Kind, ceremony.UserID, ceremony.Label, []byte(ceremony.SessionData), ceremony.BindingDigest[:], ceremony.CreatedAt.Unix(), ceremony.ExpiresAt.Unix())
return err
}
func (store *Store) TakeCeremony(ctx context.Context, digest [32]byte, now time.Time) (authwebauthn.Ceremony, error) {
if zeroDigest(digest) || now.IsZero() {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
var ceremony authwebauthn.Ceremony
var userID sql.NullString
var binding []byte
var created, expires int64
err := store.db.QueryRowContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE token_hash=? RETURNING kind,user_id,label,session_json,binding_hash,created_at,expires_at`, digest[:]).Scan(&ceremony.Kind, &userID, &ceremony.Label, &ceremony.SessionData, &binding, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
if err != nil {
return authwebauthn.Ceremony{}, err
}
ceremony.Digest = digest
ceremony.UserID = userID.String
copy(ceremony.BindingDigest[:], binding)
ceremony.CreatedAt, ceremony.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if len(binding) != sha256Size || !now.Before(ceremony.ExpiresAt) {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
return ceremony, nil
}
func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authwebauthn.ErrEnrollmentNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
var userID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], now.Unix()).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, authwebauthn.ErrEnrollmentNotFound
}
if err != nil {
return auth.User{}, err
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) (auth.User, error) {
if !text(strings.TrimSpace(identifier), 320, false) || !validEnrollment(enrollment) || !validAuditEvent(audit) {
return auth.User{}, errors.New("authsqlite: invalid passkey recovery")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
if err != nil {
return auth.User{}, err
}
if enrollment.UserID != user.ID || audit.ResourceID != user.ID {
return auth.User{}, errors.New("authsqlite: passkey recovery identity mismatch")
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], user.ID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
return auth.User{}, err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
type rowScanner interface{ Scan(...any) error }
func scanPasskeyUser(row rowScanner) (auth.User, error) {
var user auth.User
var passwordChangeRequired int
var created, updated int64
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, auth.ErrUserNotFound
}
return auth.User{}, err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, nil
}
func validPasskeyUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validEnrollment(token authwebauthn.EnrollmentToken) bool {
return !zeroDigest(token.Digest) && opaqueID(token.UserID) && !token.CreatedAt.IsZero() && token.ExpiresAt.After(token.CreatedAt)
}
func boundedCredentialID(value []byte) bool { return len(value) >= 16 && len(value) <= 1024 }
func validCredential(credential authwebauthn.Credential, requireLabel bool) bool {
return boundedCredentialID(credential.ID) && opaqueID(credential.UserID) && (!requireLabel || text(credential.Label, 80, false)) && len(credential.Data) > 0 && len(credential.Data) <= maxCredentialBytes && json.Valid(credential.Data) && (!requireLabel || !credential.CreatedAt.IsZero())
}
func validCeremony(ceremony authwebauthn.Ceremony) bool {
validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval
validUser := ceremony.Kind == authwebauthn.CeremonyLogin && ceremony.UserID == "" || opaqueID(ceremony.UserID)
validLabel := ceremony.Kind == authwebauthn.CeremonyRegistration && text(ceremony.Label, 80, false) || ceremony.Kind != authwebauthn.CeremonyRegistration && ceremony.Label == ""
zeroBinding := zeroDigest(ceremony.BindingDigest)
validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind != authwebauthn.CeremonyApproval && zeroBinding
return !zeroDigest(ceremony.Digest) && validKind && validUser && validLabel && validBinding && len(ceremony.SessionData) > 0 && len(ceremony.SessionData) <= maxCeremonySessionBytes && json.Valid(ceremony.SessionData) && !ceremony.CreatedAt.IsZero() && ceremony.ExpiresAt.After(ceremony.CreatedAt)
}
const sha256Size = 32
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
func TestPasskeyCredentialDeletionPreservesConfiguredFloor(t *testing.T) {
store, err := Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
user := auth.User{ID: "passkey-user-id", Username: "passkey.user", Email: "passkey@example.test", DisplayName: "Passkey User", Status: "active", CreatedAt: now, UpdatedAt: now}
enrollment := authwebauthn.EnrollmentToken{Digest: [32]byte{1}, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err = store.CreatePasskeyUser(t.Context(), user, enrollment, testAudit("bootstrap-audit", "auth.passkey.bootstrap", user.ID, now)); err != nil {
t.Fatal(err)
}
ids := [][]byte{bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, 32)}
for index, id := range ids {
encoded, marshalErr := json.Marshal(wa.Credential{ID: id, PublicKey: []byte{1, 2, 3}})
if marshalErr != nil {
t.Fatal(marshalErr)
}
credential := authwebauthn.Credential{ID: id, UserID: user.ID, Label: "Credential", Data: encoded, CreatedAt: now}
if err = store.SaveCredential(t.Context(), credential, testAudit("add-audit-"+string(rune('a'+index)), "auth.passkey.add", user.ID, now)); err != nil {
t.Fatal(err)
}
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[0], 2, testAudit("delete-audit", "auth.passkey.remove", user.ID, now)); err != nil {
t.Fatal(err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[1], 2, testAudit("delete-floor", "auth.passkey.remove", user.ID, now)); !errors.Is(err, authwebauthn.ErrCredentialFloor) {
t.Fatalf("credential floor err=%v", err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[1], 1, testAudit("delete-second", "auth.passkey.remove", user.ID, now)); err != nil {
t.Fatal(err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[2], 1, testAudit("delete-last", "auth.passkey.remove", user.ID, now)); !errors.Is(err, authwebauthn.ErrLastCredential) {
t.Fatalf("last credential err=%v", err)
}
}
func TestPasskeyCeremonyIsConsumedExactlyOnceConcurrently(t *testing.T) {
store, err := Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
digest := [32]byte{1, 2, 3}
if err = store.CreateCeremony(t.Context(), authwebauthn.Ceremony{Digest: digest, Kind: authwebauthn.CeremonyLogin, SessionData: []byte(`{"challenge":"example"}`), CreatedAt: now, ExpiresAt: now.Add(time.Minute)}); err != nil {
t.Fatal(err)
}
var successes atomic.Int32
unexpected := make(chan error, 16)
var group sync.WaitGroup
for range 16 {
group.Add(1)
go func() {
defer group.Done()
_, takeErr := store.TakeCeremony(t.Context(), digest, now)
if takeErr == nil {
successes.Add(1)
return
}
if !errors.Is(takeErr, authwebauthn.ErrCeremonyNotFound) {
unexpected <- takeErr
}
}()
}
group.Wait()
close(unexpected)
for value := range unexpected {
t.Fatalf("unexpected concurrent error: %v", value)
}
if successes.Load() != 1 {
t.Fatalf("successful consumes=%d", successes.Load())
}
}
func testAudit(id, action, resourceID string, now time.Time) auth.AuditEvent {
return auth.AuditEvent{ID: id, Action: action, ResourceType: "user", ResourceID: resourceID, Summary: "test", CreatedAt: now}
}
+9
View File
@@ -88,6 +88,12 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS gwf_auth_sessions_expiry ON gwf_auth_sessions(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_audit_events (id TEXT PRIMARY KEY, actor_user_id TEXT REFERENCES gwf_users(id) ON DELETE SET NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_audit_created ON gwf_audit_events(created_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_credentials (credential_id BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, credential_json BLOB NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_credentials_user ON gwf_passkey_credentials(user_id,created_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_enrollment_tokens (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_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_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, created_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`,
`CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`,
@@ -132,6 +138,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(3,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(4,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}