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:
@@ -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 != ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS webauthn_credentials;
|
||||
18
internal/db/migrations/000009_webauthn_credentials.up.sql
Normal file
18
internal/db/migrations/000009_webauthn_credentials.up.sql
Normal 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
208
internal/db/webauthn.go
Normal 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)
|
||||
}
|
||||
251
internal/db/webauthn_test.go
Normal file
251
internal/db/webauthn_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
92
internal/grpcserver/webauthn.go
Normal file
92
internal/grpcserver/webauthn.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
741
internal/server/handlers_webauthn.go
Normal file
741
internal/server/handlers_webauthn.go
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
696
internal/ui/handlers_webauthn.go
Normal file
696
internal/ui/handlers_webauthn.go
Normal 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})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
28
internal/webauthn/adapter.go
Normal file
28
internal/webauthn/adapter.go
Normal 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},
|
||||
})
|
||||
}
|
||||
75
internal/webauthn/adapter_test.go
Normal file
75
internal/webauthn/adapter_test.go
Normal 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()))
|
||||
}
|
||||
}
|
||||
99
internal/webauthn/convert.go
Normal file
99
internal/webauthn/convert.go
Normal 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
|
||||
}
|
||||
148
internal/webauthn/convert_test.go
Normal file
148
internal/webauthn/convert_test.go
Normal 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
37
internal/webauthn/user.go
Normal 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 }
|
||||
Reference in New Issue
Block a user