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

@@ -86,6 +86,54 @@ components:
type: boolean
description: Whether TOTP is enrolled and required for this account.
example: false
webauthn_enabled:
type: boolean
description: Whether at least one WebAuthn credential is registered.
example: false
webauthn_count:
type: integer
description: Number of registered WebAuthn credentials.
example: 0
WebAuthnCredentialInfo:
type: object
required: [id, name, sign_count, discoverable, created_at]
properties:
id:
type: integer
format: int64
description: Database row ID.
example: 1
name:
type: string
description: User-supplied label for the credential.
example: "YubiKey 5"
aaguid:
type: string
description: Authenticator Attestation GUID.
example: "2fc0579f-8113-47ea-b116-bb5a8db9202a"
sign_count:
type: integer
format: uint32
description: Signature counter (used to detect cloned authenticators).
example: 42
discoverable:
type: boolean
description: Whether this is a discoverable (passkey/resident) credential.
example: true
transports:
type: string
description: Comma-separated transport hints (usb, nfc, ble, internal).
example: "usb,nfc"
created_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
last_used_at:
type: string
format: date-time
nullable: true
example: "2026-03-15T14:30:00Z"
AuditEvent:
type: object
@@ -847,6 +895,213 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
# ── WebAuthn ──────────────────────────────────────────────────────────────
/v1/auth/webauthn/register/begin:
post:
summary: Begin WebAuthn registration
description: |
Start a WebAuthn credential registration ceremony. Requires the current
password for re-authentication (same security model as TOTP enrollment).
Returns PublicKeyCredentialCreationOptions for the browser WebAuthn API.
operationId: beginWebAuthnRegister
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
description: Current password for re-authentication.
name:
type: string
description: Optional label for the credential (e.g. "YubiKey 5").
example: "YubiKey 5"
responses:
"200":
description: Registration options for navigator.credentials.create().
content:
application/json:
schema:
type: object
description: PublicKeyCredentialCreationOptions (WebAuthn spec).
"401":
$ref: "#/components/responses/Unauthorized"
"429":
description: Account temporarily locked.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/auth/webauthn/register/finish:
post:
summary: Finish WebAuthn registration
description: |
Complete the WebAuthn credential registration ceremony. The request body
contains the authenticator's response from navigator.credentials.create().
The credential is encrypted at rest with AES-256-GCM.
operationId: finishWebAuthnRegister
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
description: AuthenticatorAttestationResponse (WebAuthn spec).
responses:
"200":
description: Credential registered.
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/webauthn/login/begin:
post:
summary: Begin WebAuthn login
description: |
Start a WebAuthn authentication ceremony. Public RPC — no auth required.
With a username: returns allowCredentials for the account's registered
credentials. Without a username: starts a discoverable (passkey) flow.
operationId: beginWebAuthnLogin
tags: [Public]
requestBody:
content:
application/json:
schema:
type: object
properties:
username:
type: string
description: Optional. If omitted, starts a discoverable (passkey) flow.
example: alice
responses:
"200":
description: Assertion options for navigator.credentials.get().
content:
application/json:
schema:
type: object
description: PublicKeyCredentialRequestOptions (WebAuthn spec).
"429":
$ref: "#/components/responses/RateLimited"
/v1/auth/webauthn/login/finish:
post:
summary: Finish WebAuthn login
description: |
Complete the WebAuthn authentication ceremony. Validates the assertion,
checks the sign counter, and issues a JWT. Public RPC — no auth required.
operationId: finishWebAuthnLogin
tags: [Public]
requestBody:
required: true
content:
application/json:
schema:
type: object
description: AuthenticatorAssertionResponse (WebAuthn spec).
responses:
"200":
description: Login successful.
content:
application/json:
schema:
$ref: "#/components/schemas/TokenResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
/v1/accounts/{id}/webauthn:
get:
summary: List WebAuthn credentials (admin)
description: |
Returns metadata for all WebAuthn credentials registered to an account.
Credential material (IDs, public keys) is never included.
operationId: listWebAuthnCredentials
tags: [Admin — Accounts]
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: Account UUID.
responses:
"200":
description: Credential metadata list.
content:
application/json:
schema:
type: object
properties:
credentials:
type: array
items:
$ref: "#/components/schemas/WebAuthnCredentialInfo"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/webauthn/{credentialId}:
delete:
summary: Remove WebAuthn credential (admin)
description: |
Remove a specific WebAuthn credential from an account. Admin only.
operationId: deleteWebAuthnCredential
tags: [Admin — Accounts]
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: Account UUID.
- name: credentialId
in: path
required: true
schema:
type: integer
format: int64
description: Credential database row ID.
responses:
"204":
description: Credential removed.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/token/issue:
post:
summary: Issue service account token (admin)