Add FIDO2/WebAuthn passkey authentication

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

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

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

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

View File

@@ -128,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
**Human accounts** — interactive users. Can authenticate via:
- Username + password (Argon2id hash stored in DB)
- Optional TOTP (RFC 6238); if enrolled, required on every login
- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1)
- Optional FIDO2/WebAuthn passkeys and security keys; discoverable credentials
enable passwordless login, non-discoverable credentials serve as 2FA
**System accounts** — non-interactive service identities. Have:
- A single active bearer token at a time (rotating the token revokes the old one)
@@ -420,6 +421,17 @@ value in an HTMX fragment or flash message.
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |
### WebAuthn Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/v1/auth/webauthn/register/begin` | bearer JWT | Begin WebAuthn registration (requires password re-auth) |
| POST | `/v1/auth/webauthn/register/finish` | bearer JWT | Complete WebAuthn registration |
| POST | `/v1/auth/webauthn/login/begin` | none | Begin WebAuthn login (discoverable or username-scoped) |
| POST | `/v1/auth/webauthn/login/finish` | none | Complete WebAuthn login, returns JWT |
| GET | `/v1/accounts/{id}/webauthn` | admin JWT | List WebAuthn credential metadata |
| DELETE | `/v1/accounts/{id}/webauthn/{credentialId}` | admin JWT | Remove WebAuthn credential |
### Postgres Credential Endpoints
| Method | Path | Auth required | Description |
@@ -697,14 +709,36 @@ CREATE INDEX idx_sa_delegates_account ON service_account_delegates (account_id);
CREATE INDEX idx_sa_delegates_grantee ON service_account_delegates (grantee_id);
```
```sql
-- WebAuthn credentials (migration 000009)
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY,
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 ON webauthn_credentials(account_id);
```
### Schema Notes
- Passwords are stored as PHC-format Argon2id strings (e.g.,
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
parameters. Future parameter upgrades are transparent.
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
master key held only in server memory (derived at startup from a passphrase
or keyfile). The nonce is stored adjacent to the ciphertext.
- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
encrypted with AES-256-GCM using a master key held only in server memory
(derived at startup from a passphrase or keyfile). The nonce is stored
adjacent to the ciphertext.
- The master key salt is stored in `server_config.master_key_salt` so the
Argon2id KDF produces the same key on every restart. Generated on first run.
- The signing key encryption is layered: the Ed25519 private key is wrapped
@@ -782,7 +816,7 @@ mcias/
│ ├── config/ # config file parsing and validation
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
│ ├── db/ # SQLite access layer (schema, migrations, queries)
│ │ └── migrations/ # numbered SQL migrations (currently 8)
│ │ └── migrations/ # numbered SQL migrations (currently 9)
│ ├── grpcserver/ # gRPC handler implementations
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
@@ -791,7 +825,8 @@ mcias/
│ ├── token/ # JWT issuance, validation, revocation
│ ├── ui/ # web UI context, CSRF, session, template handlers
│ ├── validate/ # input validation helpers (username, password strength)
── vault/ # master key lifecycle: seal/unseal state, key derivation
── vault/ # master key lifecycle: seal/unseal state, key derivation
│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
├── web/
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
│ ├── templates/ # HTML templates (base layout, pages, HTMX fragments)
@@ -1830,3 +1865,66 @@ the "Token Issue Access" section.
| `token_delegate_granted` | Admin granted a human account token-issue access for a system account |
| `token_delegate_revoked` | Admin revoked token-issue delegation |
| `token_issued` | Token issued (existing event, also fires for delegate-issued tokens) |
## 22. FIDO2/WebAuthn Authentication
### Overview
WebAuthn support enables two credential modes:
- **Discoverable credentials (passkeys)** — passwordless login. The authenticator
stores a resident credential; the user clicks "Sign in with passkey" and the
browser prompts for the credential directly.
- **Non-discoverable credentials (security keys)** — 2FA alongside
username+password. The server supplies allowCredentials for the account.
Either WebAuthn or TOTP satisfies the 2FA requirement. If both are enrolled the
UI offers passkey first.
### Credential Storage
Credential IDs and public keys are encrypted at rest with AES-256-GCM using
the vault master key, consistent with TOTP secrets and PG credentials. The
nonce is stored alongside the ciphertext in the `webauthn_credentials` table.
Metadata (name, AAGUID, sign count, discoverable flag, transports, timestamps)
is stored in plaintext for display and management.
### Challenge (Ceremony) Management
Registration and login ceremonies use an in-memory `sync.Map` with 120-second
TTL, consistent with the `pendingLogins` and `tokenDownloads` patterns. Each
ceremony is keyed by a 128-bit random nonce. Ceremonies are single-use:
consumed on finish, expired entries cleaned by a background goroutine.
Separate ceremony stores exist for REST API (`internal/server`) and web UI
(`internal/ui`) to maintain independent lifecycle management.
### Sign Counter Validation
On each assertion the stored sign counter is compared to the authenticator's
reported value. If the reported counter is less than or equal to the stored
counter (and both are non-zero), the assertion is rejected as a potential
cloned authenticator. This mirrors the TOTP replay protection pattern.
### Audit Events
| Event | Description |
|---|---|
| `webauthn_enrolled` | New WebAuthn credential registered |
| `webauthn_removed` | WebAuthn credential removed (self-service or admin) |
| `webauthn_login_ok` | Successful WebAuthn authentication |
| `webauthn_login_fail` | Failed WebAuthn authentication attempt |
### Configuration
WebAuthn is enabled by adding a `[webauthn]` section to the TOML config:
```toml
[webauthn]
rp_id = "mcias.metacircular.net"
rp_origin = "https://mcias.metacircular.net:8443"
display_name = "MCIAS"
```
If the section is omitted, WebAuthn endpoints return 404 and the UI hides
passkey-related controls.