Add FIDO2/WebAuthn passkey authentication

Phase 14: Full WebAuthn support for passwordless passkey login and
hardware security key 2FA.

- go-webauthn/webauthn v0.16.1 dependency
- WebAuthnConfig with RPID/RPOrigin/DisplayName validation
- Migration 000009: webauthn_credentials table
- DB CRUD with ownership checks and admin operations
- internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM
- REST: register begin/finish, login begin/finish, list, delete
- Web UI: profile enrollment, login passkey button, admin management
- gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs
- mciasdb: webauthn list/delete/reset subcommands
- OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema
- Policy: self-service enrollment rule, admin remove via wildcard
- Tests: DB CRUD, adapter round-trip, interface compliance
- Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14

Security: Credential IDs and public keys encrypted at rest with
AES-256-GCM via vault master key. Challenge ceremonies use 128-bit
nonces with 120s TTL in sync.Map. Sign counter validated on each
assertion to detect cloned authenticators. Password re-auth required
for registration (SEC-01 pattern). No credential material in API
responses or logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:12:59 -07:00
parent 19fa0c9a8e
commit 25417b24f4
42 changed files with 4214 additions and 84 deletions

View File

@@ -8,18 +8,29 @@ import (
"fmt"
"net"
"os"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
// Config is the top-level configuration structure parsed from the TOML file.
type Config struct {
type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable
Server ServerConfig `toml:"server"`
MasterKey MasterKeyConfig `toml:"master_key"`
Database DatabaseConfig `toml:"database"`
Tokens TokensConfig `toml:"tokens"`
Argon2 Argon2Config `toml:"argon2"`
WebAuthn WebAuthnConfig `toml:"webauthn"`
}
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
// section disables WebAuthn support. If any field is set, RPID and RPOrigin are
// required and RPOrigin must use the HTTPS scheme.
type WebAuthnConfig struct {
RPID string `toml:"rp_id"`
RPOrigin string `toml:"rp_origin"`
DisplayName string `toml:"display_name"`
}
// ServerConfig holds HTTP listener and TLS settings.
@@ -222,6 +233,19 @@ func (c *Config) validate() error {
errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set"))
}
// WebAuthn — if any field is set, RPID and RPOrigin are required.
hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != ""
if hasWebAuthn {
if c.WebAuthn.RPID == "" {
errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured"))
}
if c.WebAuthn.RPOrigin == "" {
errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured"))
} else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") {
errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin))
}
}
return errors.Join(errs...)
}
@@ -233,3 +257,8 @@ func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Durat
// ServiceExpiry returns the configured service token expiry duration.
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
func (c *Config) WebAuthnEnabled() bool {
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
}

View File

@@ -22,7 +22,7 @@ var migrationsFS embed.FS
// LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file
// is added.
const LatestSchemaVersion = 7
const LatestSchemaVersion = 9
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
// files. It opens a dedicated *sql.DB using the same DSN as the main

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS webauthn_credentials;

View File

@@ -0,0 +1,18 @@
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
credential_id_enc BLOB NOT NULL,
credential_id_nonce BLOB NOT NULL,
public_key_enc BLOB NOT NULL,
public_key_nonce BLOB NOT NULL,
aaguid TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
discoverable INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX idx_webauthn_credentials_account_id ON webauthn_credentials(account_id);

208
internal/db/webauthn.go Normal file
View File

@@ -0,0 +1,208 @@
package db
import (
"database/sql"
"errors"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// CreateWebAuthnCredential inserts a new WebAuthn credential record.
// All encrypted fields (credential_id, public_key) must be encrypted by the caller.
func (db *DB) CreateWebAuthnCredential(cred *model.WebAuthnCredential) (int64, error) {
n := now()
result, err := db.sql.Exec(`
INSERT INTO webauthn_credentials
(account_id, name, credential_id_enc, credential_id_nonce,
public_key_enc, public_key_nonce, aaguid, sign_count,
discoverable, transports, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
cred.AccountID, cred.Name, cred.CredentialIDEnc, cred.CredentialIDNonce,
cred.PublicKeyEnc, cred.PublicKeyNonce, cred.AAGUID, cred.SignCount,
boolToInt(cred.Discoverable), cred.Transports, n, n)
if err != nil {
return 0, fmt.Errorf("db: create webauthn credential: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("db: webauthn credential last insert id: %w", err)
}
return id, nil
}
// GetWebAuthnCredentials returns all WebAuthn credentials for an account.
func (db *DB) GetWebAuthnCredentials(accountID int64) ([]*model.WebAuthnCredential, error) {
rows, err := db.sql.Query(`
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
public_key_enc, public_key_nonce, aaguid, sign_count,
discoverable, transports, created_at, updated_at, last_used_at
FROM webauthn_credentials WHERE account_id = ? ORDER BY created_at ASC`, accountID)
if err != nil {
return nil, fmt.Errorf("db: list webauthn credentials: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Close error is non-fatal
return scanWebAuthnCredentials(rows)
}
// GetWebAuthnCredentialByID returns a single WebAuthn credential by its DB row ID.
// Returns ErrNotFound if the credential does not exist.
func (db *DB) GetWebAuthnCredentialByID(id int64) (*model.WebAuthnCredential, error) {
row := db.sql.QueryRow(`
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
public_key_enc, public_key_nonce, aaguid, sign_count,
discoverable, transports, created_at, updated_at, last_used_at
FROM webauthn_credentials WHERE id = ?`, id)
return scanWebAuthnCredential(row)
}
// DeleteWebAuthnCredential deletes a WebAuthn credential by ID, verifying ownership.
// Returns ErrNotFound if the credential does not exist or does not belong to the account.
func (db *DB) DeleteWebAuthnCredential(id, accountID int64) error {
result, err := db.sql.Exec(
`DELETE FROM webauthn_credentials WHERE id = ? AND account_id = ?`, id, accountID)
if err != nil {
return fmt.Errorf("db: delete webauthn credential: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: webauthn delete rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// DeleteWebAuthnCredentialAdmin deletes a WebAuthn credential by ID without ownership check.
func (db *DB) DeleteWebAuthnCredentialAdmin(id int64) error {
result, err := db.sql.Exec(`DELETE FROM webauthn_credentials WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("db: admin delete webauthn credential: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: webauthn admin delete rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// DeleteAllWebAuthnCredentials removes all WebAuthn credentials for an account.
func (db *DB) DeleteAllWebAuthnCredentials(accountID int64) (int64, error) {
result, err := db.sql.Exec(
`DELETE FROM webauthn_credentials WHERE account_id = ?`, accountID)
if err != nil {
return 0, fmt.Errorf("db: delete all webauthn credentials: %w", err)
}
return result.RowsAffected()
}
// UpdateWebAuthnSignCount updates the sign counter for a credential.
func (db *DB) UpdateWebAuthnSignCount(id int64, signCount uint32) error {
_, err := db.sql.Exec(
`UPDATE webauthn_credentials SET sign_count = ?, updated_at = ? WHERE id = ?`,
signCount, now(), id)
if err != nil {
return fmt.Errorf("db: update webauthn sign count: %w", err)
}
return nil
}
// UpdateWebAuthnLastUsed sets the last_used_at timestamp for a credential.
func (db *DB) UpdateWebAuthnLastUsed(id int64) error {
_, err := db.sql.Exec(
`UPDATE webauthn_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?`,
now(), now(), id)
if err != nil {
return fmt.Errorf("db: update webauthn last used: %w", err)
}
return nil
}
// HasWebAuthnCredentials reports whether the account has any WebAuthn credentials.
func (db *DB) HasWebAuthnCredentials(accountID int64) (bool, error) {
var count int
err := db.sql.QueryRow(
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: count webauthn credentials: %w", err)
}
return count > 0, nil
}
// CountWebAuthnCredentials returns the number of WebAuthn credentials for an account.
func (db *DB) CountWebAuthnCredentials(accountID int64) (int, error) {
var count int
err := db.sql.QueryRow(
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("db: count webauthn credentials: %w", err)
}
return count, nil
}
// boolToInt converts a bool to 0/1 for SQLite storage.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func scanWebAuthnCredentials(rows *sql.Rows) ([]*model.WebAuthnCredential, error) {
var creds []*model.WebAuthnCredential
for rows.Next() {
cred, err := scanWebAuthnRow(rows)
if err != nil {
return nil, err
}
creds = append(creds, cred)
}
return creds, rows.Err()
}
// scannable is implemented by both *sql.Row and *sql.Rows.
type scannable interface {
Scan(dest ...any) error
}
func scanWebAuthnRow(s scannable) (*model.WebAuthnCredential, error) {
var cred model.WebAuthnCredential
var createdAt, updatedAt string
var lastUsedAt *string
var discoverable int
err := s.Scan(
&cred.ID, &cred.AccountID, &cred.Name,
&cred.CredentialIDEnc, &cred.CredentialIDNonce,
&cred.PublicKeyEnc, &cred.PublicKeyNonce,
&cred.AAGUID, &cred.SignCount,
&discoverable, &cred.Transports,
&createdAt, &updatedAt, &lastUsedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("db: scan webauthn credential: %w", err)
}
cred.Discoverable = discoverable != 0
cred.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
}
cred.UpdatedAt, err = parseTime(updatedAt)
if err != nil {
return nil, err
}
cred.LastUsedAt, err = nullableTime(lastUsedAt)
if err != nil {
return nil, err
}
return &cred, nil
}
func scanWebAuthnCredential(row *sql.Row) (*model.WebAuthnCredential, error) {
return scanWebAuthnRow(row)
}

View File

@@ -0,0 +1,251 @@
package db
import (
"errors"
"testing"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
func TestWebAuthnCRUD(t *testing.T) {
database := openTestDB(t)
acct, err := database.CreateAccount("webauthnuser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
// Empty state.
has, err := database.HasWebAuthnCredentials(acct.ID)
if err != nil {
t.Fatalf("has credentials: %v", err)
}
if has {
t.Error("expected no credentials")
}
count, err := database.CountWebAuthnCredentials(acct.ID)
if err != nil {
t.Fatalf("count credentials: %v", err)
}
if count != 0 {
t.Errorf("expected 0 credentials, got %d", count)
}
creds, err := database.GetWebAuthnCredentials(acct.ID)
if err != nil {
t.Fatalf("get credentials (empty): %v", err)
}
if len(creds) != 0 {
t.Errorf("expected 0 credentials, got %d", len(creds))
}
// Create credential.
cred := &model.WebAuthnCredential{
AccountID: acct.ID,
Name: "Test Key",
CredentialIDEnc: []byte("enc-cred-id"),
CredentialIDNonce: []byte("nonce-cred-id"),
PublicKeyEnc: []byte("enc-pubkey"),
PublicKeyNonce: []byte("nonce-pubkey"),
AAGUID: "2fc0579f811347eab116bb5a8db9202a",
SignCount: 0,
Discoverable: true,
Transports: "usb,nfc",
}
id, err := database.CreateWebAuthnCredential(cred)
if err != nil {
t.Fatalf("create credential: %v", err)
}
if id == 0 {
t.Error("expected non-zero credential ID")
}
// Now has credentials.
has, err = database.HasWebAuthnCredentials(acct.ID)
if err != nil {
t.Fatalf("has credentials after create: %v", err)
}
if !has {
t.Error("expected credentials to exist")
}
count, err = database.CountWebAuthnCredentials(acct.ID)
if err != nil {
t.Fatalf("count after create: %v", err)
}
if count != 1 {
t.Errorf("expected 1 credential, got %d", count)
}
// Get by ID.
got, err := database.GetWebAuthnCredentialByID(id)
if err != nil {
t.Fatalf("get by ID: %v", err)
}
if got.Name != "Test Key" {
t.Errorf("Name = %q, want %q", got.Name, "Test Key")
}
if !got.Discoverable {
t.Error("expected discoverable=true")
}
if got.Transports != "usb,nfc" {
t.Errorf("Transports = %q, want %q", got.Transports, "usb,nfc")
}
if got.AccountID != acct.ID {
t.Errorf("AccountID = %d, want %d", got.AccountID, acct.ID)
}
// Get list.
creds, err = database.GetWebAuthnCredentials(acct.ID)
if err != nil {
t.Fatalf("get credentials: %v", err)
}
if len(creds) != 1 {
t.Fatalf("expected 1 credential, got %d", len(creds))
}
if creds[0].ID != id {
t.Errorf("credential ID = %d, want %d", creds[0].ID, id)
}
// Update sign count.
if err := database.UpdateWebAuthnSignCount(id, 5); err != nil {
t.Fatalf("update sign count: %v", err)
}
got, _ = database.GetWebAuthnCredentialByID(id)
if got.SignCount != 5 {
t.Errorf("SignCount = %d, want 5", got.SignCount)
}
// Update last used.
if err := database.UpdateWebAuthnLastUsed(id); err != nil {
t.Fatalf("update last used: %v", err)
}
got, _ = database.GetWebAuthnCredentialByID(id)
if got.LastUsedAt == nil {
t.Error("expected LastUsedAt to be set")
}
}
func TestWebAuthnDeleteOwnership(t *testing.T) {
database := openTestDB(t)
acct1, _ := database.CreateAccount("wa1", model.AccountTypeHuman, "hash")
acct2, _ := database.CreateAccount("wa2", model.AccountTypeHuman, "hash")
cred := &model.WebAuthnCredential{
AccountID: acct1.ID,
Name: "Key",
CredentialIDEnc: []byte("enc"),
CredentialIDNonce: []byte("nonce"),
PublicKeyEnc: []byte("enc"),
PublicKeyNonce: []byte("nonce"),
}
id, _ := database.CreateWebAuthnCredential(cred)
// Delete with wrong owner should fail.
err := database.DeleteWebAuthnCredential(id, acct2.ID)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound for wrong owner, got %v", err)
}
// Delete with correct owner succeeds.
if err := database.DeleteWebAuthnCredential(id, acct1.ID); err != nil {
t.Fatalf("delete with correct owner: %v", err)
}
// Verify gone.
_, err = database.GetWebAuthnCredentialByID(id)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
}
func TestWebAuthnDeleteAdmin(t *testing.T) {
database := openTestDB(t)
acct, _ := database.CreateAccount("waadmin", model.AccountTypeHuman, "hash")
cred := &model.WebAuthnCredential{
AccountID: acct.ID,
Name: "Key",
CredentialIDEnc: []byte("enc"),
CredentialIDNonce: []byte("nonce"),
PublicKeyEnc: []byte("enc"),
PublicKeyNonce: []byte("nonce"),
}
id, _ := database.CreateWebAuthnCredential(cred)
// Admin delete (no ownership check).
if err := database.DeleteWebAuthnCredentialAdmin(id); err != nil {
t.Fatalf("admin delete: %v", err)
}
// Non-existent should return ErrNotFound.
if err := database.DeleteWebAuthnCredentialAdmin(id); !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound for non-existent, got %v", err)
}
}
func TestWebAuthnDeleteAll(t *testing.T) {
database := openTestDB(t)
acct, _ := database.CreateAccount("wada", model.AccountTypeHuman, "hash")
for i := range 3 {
cred := &model.WebAuthnCredential{
AccountID: acct.ID,
Name: "Key",
CredentialIDEnc: []byte{byte(i)},
CredentialIDNonce: []byte("n"),
PublicKeyEnc: []byte{byte(i)},
PublicKeyNonce: []byte("n"),
}
if _, err := database.CreateWebAuthnCredential(cred); err != nil {
t.Fatalf("create %d: %v", i, err)
}
}
deleted, err := database.DeleteAllWebAuthnCredentials(acct.ID)
if err != nil {
t.Fatalf("delete all: %v", err)
}
if deleted != 3 {
t.Errorf("expected 3 deleted, got %d", deleted)
}
count, _ := database.CountWebAuthnCredentials(acct.ID)
if count != 0 {
t.Errorf("expected 0 after delete all, got %d", count)
}
}
func TestWebAuthnCascadeDelete(t *testing.T) {
database := openTestDB(t)
acct, _ := database.CreateAccount("wacascade", model.AccountTypeHuman, "hash")
cred := &model.WebAuthnCredential{
AccountID: acct.ID,
Name: "Key",
CredentialIDEnc: []byte("enc"),
CredentialIDNonce: []byte("nonce"),
PublicKeyEnc: []byte("enc"),
PublicKeyNonce: []byte("nonce"),
}
id, _ := database.CreateWebAuthnCredential(cred)
// Delete the account — credentials should cascade.
if err := database.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
t.Fatalf("update status: %v", err)
}
// The credential should still be retrievable (soft delete on account doesn't cascade).
// But if we hard-delete via SQL, the FK cascade should clean up.
// For now just verify the credential still exists after a status change.
got, err := database.GetWebAuthnCredentialByID(id)
if err != nil {
t.Fatalf("get after account status change: %v", err)
}
if got.ID != id {
t.Errorf("credential ID = %d, want %d", got.ID, id)
}
}

View File

@@ -0,0 +1,92 @@
// WebAuthn gRPC handlers for listing and removing WebAuthn credentials.
// These are admin-only operations that mirror the REST handlers in
// internal/server/handlers_webauthn.go.
package grpcserver
import (
"context"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
// Requires: admin JWT in metadata.
//
// Security: credential material (IDs, public keys) is never included in the
// response — only metadata (name, sign count, timestamps, etc.).
func (a *authServiceServer) ListWebAuthnCredentials(ctx context.Context, req *mciasv1.ListWebAuthnCredentialsRequest) (*mciasv1.ListWebAuthnCredentialsResponse, error) {
if err := a.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.AccountId == "" {
return nil, status.Error(codes.InvalidArgument, "account_id is required")
}
acct, err := a.s.db.GetAccountByUUID(req.AccountId)
if err != nil {
return nil, status.Error(codes.NotFound, "account not found")
}
creds, err := a.s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
a.s.logger.Error("list webauthn credentials", "error", err, "account_id", acct.ID)
return nil, status.Error(codes.Internal, "internal error")
}
resp := &mciasv1.ListWebAuthnCredentialsResponse{
Credentials: make([]*mciasv1.WebAuthnCredentialInfo, 0, len(creds)),
}
for _, c := range creds {
info := &mciasv1.WebAuthnCredentialInfo{
Id: c.ID,
Name: c.Name,
Aaguid: c.AAGUID,
SignCount: c.SignCount,
Discoverable: c.Discoverable,
Transports: c.Transports,
CreatedAt: timestamppb.New(c.CreatedAt),
}
if c.LastUsedAt != nil {
info.LastUsedAt = timestamppb.New(*c.LastUsedAt)
}
resp.Credentials = append(resp.Credentials, info)
}
return resp, nil
}
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
// Requires: admin JWT in metadata.
func (a *authServiceServer) RemoveWebAuthnCredential(ctx context.Context, req *mciasv1.RemoveWebAuthnCredentialRequest) (*mciasv1.RemoveWebAuthnCredentialResponse, error) {
if err := a.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.AccountId == "" {
return nil, status.Error(codes.InvalidArgument, "account_id is required")
}
if req.CredentialId == 0 {
return nil, status.Error(codes.InvalidArgument, "credential_id is required")
}
acct, err := a.s.db.GetAccountByUUID(req.AccountId)
if err != nil {
return nil, status.Error(codes.NotFound, "account not found")
}
// DeleteWebAuthnCredentialAdmin bypasses ownership checks (admin operation).
if err := a.s.db.DeleteWebAuthnCredentialAdmin(req.CredentialId); err != nil {
a.s.logger.Error("delete webauthn credential", "error", err, "credential_id", req.CredentialId)
return nil, status.Error(codes.Internal, "internal error")
}
a.s.db.WriteAuditEvent(model.EventWebAuthnRemoved, nil, &acct.ID, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"credential_id":%d}`, req.CredentialId))
return &mciasv1.RemoveWebAuthnCredentialResponse{}, nil
}

View File

@@ -213,6 +213,11 @@ const (
EventTokenDelegateGranted = "token_delegate_granted"
EventTokenDelegateRevoked = "token_delegate_revoked"
EventWebAuthnEnrolled = "webauthn_enrolled"
EventWebAuthnRemoved = "webauthn_removed"
EventWebAuthnLoginOK = "webauthn_login_ok"
EventWebAuthnLoginFail = "webauthn_login_fail"
)
// ServiceAccountDelegate records that a specific account has been granted
@@ -229,6 +234,26 @@ type ServiceAccountDelegate struct {
GranteeID int64 `json:"-"`
}
// WebAuthnCredential holds a stored WebAuthn/passkey credential.
// Credential IDs and public keys are encrypted at rest with AES-256-GCM;
// decrypted values must never be logged or included in API responses.
type WebAuthnCredential struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Name string `json:"name"`
AAGUID string `json:"aaguid"`
Transports string `json:"transports,omitempty"`
CredentialIDEnc []byte `json:"-"`
CredentialIDNonce []byte `json:"-"`
PublicKeyEnc []byte `json:"-"`
PublicKeyNonce []byte `json:"-"`
ID int64 `json:"id"`
AccountID int64 `json:"-"`
SignCount uint32 `json:"sign_count"`
Discoverable bool `json:"discoverable"`
}
// PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns.

View File

@@ -81,6 +81,16 @@ var defaultRules = []Rule{
OwnerMatchesSubject: true,
Effect: Allow,
},
{
// Self-service WebAuthn enrollment: any authenticated human account may
// register and manage their own passkeys/security keys. The handler
// verifies the subject matches before writing. Mirrors TOTP rule -3.
ID: -8,
Description: "Self-service: any principal may enroll their own WebAuthn credentials",
Priority: 0,
Actions: []Action{ActionEnrollWebAuthn},
Effect: Allow,
},
{
// Public endpoints: token validation and login do not require
// authentication. The middleware exempts them from RequireAuth entirely;

View File

@@ -48,6 +48,9 @@ const (
ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage"
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
ActionRemoveWebAuthn Action = "webauthn:remove" // admin
)
// ResourceType identifies what kind of object a request targets.
@@ -60,6 +63,7 @@ const (
ResourceAuditLog ResourceType = "audit_log"
ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy"
ResourceWebAuthn ResourceType = "webauthn"
)
// Effect is the outcome of policy evaluation.

View File

@@ -0,0 +1,741 @@
// Package server: WebAuthn/passkey REST API handlers.
//
// Security design:
// - Registration requires re-authentication (current password) to prevent a
// stolen session token from enrolling attacker-controlled credentials.
// - Challenge sessions are stored in a sync.Map with a 120-second TTL and are
// single-use (deleted on consumption) to prevent replay attacks.
// - All credential material (IDs, public keys) is encrypted at rest with
// AES-256-GCM via the vault master key.
// - Sign counter validation detects cloned authenticators.
// - Login endpoints return generic errors to prevent credential enumeration.
package server
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
)
const (
webauthnCeremonyTTL = 120 * time.Second
webauthnCleanupPeriod = 5 * time.Minute
webauthnCeremonyNonce = 16 // 128 bits of entropy
)
// webauthnCeremony holds a pending registration or login ceremony.
type webauthnCeremony struct {
expiresAt time.Time
session *libwebauthn.SessionData
accountID int64 // 0 for discoverable login
}
// pendingWebAuthnCeremonies is the package-level ceremony store.
// Stored on the Server struct would require adding fields; using a
// package-level map is consistent with the TOTP/token pattern from the UI.
var pendingWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
func init() {
go cleanupWebAuthnCeremonies()
}
func cleanupWebAuthnCeremonies() {
ticker := time.NewTicker(webauthnCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingWebAuthnCeremonies.Range(func(key, value any) bool {
c, ok := value.(*webauthnCeremony)
if !ok || now.After(c.expiresAt) {
pendingWebAuthnCeremonies.Delete(key)
}
return true
})
}
}
func storeWebAuthnCeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
raw, err := crypto.RandomBytes(webauthnCeremonyNonce)
if err != nil {
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
}
nonce := fmt.Sprintf("%x", raw)
pendingWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
session: session,
accountID: accountID,
expiresAt: time.Now().Add(webauthnCeremonyTTL),
})
return nonce, nil
}
func consumeWebAuthnCeremony(nonce string) (*webauthnCeremony, bool) {
v, ok := pendingWebAuthnCeremonies.LoadAndDelete(nonce)
if !ok {
return nil, false
}
c, ok2 := v.(*webauthnCeremony)
if !ok2 || time.Now().After(c.expiresAt) {
return nil, false
}
return c, true
}
// ---- Registration ----
type webauthnRegisterBeginRequest struct {
Password string `json:"password"`
Name string `json:"name"`
}
type webauthnRegisterBeginResponse struct {
Nonce string `json:"nonce"`
Options json.RawMessage `json:"options"`
}
// handleWebAuthnRegisterBegin starts a WebAuthn credential registration ceremony.
//
// Security (SEC-01): the current password is required to prevent a stolen
// session from enrolling attacker-controlled credentials.
func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
var req webauthnRegisterBeginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Password == "" {
middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request")
return
}
// Security: check lockout before password verification.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (WebAuthn register)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
return
}
// Security: verify current password with constant-time Argon2id.
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = s.db.RecordLoginFailure(acct.ID)
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized")
return
}
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
// Load existing credentials to exclude them from registration.
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
s.logger.Error("load webauthn credentials", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
s.logger.Error("decrypt webauthn credentials", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
s.logger.Error("create webauthn instance", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
creation, session, err := wa.BeginRegistration(user,
libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()),
libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
)
if err != nil {
s.logger.Error("begin webauthn registration", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
nonce, err := storeWebAuthnCeremony(session, acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
optionsJSON, err := json.Marshal(creation)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, webauthnRegisterBeginResponse{
Options: optionsJSON,
Nonce: nonce,
})
}
// handleWebAuthnRegisterFinish completes WebAuthn credential registration.
func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
// Read the raw body so we can extract the nonce and also pass
// the credential response to the library via a reconstructed request.
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
bodyBytes, err := readAllBody(r)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
return
}
// Extract nonce and name from the wrapper.
var wrapper struct {
Nonce string `json:"nonce"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
return
}
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
if !ok {
middleware.WriteError(w, http.StatusBadRequest, "ceremony expired or invalid", "bad_request")
return
}
if ceremony.accountID != acct.ID {
middleware.WriteError(w, http.StatusForbidden, "ceremony mismatch", "forbidden")
return
}
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
// Build a fake http.Request from the credential JSON for the library.
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
fakeReq.Header.Set("Content-Type", "application/json")
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
if err != nil {
s.logger.Error("finish webauthn registration", "error", err)
middleware.WriteError(w, http.StatusBadRequest, "registration failed", "bad_request")
return
}
// Determine if the credential is discoverable based on the flags.
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
name := wrapper.Name
if name == "" {
name = "Passkey"
}
// Encrypt and store the credential.
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
modelCred.AccountID = acct.ID
credID, err := s.db.CreateWebAuthnCredential(modelCred)
if err != nil {
s.logger.Error("store webauthn credential", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": credID,
"name": name,
})
}
// ---- Login ----
type webauthnLoginBeginRequest struct {
Username string `json:"username,omitempty"`
}
type webauthnLoginBeginResponse struct {
Nonce string `json:"nonce"`
Options json.RawMessage `json:"options"`
}
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony.
// If username is provided, loads that account's credentials (non-discoverable flow).
// If empty, starts a discoverable login.
func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
var req webauthnLoginBeginRequest
if !decodeJSON(w, r, &req) {
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
s.logger.Error("create webauthn instance", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
var (
assertion *protocol.CredentialAssertion
session *libwebauthn.SessionData
accountID int64
)
if req.Username != "" {
// Non-discoverable flow: load account credentials.
acct, lookupErr := s.db.GetAccountByUsername(req.Username)
if lookupErr != nil || acct.Status != model.AccountStatusActive {
// Security: return a valid-looking response even for unknown users
// to prevent username enumeration. Use discoverable login as a dummy.
assertion, session, err = wa.BeginDiscoverableLogin()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
} else {
// Check lockout.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (WebAuthn login)", "error", lockErr)
}
if locked {
// Return discoverable login as dummy to avoid enumeration.
assertion, session, err = wa.BeginDiscoverableLogin()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
} else {
masterKey, mkErr := s.vault.MasterKey()
if mkErr != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
dbCreds, dbErr := s.db.GetWebAuthnCredentials(acct.ID)
if dbErr != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if len(dbCreds) == 0 {
middleware.WriteError(w, http.StatusBadRequest, "no WebAuthn credentials registered", "no_credentials")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
assertion, session, err = wa.BeginLogin(user)
if err != nil {
s.logger.Error("begin webauthn login", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
accountID = acct.ID
}
}
} else {
// Discoverable login (passkey).
assertion, session, err = wa.BeginDiscoverableLogin()
if err != nil {
s.logger.Error("begin discoverable webauthn login", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
}
nonce, err := storeWebAuthnCeremony(session, accountID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
optionsJSON, err := json.Marshal(assertion)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, webauthnLoginBeginResponse{
Options: optionsJSON,
Nonce: nonce,
})
}
// handleWebAuthnLoginFinish completes a WebAuthn login ceremony and issues a JWT.
func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
bodyBytes, err := readAllBody(r)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
return
}
var wrapper struct {
Nonce string `json:"nonce"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
return
}
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
if !ok {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
fakeReq.Header.Set("Content-Type", "application/json")
var (
acct *model.Account
cred *libwebauthn.Credential
dbCreds []*model.WebAuthnCredential
)
if ceremony.accountID != 0 {
// Non-discoverable: we know the account.
acct, err = s.db.GetAccountByID(ceremony.accountID)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
dbCreds, err = s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
if err != nil {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
} else {
// Discoverable login: the library resolves the user from the credential.
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
// userHandle is the WebAuthnID we set (account UUID as bytes).
acctUUID := string(userHandle)
foundAcct, lookupErr := s.db.GetAccountByUUID(acctUUID)
if lookupErr != nil {
return nil, fmt.Errorf("account not found")
}
if foundAcct.Status != model.AccountStatusActive {
return nil, fmt.Errorf("account inactive")
}
acct = foundAcct
foundDBCreds, credErr := s.db.GetWebAuthnCredentials(foundAcct.ID)
if credErr != nil {
return nil, fmt.Errorf("load credentials: %w", credErr)
}
dbCreds = foundDBCreds
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds)
if decErr != nil {
return nil, fmt.Errorf("decrypt credentials: %w", decErr)
}
return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil
}
cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq)
if err != nil {
s.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
if acct == nil {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
// Security: check account status and lockout.
if acct.Status != model.AccountStatusActive {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (WebAuthn login finish)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
// Security: validate sign counter to detect cloned authenticators.
// Find the matching DB credential to update.
var matchedDBCred *model.WebAuthnCredential
for _, dc := range dbCreds {
decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc)
if decErr != nil {
continue
}
if bytes.Equal(decrypted.ID, cred.ID) {
matchedDBCred = dc
break
}
}
if matchedDBCred != nil {
// Security: reject sign counter rollback (cloned authenticator detection).
// If both are 0, the authenticator doesn't support counters — allow it.
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
audit.JSON("reason", "counter_rollback",
"expected_gt", fmt.Sprintf("%d", matchedDBCred.SignCount),
"got", fmt.Sprintf("%d", cred.Authenticator.SignCount)))
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
// Update sign count and last used.
_ = s.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
_ = s.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
}
// Login succeeded: clear lockout counter.
_ = s.db.ClearLoginFailures(acct.ID)
// Issue JWT.
roles, err := s.db.GetRoles(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
expiry := s.cfg.DefaultExpiry()
for _, role := range roles {
if role == "admin" {
expiry = s.cfg.AdminExpiry()
break
}
}
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
tokenStr, tokenClaims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", tokenClaims.JTI, "via", "webauthn"))
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
ExpiresAt: tokenClaims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}
// ---- Credential management ----
type webauthnCredentialView struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
Name string `json:"name"`
AAGUID string `json:"aaguid"`
Transports string `json:"transports,omitempty"`
ID int64 `json:"id"`
SignCount uint32 `json:"sign_count"`
Discoverable bool `json:"discoverable"`
}
// handleListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
func (s *Server) handleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
creds, err := s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
views := make([]webauthnCredentialView, 0, len(creds))
for _, c := range creds {
v := webauthnCredentialView{
ID: c.ID,
Name: c.Name,
AAGUID: c.AAGUID,
SignCount: c.SignCount,
Discoverable: c.Discoverable,
Transports: c.Transports,
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
if c.LastUsedAt != nil {
v.LastUsedAt = c.LastUsedAt.Format("2006-01-02T15:04:05Z")
}
views = append(views, v)
}
writeJSON(w, http.StatusOK, views)
}
// handleDeleteWebAuthnCredential removes a specific WebAuthn credential.
func (s *Server) handleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
credIDStr := r.PathValue("credentialId")
credID, err := strconv.ParseInt(credIDStr, 10, 64)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid credential ID", "bad_request")
return
}
if err := s.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
middleware.WriteError(w, http.StatusNotFound, "credential not found", "not_found")
return
}
s.writeAudit(r, model.EventWebAuthnRemoved, nil, &acct.ID,
audit.JSON("credential_id", credIDStr))
w.WriteHeader(http.StatusNoContent)
}
// readAllBody reads the entire request body and returns it as a byte slice.
func readAllBody(r *http.Request) ([]byte, error) {
var buf bytes.Buffer
_, err := buf.ReadFrom(r.Body)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View File

@@ -308,6 +308,13 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
// WebAuthn registration endpoints (require valid token; self-service).
mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin)))
mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish)))
// WebAuthn login endpoints (public, rate-limited).
mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin)))
mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish)))
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
mux.Handle("DELETE /v1/auth/totp",
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
@@ -338,6 +345,11 @@ func (s *Server) Handler() http.Handler {
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
// WebAuthn credential management (policy-gated).
mux.Handle("GET /v1/accounts/{id}/webauthn",
requirePolicy(policy.ActionReadAccount, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleListWebAuthnCredentials)))
mux.Handle("DELETE /v1/accounts/{id}/webauthn/{credentialId}",
requirePolicy(policy.ActionRemoveWebAuthn, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleDeleteWebAuthnCredential)))
mux.Handle("GET /v1/audit",
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags",

View File

@@ -197,6 +197,15 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
}
}
// Load WebAuthn credentials for the account detail page.
var webAuthnCreds []*model.WebAuthnCredential
if u.cfg.WebAuthnEnabled() {
webAuthnCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
u.logger.Warn("load webauthn credentials", "error", err)
}
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Account: acct,
@@ -211,6 +220,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
TokenDelegates: tokenDelegates,
DelegatableAccounts: delegatableAccounts,
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
WebAuthnCreds: webAuthnCreds,
DeletePrefix: "/accounts/" + acct.UUID + "/webauthn",
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}

View File

@@ -13,7 +13,9 @@ import (
// handleLoginPage renders the login form.
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{})
u.render(w, "login", LoginData{
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
@@ -290,13 +292,30 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
// handleProfilePage renders the profile page for the currently logged-in user.
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "profile", ProfileData{
claims := claimsFromContext(r.Context())
data := ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
})
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
DeletePrefix: "/profile/webauthn",
}
// Load WebAuthn credentials for the profile page.
if u.cfg.WebAuthnEnabled() && claims != nil {
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
creds, err := u.db.GetWebAuthnCredentials(acct.ID)
if err == nil {
data.WebAuthnCreds = creds
}
}
}
u.render(w, "profile", data)
}
// handleSelfChangePassword allows an authenticated human user to change their

View File

@@ -0,0 +1,696 @@
package ui
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
)
const (
webauthnCeremonyTTL = 120 * time.Second
webauthnCleanupPeriod = 5 * time.Minute
webauthnNonceBytes = 16
)
// webauthnCeremony holds a pending WebAuthn ceremony.
type webauthnCeremony struct {
expiresAt time.Time
session *libwebauthn.SessionData
accountID int64
}
// pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI.
var pendingUIWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
func init() {
go cleanupUIWebAuthnCeremonies()
}
func cleanupUIWebAuthnCeremonies() {
ticker := time.NewTicker(webauthnCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingUIWebAuthnCeremonies.Range(func(key, value any) bool {
c, ok := value.(*webauthnCeremony)
if !ok || now.After(c.expiresAt) {
pendingUIWebAuthnCeremonies.Delete(key)
}
return true
})
}
}
func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
raw, err := crypto.RandomBytes(webauthnNonceBytes)
if err != nil {
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
}
nonce := fmt.Sprintf("%x", raw)
pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
session: session,
accountID: accountID,
expiresAt: time.Now().Add(webauthnCeremonyTTL),
})
return nonce, nil
}
func consumeUICeremony(nonce string) (*webauthnCeremony, bool) {
v, ok := pendingUIWebAuthnCeremonies.LoadAndDelete(nonce)
if !ok {
return nil, false
}
c, ok2 := v.(*webauthnCeremony)
if !ok2 || time.Now().After(c.expiresAt) {
return nil, false
}
return c, true
}
// ---- Profile: registration ----
// handleWebAuthnBegin starts a WebAuthn credential registration ceremony.
func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
u.renderError(w, r, http.StatusNotFound, "WebAuthn not configured")
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusUnauthorized, "account not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var req struct {
Password string `json:"password"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid request")
return
}
if req.Password == "" {
writeJSONError(w, http.StatusBadRequest, "password is required")
return
}
// Security: check lockout.
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (WebAuthn enroll)", "error", lockErr)
}
if locked {
writeJSONError(w, http.StatusTooManyRequests, "account temporarily locked")
return
}
// Security: verify current password.
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = u.db.RecordLoginFailure(acct.ID)
writeJSONError(w, http.StatusUnauthorized, "password is incorrect")
return
}
masterKey, err := u.vault.MasterKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
u.logger.Error("create webauthn instance", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
creation, session, err := wa.BeginRegistration(user,
libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()),
libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
)
if err != nil {
u.logger.Error("begin webauthn registration", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
nonce, err := storeUICeremony(session, acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
optionsJSON, _ := json.Marshal(creation)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"options": json.RawMessage(optionsJSON),
"nonce": nonce,
})
}
// handleWebAuthnFinish completes WebAuthn credential registration.
func (u *UIServer) handleWebAuthnFinish(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "account not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var buf bytes.Buffer
if _, err := buf.ReadFrom(r.Body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
var wrapper struct {
Nonce string `json:"nonce"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
return
}
ceremony, ok := consumeUICeremony(wrapper.Nonce)
if !ok {
writeJSONError(w, http.StatusBadRequest, "ceremony expired or invalid")
return
}
if ceremony.accountID != acct.ID {
writeJSONError(w, http.StatusForbidden, "ceremony mismatch")
return
}
masterKey, err := u.vault.MasterKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
fakeReq.Header.Set("Content-Type", "application/json")
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
if err != nil {
u.logger.Error("finish webauthn registration", "error", err)
writeJSONError(w, http.StatusBadRequest, "registration failed")
return
}
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
name := wrapper.Name
if name == "" {
name = "Passkey"
}
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
modelCred.AccountID = acct.ID
credID, err := u.db.CreateWebAuthnCredential(modelCred)
if err != nil {
u.logger.Error("store webauthn credential", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
u.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": credID,
"name": name,
})
}
// handleWebAuthnDelete removes a WebAuthn credential from the profile page.
func (u *UIServer) handleWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusUnauthorized, "account not found")
return
}
credIDStr := r.PathValue("id")
credID, err := strconv.ParseInt(credIDStr, 10, 64)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
return
}
if err := u.db.DeleteWebAuthnCredential(credID, acct.ID); err != nil {
u.renderError(w, r, http.StatusNotFound, "credential not found")
return
}
u.writeAudit(r, model.EventWebAuthnRemoved, &acct.ID, &acct.ID,
audit.JSON("credential_id", credIDStr))
// Return updated credentials list fragment.
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "webauthn_credentials", ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
WebAuthnCreds: creds,
DeletePrefix: "/profile/webauthn",
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
// ---- Login: WebAuthn ----
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony from the UI.
func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
var (
assertion *protocol.CredentialAssertion
session *libwebauthn.SessionData
accountID int64
)
if req.Username != "" {
acct, lookupErr := u.db.GetAccountByUsername(req.Username)
if lookupErr != nil || acct.Status != model.AccountStatusActive {
// Security: return discoverable login as dummy for unknown users.
assertion, session, err = wa.BeginDiscoverableLogin()
} else {
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (WebAuthn UI login)", "error", lockErr)
}
if locked {
assertion, session, err = wa.BeginDiscoverableLogin()
} else {
masterKey, mkErr := u.vault.MasterKey()
if mkErr != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
dbCreds, dbErr := u.db.GetWebAuthnCredentials(acct.ID)
if dbErr != nil || len(dbCreds) == 0 {
writeJSONError(w, http.StatusBadRequest, "no passkeys registered")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
assertion, session, err = wa.BeginLogin(user)
accountID = acct.ID
}
}
} else {
assertion, session, err = wa.BeginDiscoverableLogin()
}
if err != nil {
u.logger.Error("begin webauthn login", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
nonce, err := storeUICeremony(session, accountID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
optionsJSON, _ := json.Marshal(assertion)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"options": json.RawMessage(optionsJSON),
"nonce": nonce,
})
}
// handleWebAuthnLoginFinish completes a WebAuthn login from the UI.
func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var buf bytes.Buffer
if _, err := buf.ReadFrom(r.Body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
var wrapper struct {
Nonce string `json:"nonce"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
return
}
ceremony, ok := consumeUICeremony(wrapper.Nonce)
if !ok {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
masterKey, err := u.vault.MasterKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
fakeReq.Header.Set("Content-Type", "application/json")
var (
acct *model.Account
cred *libwebauthn.Credential
dbCreds []*model.WebAuthnCredential
)
if ceremony.accountID != 0 {
acct, err = u.db.GetAccountByID(ceremony.accountID)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
dbCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
if err != nil {
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
_ = u.db.RecordLoginFailure(acct.ID)
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
} else {
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
acctUUID := string(userHandle)
foundAcct, lookupErr := u.db.GetAccountByUUID(acctUUID)
if lookupErr != nil {
return nil, fmt.Errorf("account not found")
}
if foundAcct.Status != model.AccountStatusActive {
return nil, fmt.Errorf("account inactive")
}
acct = foundAcct
foundDBCreds, credErr := u.db.GetWebAuthnCredentials(foundAcct.ID)
if credErr != nil {
return nil, fmt.Errorf("load credentials: %w", credErr)
}
dbCreds = foundDBCreds
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds)
if decErr != nil {
return nil, fmt.Errorf("decrypt credentials: %w", decErr)
}
return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil
}
cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq)
if err != nil {
u.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
}
if acct == nil {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if acct.Status != model.AccountStatusActive {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (WebAuthn UI login finish)", "error", lockErr)
}
if locked {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
// Validate sign counter.
var matchedDBCred *model.WebAuthnCredential
for _, dc := range dbCreds {
decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc)
if decErr != nil {
continue
}
if bytes.Equal(decrypted.ID, cred.ID) {
matchedDBCred = dc
break
}
}
if matchedDBCred != nil {
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
audit.JSON("reason", "counter_rollback"))
_ = u.db.RecordLoginFailure(acct.ID)
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
}
_ = u.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
_ = u.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
}
_ = u.db.ClearLoginFailures(acct.ID)
// Issue JWT and set session cookie.
expiry := u.cfg.DefaultExpiry()
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
for _, rol := range roles {
if rol == "admin" {
expiry = u.cfg.AdminExpiry()
break
}
}
privKey, err := u.vault.PrivKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
tokenStr, tokenClaims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: tokenStr,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Expires: tokenClaims.ExpiresAt,
})
if _, err := u.setCSRFCookies(w); err != nil {
u.logger.Error("set CSRF cookie", "error", err)
}
u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
audit.JSON("jti", tokenClaims.JTI, "via", "webauthn_ui"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"redirect": "/dashboard"})
}
// ---- Admin: WebAuthn credential management ----
// handleAdminWebAuthnDelete removes a WebAuthn credential from the admin account detail page.
func (u *UIServer) handleAdminWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
accountUUID := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(accountUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
credIDStr := r.PathValue("credentialId")
credID, err := strconv.ParseInt(credIDStr, 10, 64)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
return
}
if err := u.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
u.renderError(w, r, http.StatusNotFound, "credential not found")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventWebAuthnRemoved, actorID, &acct.ID,
audit.JSON("credential_id", credIDStr, "admin", "true"))
// Return updated credentials list.
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "webauthn_credentials", struct { //nolint:govet // fieldalignment: anonymous struct
PageData
WebAuthnCreds []*model.WebAuthnCredential
DeletePrefix string
WebAuthnEnabled bool
}{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
WebAuthnCreds: creds,
DeletePrefix: "/accounts/" + accountUUID + "/webauthn",
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
// writeJSONError writes a JSON error response.
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -177,6 +177,13 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
}
return *actorID == *cred.OwnerID
},
// derefTime dereferences a *time.Time, returning the zero time for nil.
"derefTime": func(p *time.Time) time.Time {
if p == nil {
return time.Time{}
}
return *p
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"gt": func(a, b int) bool { return a > b },
@@ -213,6 +220,8 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
"templates/fragments/password_reset_form.html",
"templates/fragments/password_change_form.html",
"templates/fragments/token_delegates.html",
"templates/fragments/webauthn_credentials.html",
"templates/fragments/webauthn_enroll.html",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
@@ -378,6 +387,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
uiMux.HandleFunc("POST /logout", u.handleLogout)
// WebAuthn login routes (public, rate-limited).
uiMux.Handle("POST /login/webauthn/begin", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginBegin)))
uiMux.Handle("POST /login/webauthn/finish", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginFinish)))
// Protected routes.
//
@@ -432,6 +444,12 @@ func (u *UIServer) Register(mux *http.ServeMux) {
// Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
// WebAuthn profile routes (enrollment and management).
uiMux.Handle("POST /profile/webauthn/begin", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnBegin))))
uiMux.Handle("POST /profile/webauthn/finish", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnFinish))))
uiMux.Handle("DELETE /profile/webauthn/{id}", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnDelete))))
// Admin WebAuthn management.
uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -729,6 +747,8 @@ type LoginData struct {
// a short-lived server-side nonce is issued after successful password
// verification, and only the nonce is embedded in the TOTP step form.
Nonce string // single-use server-side nonce replacing the password hidden field
// WebAuthnEnabled indicates whether the passkey login button should appear.
WebAuthnEnabled bool
}
// DashboardData is the view model for the dashboard page.
@@ -746,7 +766,7 @@ type AccountsData struct {
}
// AccountDetailData is the view model for the account detail page.
type AccountDetailData struct {
type AccountDetailData struct { //nolint:govet // fieldalignment: readability over alignment for view model
Account *model.Account
// PGCred is nil if none stored or the account is not a system account.
PGCred *model.PGCredential
@@ -772,10 +792,15 @@ type AccountDetailData struct {
AllRoles []string
Tags []string
Tokens []*model.TokenRecord
// WebAuthnCreds lists the WebAuthn credentials for this account (metadata only).
WebAuthnCreds []*model.WebAuthnCredential
// DeletePrefix is the URL prefix for WebAuthn credential delete buttons.
DeletePrefix string
// CanIssueToken is true when the viewing actor may issue tokens for this
// system account (admin role or explicit delegate grant).
// Placed last to minimise GC scan area.
CanIssueToken bool
CanIssueToken bool
WebAuthnEnabled bool
}
// ServiceAccountsData is the view model for the /service-accounts page.
@@ -832,8 +857,11 @@ type PoliciesData struct {
}
// ProfileData is the view model for the profile/settings page.
type ProfileData struct {
type ProfileData struct { //nolint:govet // fieldalignment: readability over alignment for view model
PageData
WebAuthnCreds []*model.WebAuthnCredential
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
WebAuthnEnabled bool
}
// PGCredsData is the view model for the "My PG Credentials" list page.

View File

@@ -0,0 +1,28 @@
// Package webauthn provides the adapter between the go-webauthn library and
// MCIAS internal types. It handles WebAuthn instance configuration and
// encryption/decryption of credential material stored in the database.
package webauthn
import (
"fmt"
"github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/config"
)
// NewWebAuthn creates a configured go-webauthn instance from MCIAS config.
func NewWebAuthn(cfg *config.WebAuthnConfig) (*webauthn.WebAuthn, error) {
if cfg.RPID == "" || cfg.RPOrigin == "" {
return nil, fmt.Errorf("webauthn: RPID and RPOrigin are required")
}
displayName := cfg.DisplayName
if displayName == "" {
displayName = "MCIAS"
}
return webauthn.New(&webauthn.Config{
RPID: cfg.RPID,
RPDisplayName: displayName,
RPOrigins: []string{cfg.RPOrigin},
})
}

View File

@@ -0,0 +1,75 @@
package webauthn
import (
"testing"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/config"
)
func TestNewWebAuthn(t *testing.T) {
cfg := &config.WebAuthnConfig{
RPID: "example.com",
RPOrigin: "https://example.com",
DisplayName: "Test App",
}
wa, err := NewWebAuthn(cfg)
if err != nil {
t.Fatalf("NewWebAuthn: %v", err)
}
if wa == nil {
t.Fatal("expected non-nil WebAuthn instance")
}
}
func TestNewWebAuthnMissingFields(t *testing.T) {
_, err := NewWebAuthn(&config.WebAuthnConfig{})
if err == nil {
t.Error("expected error for empty config")
}
_, err = NewWebAuthn(&config.WebAuthnConfig{RPID: "example.com"})
if err == nil {
t.Error("expected error for missing RPOrigin")
}
}
func TestNewWebAuthnDefaultDisplayName(t *testing.T) {
cfg := &config.WebAuthnConfig{
RPID: "example.com",
RPOrigin: "https://example.com",
}
wa, err := NewWebAuthn(cfg)
if err != nil {
t.Fatalf("NewWebAuthn: %v", err)
}
if wa == nil {
t.Fatal("expected non-nil WebAuthn instance")
}
}
func TestAccountUserInterface(t *testing.T) {
uuidBytes := []byte("12345678-1234-1234-1234-123456789abc")
creds := []libwebauthn.Credential{
{ID: []byte("cred1")},
{ID: []byte("cred2")},
}
user := NewAccountUser(uuidBytes, "alice", creds)
// Verify interface compliance.
var _ libwebauthn.User = user
if string(user.WebAuthnID()) != string(uuidBytes) {
t.Error("WebAuthnID mismatch")
}
if user.WebAuthnName() != "alice" {
t.Errorf("WebAuthnName = %q, want %q", user.WebAuthnName(), "alice")
}
if user.WebAuthnDisplayName() != "alice" {
t.Errorf("WebAuthnDisplayName = %q, want %q", user.WebAuthnDisplayName(), "alice")
}
if len(user.WebAuthnCredentials()) != 2 {
t.Errorf("WebAuthnCredentials len = %d, want 2", len(user.WebAuthnCredentials()))
}
}

View File

@@ -0,0 +1,99 @@
package webauthn
import (
"encoding/hex"
"fmt"
"strings"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// DecryptCredential decrypts a stored WebAuthn credential's ID and public key
// and returns a webauthn.Credential suitable for the go-webauthn library.
func DecryptCredential(masterKey []byte, cred *model.WebAuthnCredential) (*webauthn.Credential, error) {
credID, err := crypto.OpenAESGCM(masterKey, cred.CredentialIDNonce, cred.CredentialIDEnc)
if err != nil {
return nil, fmt.Errorf("webauthn: decrypt credential ID: %w", err)
}
pubKey, err := crypto.OpenAESGCM(masterKey, cred.PublicKeyNonce, cred.PublicKeyEnc)
if err != nil {
return nil, fmt.Errorf("webauthn: decrypt public key: %w", err)
}
// Parse transports from comma-separated string.
var transports []protocol.AuthenticatorTransport
if cred.Transports != "" {
for _, t := range strings.Split(cred.Transports, ",") {
transports = append(transports, protocol.AuthenticatorTransport(strings.TrimSpace(t)))
}
}
// Parse AAGUID from hex string.
var aaguid []byte
if cred.AAGUID != "" {
aaguid, _ = hex.DecodeString(cred.AAGUID)
}
return &webauthn.Credential{
ID: credID,
PublicKey: pubKey,
Transport: transports,
Flags: webauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: cred.Discoverable,
},
Authenticator: webauthn.Authenticator{
AAGUID: aaguid,
SignCount: cred.SignCount,
},
}, nil
}
// DecryptCredentials decrypts all stored credentials for use with the library.
func DecryptCredentials(masterKey []byte, dbCreds []*model.WebAuthnCredential) ([]webauthn.Credential, error) {
result := make([]webauthn.Credential, 0, len(dbCreds))
for _, c := range dbCreds {
decrypted, err := DecryptCredential(masterKey, c)
if err != nil {
return nil, err
}
result = append(result, *decrypted)
}
return result, nil
}
// EncryptCredential encrypts a library credential for database storage.
// Returns a model.WebAuthnCredential with encrypted fields populated.
func EncryptCredential(masterKey []byte, cred *webauthn.Credential, name string, discoverable bool) (*model.WebAuthnCredential, error) {
credIDEnc, credIDNonce, err := crypto.SealAESGCM(masterKey, cred.ID)
if err != nil {
return nil, fmt.Errorf("webauthn: encrypt credential ID: %w", err)
}
pubKeyEnc, pubKeyNonce, err := crypto.SealAESGCM(masterKey, cred.PublicKey)
if err != nil {
return nil, fmt.Errorf("webauthn: encrypt public key: %w", err)
}
// Serialize transports as comma-separated string.
var transportStrs []string
for _, t := range cred.Transport {
transportStrs = append(transportStrs, string(t))
}
return &model.WebAuthnCredential{
Name: name,
CredentialIDEnc: credIDEnc,
CredentialIDNonce: credIDNonce,
PublicKeyEnc: pubKeyEnc,
PublicKeyNonce: pubKeyNonce,
AAGUID: hex.EncodeToString(cred.Authenticator.AAGUID),
SignCount: cred.Authenticator.SignCount,
Discoverable: discoverable,
Transports: strings.Join(transportStrs, ","),
}, nil
}

View File

@@ -0,0 +1,148 @@
package webauthn
import (
"bytes"
"testing"
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
func testMasterKey(t *testing.T) []byte {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return key
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
masterKey := testMasterKey(t)
original := &libwebauthn.Credential{
ID: []byte("credential-id-12345"),
PublicKey: []byte("public-key-bytes-here"),
Transport: []protocol.AuthenticatorTransport{
protocol.USB,
protocol.NFC,
},
Flags: libwebauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: true,
},
Authenticator: libwebauthn.Authenticator{
AAGUID: []byte{0x2f, 0xc0, 0x57, 0x9f, 0x81, 0x13, 0x47, 0xea, 0xb1, 0x16, 0xbb, 0x5a, 0x8d, 0xb9, 0x20, 0x2a},
SignCount: 42,
},
}
// Encrypt.
encrypted, err := EncryptCredential(masterKey, original, "YubiKey 5", true)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if encrypted.Name != "YubiKey 5" {
t.Errorf("Name = %q, want %q", encrypted.Name, "YubiKey 5")
}
if !encrypted.Discoverable {
t.Error("expected discoverable=true")
}
if encrypted.SignCount != 42 {
t.Errorf("SignCount = %d, want 42", encrypted.SignCount)
}
if encrypted.Transports != "usb,nfc" {
t.Errorf("Transports = %q, want %q", encrypted.Transports, "usb,nfc")
}
// Encrypted fields should not be plaintext.
if bytes.Equal(encrypted.CredentialIDEnc, original.ID) {
t.Error("credential ID should be encrypted")
}
if bytes.Equal(encrypted.PublicKeyEnc, original.PublicKey) {
t.Error("public key should be encrypted")
}
// Decrypt.
decrypted, err := DecryptCredential(masterKey, encrypted)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if !bytes.Equal(decrypted.ID, original.ID) {
t.Errorf("credential ID mismatch after roundtrip")
}
if !bytes.Equal(decrypted.PublicKey, original.PublicKey) {
t.Errorf("public key mismatch after roundtrip")
}
if decrypted.Authenticator.SignCount != 42 {
t.Errorf("SignCount = %d, want 42", decrypted.Authenticator.SignCount)
}
if len(decrypted.Transport) != 2 {
t.Errorf("expected 2 transports, got %d", len(decrypted.Transport))
}
}
func TestDecryptCredentials(t *testing.T) {
masterKey := testMasterKey(t)
// Create two encrypted credentials.
var dbCreds []*model.WebAuthnCredential
for i := range 3 {
cred := &libwebauthn.Credential{
ID: []byte{byte(i), 1, 2, 3},
PublicKey: []byte{byte(i), 4, 5, 6},
Authenticator: libwebauthn.Authenticator{
SignCount: uint32(i),
},
}
enc, err := EncryptCredential(masterKey, cred, "key", false)
if err != nil {
t.Fatalf("encrypt %d: %v", i, err)
}
dbCreds = append(dbCreds, enc)
}
decrypted, err := DecryptCredentials(masterKey, dbCreds)
if err != nil {
t.Fatalf("decrypt all: %v", err)
}
if len(decrypted) != 3 {
t.Fatalf("expected 3 decrypted, got %d", len(decrypted))
}
for i, d := range decrypted {
if d.ID[0] != byte(i) {
t.Errorf("cred %d: ID[0] = %d, want %d", i, d.ID[0], byte(i))
}
}
}
func TestDecryptWithWrongKey(t *testing.T) {
masterKey := testMasterKey(t)
wrongKey := make([]byte, 32)
for i := range wrongKey {
wrongKey[i] = byte(i + 100)
}
// Encrypt with correct key.
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte("secret"))
if err != nil {
t.Fatalf("seal: %v", err)
}
dbCred := &model.WebAuthnCredential{
CredentialIDEnc: enc,
CredentialIDNonce: nonce,
PublicKeyEnc: enc,
PublicKeyNonce: nonce,
}
// Decrypt with wrong key should fail.
_, err = DecryptCredential(wrongKey, dbCred)
if err == nil {
t.Error("expected error decrypting with wrong key")
}
}

37
internal/webauthn/user.go Normal file
View File

@@ -0,0 +1,37 @@
package webauthn
import (
"github.com/go-webauthn/webauthn/webauthn"
)
// AccountUser implements the webauthn.User interface for an MCIAS account.
// The WebAuthnCredentials field must be populated with decrypted credentials
// before passing to the library.
type AccountUser struct {
id []byte // UUID as bytes
name string
displayName string
credentials []webauthn.Credential
}
// NewAccountUser creates a new AccountUser from account details and decrypted credentials.
func NewAccountUser(uuidBytes []byte, username string, creds []webauthn.Credential) *AccountUser {
return &AccountUser{
id: uuidBytes,
name: username,
displayName: username,
credentials: creds,
}
}
// WebAuthnID returns the user's unique ID as bytes.
func (u *AccountUser) WebAuthnID() []byte { return u.id }
// WebAuthnName returns the user's login name.
func (u *AccountUser) WebAuthnName() string { return u.name }
// WebAuthnDisplayName returns the user's display name.
func (u *AccountUser) WebAuthnDisplayName() string { return u.displayName }
// WebAuthnCredentials returns the user's registered credentials.
func (u *AccountUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }