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>
- Replace type: [string, "null"] array syntax with
type: string + nullable: true on AuditEvent.actor_id,
AuditEvent.target_id, PolicyRule.not_before, and
PolicyRule.expires_at; Swagger UI 5 cannot parse the
JSON Schema array form
- Add missing username field to /v1/token/validate response
schema (added to handler in d6cc827 but never synced)
- Add missing GET /v1/pgcreds endpoint to spec
- Sync web/static/openapi.yaml (served file) with root;
the static copy was many commits out of date, missing
all policy/tags schemas and endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Include username field in validateResponse struct
- Look up account by UUID and populate username on success
- Add username field to Go client TokenClaims struct
- Fix OpenAPI nullable type syntax (use array form)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- REST handleTOTPEnroll now requires password field in request body
- gRPC EnrollTOTP updated with password field in proto message
- Both handlers check lockout status and record failures on bad password
- Updated Go, Python, and Rust client libraries to pass password
- Updated OpenAPI specs with new requestBody schema
- Added TestTOTPEnrollRequiresPassword with no-password, wrong-password,
and correct-password sub-tests
Security: TOTP enrollment now requires the current password to prevent
session-theft escalation to persistent account takeover. Lockout and
failure recording use the same Argon2id constant-time path as login.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add POST /v1/accounts/{id}/roles and DELETE /v1/accounts/{id}/roles/{role} REST endpoints
- Add GrantRole and RevokeRole RPCs to AccountService in gRPC API
- Update OpenAPI specification with new endpoints
- Add grant and revoke subcommands to mciasctl
- Add grant and revoke subcommands to mciasgrpcctl
- Regenerate proto files with new message types and RPCs
- Implement gRPC server methods for granular role management
- All existing tests pass; build verified with goimports
Security: Role changes are audited via EventRoleGranted and EventRoleRevoked events,
consistent with existing SetRoles implementation.
- Web UI admin password reset now enforces admin role
server-side (was cookie-auth + CSRF only; any logged-in
user could previously reset any account's password)
- Added self-service password change UI at GET/PUT /profile:
current_password + new_password + confirm_password;
server-side equality check; lockout + Argon2id verification;
revokes all other sessions on success
- password_change_form.html fragment and profile.html page
- Nav bar actor name now links to /profile
- policy: ActionChangePassword + default rule -7 allowing
human accounts to change their own password
- openapi.yaml: built-in rules count updated to -7
Migration recovery:
- mciasdb schema force --version N: new subcommand to clear
dirty migration state without running SQL (break-glass)
- schema subcommands bypass auto-migration on open so the
tool stays usable when the database is dirty
- Migrate(): shim no longer overrides schema_migrations
when it already has an entry; duplicate-column error on
the latest migration is force-cleaned and treated as
success (handles columns added outside the runner)
Security:
- Admin role is now validated in handleAdminResetPassword
before any DB access; non-admin receives 403
- handleSelfChangePassword follows identical lockout +
constant-time Argon2id path as the REST self-service
handler; current password required to prevent
token-theft account takeover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- internal/ui/ui.go: add PGCred, Tags to AccountDetailData; register
PUT /accounts/{id}/pgcreds and PUT /accounts/{id}/tags routes; add
pgcreds_form.html and tags_editor.html to shared template set; remove
unused AccountTagsData; fix fieldalignment on PolicyRuleView, PoliciesData
- internal/ui/handlers_accounts.go: add handleSetPGCreds — encrypts
password via crypto.SealAESGCM, writes audit EventPGCredUpdated, renders
pgcreds_form fragment; password never echoed; load PG creds and tags in
handleAccountDetail
- internal/ui/handlers_policy.go: fix handleSetAccountTags to render with
AccountDetailData instead of removed AccountTagsData
- internal/ui/ui_test.go: add 5 PG credential UI tests
- web/templates/fragments/pgcreds_form.html: new fragment — metadata display
+ set/replace form; system accounts only; password write-only
- web/templates/fragments/tags_editor.html: new fragment — textarea editor
with HTMX PUT for atomic tag replacement
- web/templates/fragments/policy_form.html: rewrite to use structured fields
matching handleCreatePolicyRule (roles/account_types/actions multi-select,
resource_type, subject_uuid, service_names, required_tags, checkbox)
- web/templates/policies.html: new policies management page
- web/templates/fragments/policy_row.html: new HTMX table row with toggle
and delete
- web/templates/account_detail.html: add Tags card and PG Credentials card
- web/templates/base.html: add Policies nav link
- internal/server/server.go: remove ~220 lines of duplicate tag/policy
handler code (real implementations are in handlers_policy.go)
- internal/policy/engine_wrapper.go: fix corrupted source; use errors.New
- internal/db/policy_test.go: use model.AccountTypeHuman constant
- cmd/mciasctl/main.go: add nolint:gosec to int(os.Stdin.Fd()) calls
- gofmt/goimports: db/policy_test.go, policy/defaults.go,
policy/engine_test.go, ui/ui.go, cmd/mciasctl/main.go
- fieldalignment: model.PolicyRuleRecord, policy.Engine, policy.Rule,
policy.RuleBody, ui.PolicyRuleView
Security: PG password encrypted AES-256-GCM with fresh random nonce before
storage; plaintext never logged or returned in any response; audit event
written on every credential write.
- ui/ui.go: add pendingLogin struct and pendingLogins sync.Map
to UIServer; add issueTOTPNonce (generates 128-bit random nonce,
stores accountID with 90s TTL) and consumeTOTPNonce (single-use,
expiry-checked LoadAndDelete); add dummyHash() method
- ui/handlers_auth.go: split handleLoginPost into step 1
(password verify → issue nonce) and step 2 (handleTOTPStep,
consume nonce → validate TOTP) via a new finishLogin helper;
password never transmitted or stored after step 1
- ui/ui_test.go: refactor newTestMux to reuse new
newTestUIServer; add TestTOTPNonceIssuedAndConsumed,
TestTOTPNonceUnknownRejected, TestTOTPNonceExpired, and
TestLoginPostPasswordNotInTOTPForm; 11/11 tests pass
- web/templates/fragments/totp_step.html: replace
'name=password' hidden field with 'name=totp_nonce'
- db/accounts.go: add GetAccountByID for TOTP step lookup
- AUDIT.md: mark F-02 as fixed
Security: the plaintext password previously survived two HTTP
round-trips and lived in the browser DOM during the TOTP step.
The nonce approach means the password is verified once and
immediately discarded; only an opaque random token tied to an
account ID (never a credential) crosses the wire on step 2.
Nonces are single-use and expire after 90 seconds to limit
the window if one is captured.