9 Commits

Author SHA1 Message Date
37afc68287 Add TOTP enrollment to web UI
- Profile page TOTP section with enrollment flow:
  password re-auth → QR code + manual entry → 6-digit confirm
- Server-side QR code generation (go-qrcode, data: URI PNG)
- Admin "Remove TOTP" button on account detail page
- Enrollment nonces: sync.Map with 5-minute TTL, single-use
- Template fragments: totp_section.html, totp_enroll_qr.html
- Handler: handlers_totp.go (enroll start, confirm, admin remove)

Security: Password re-auth before secret generation (SEC-01).
Lockout checked before Argon2. CSRF on all endpoints. Single-use
enrollment nonces with expiry. TOTP counter replay prevention
(CRIT-01). Self-removal not permitted (admin only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:39:45 -07:00
25417b24f4 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>
2026-03-16 16:12:59 -07:00
Claude Opus 4.6
19fa0c9a8e Fix policy form roles; add JSON edit mode
- Replace stale "service" role option with correct set:
  admin, user, guest, viewer, editor, commenter (matches model.go)
- Add Form/JSON tab toggle to policy create form
- JSON tab accepts raw RuleBody JSON with description/priority
- Handler detects rule_json field and parses/validates it
  directly, falling back to field-by-field form mode otherwise
2026-03-16 15:25:51 -07:00
7db560dae4 Update PROGRESS.md for docker-clean target
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 20:38:58 -07:00
124d0cdcd1 Add docker image cleanup to clean target
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 20:38:38 -07:00
cf1f4f94be Fix Swagger server URLs to use correct hosts
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 20:33:39 -07:00
52cc979814 Update PROGRESS.md: /docs swagger fix
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 19:19:29 -07:00
8bf5c9033f Bundle swagger-ui assets locally for /docs
- Download swagger-ui-dist@5.32.0 and embed
  swagger-ui-bundle.js and swagger-ui.css into
  web/static/ so they are served from the same origin
- Update docs.html to reference /static/ paths instead
  of unpkg.com CDN URLs
- Add GET /static/swagger-ui-bundle.js and
  GET /static/swagger-ui.css handlers serving the
  embedded bytes with correct Content-Type headers
- Fixes /docs breakage caused by CSP default-src 'self'
  blocking external CDN scripts and stylesheets

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 19:19:12 -07:00
cb661bb8f5 Checkpoint: fix all lint warnings
- errorlint: use errors.Is for ErrSealed comparisons in vault_test.go
- gofmt: reformat config, config_test, middleware_test with goimports
- govet/fieldalignment: reorder struct fields in vault.go, csrf.go,
  detail_test.go, middleware_test.go for optimal alignment
- unused: remove unused newCSRFManager in csrf.go (superseded by
  newCSRFManagerFromVault)
- revive/early-return: invert sealed-vault condition in main.go

Security: no auth/crypto logic changed; struct reordering and error
comparison fixes only. newCSRFManager removal is safe — it was never
called; all CSRF construction goes through newCSRFManagerFromVault.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 16:40:11 -07:00
61 changed files with 5893 additions and 285 deletions

View File

@@ -0,0 +1,8 @@
[2026-03-15 19:17] - Updated by Junie
{
"TYPE": "negative",
"CATEGORY": "Service reliability",
"EXPECTATION": "The Swagger docs endpoint should remain accessible and stable at all times.",
"NEW INSTRUCTION": "WHEN swagger/docs endpoint is down or errors THEN Diagnose cause, apply fix, and restore availability immediately"
}

View File

@@ -1 +1 @@
[{"lang":"en","usageCount":1}] [{"lang":"en","usageCount":7}]

View File

@@ -15,16 +15,16 @@ parties that delegate authentication decisions to it.
### Components ### Components
``` ```
┌───────────────────────────────────────────────────────── ┌─────────────────────────────────────────────────────────┐
│ MCIAS Server (mciassrv) │ MCIAS Server (mciassrv) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Auth │ │ Token │ │ Account / Role │ │ │ │ Auth │ │ Token │ │ Account / Role │ │
│ │ Handler │ │ Manager │ │ Manager │ │ │ │ Handler │ │ Manager │ │ Manager │ │
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │ │ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
│ └─────────────┴─────────────────┘ │ │ └─────────────┴─────────────────┘ │
│ │ │ │ │
│ ┌─────────▼──────────┐ │ │ ┌─────────▼──────────┐ │
│ │ SQLite Database │ │ │ SQLite Database │ │
│ └────────────────────┘ │ │ └────────────────────┘ │
│ │ │ │
│ ┌──────────────────┐ ┌──────────────────────┐ │ │ ┌──────────────────┐ ┌──────────────────────┐ │
@@ -32,10 +32,10 @@ parties that delegate authentication decisions to it.
│ │ (net/http) │ │ (google.golang.org/ │ │ │ │ (net/http) │ │ (google.golang.org/ │ │
│ │ :8443 │ │ grpc) :9443 │ │ │ │ :8443 │ │ grpc) :9443 │ │
│ └──────────────────┘ └──────────────────────┘ │ │ └──────────────────┘ └──────────────────────┘ │
└───────────────────────────────────────────────────────── └─────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O │ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
│ │ │ │ │ │ │ │
┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐ ┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐
│ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │ │ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │ │ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
@@ -128,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
**Human accounts** — interactive users. Can authenticate via: **Human accounts** — interactive users. Can authenticate via:
- Username + password (Argon2id hash stored in DB) - Username + password (Argon2id hash stored in DB)
- Optional TOTP (RFC 6238); if enrolled, required on every login - 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: **System accounts** — non-interactive service identities. Have:
- A single active bearer token at a time (rotating the token revokes the old one) - A single active bearer token at a time (rotating the token revokes the old one)
@@ -367,7 +368,25 @@ All endpoints use JSON request/response bodies. All responses include a
| POST | `/v1/token/issue` | admin JWT | Issue service account token | | POST | `/v1/token/issue` | admin JWT | Issue service account token |
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI | | DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |
### Account Endpoints (admin only) ### Token Download Endpoint
| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/token/download/{nonce}` | bearer JWT | Download a previously issued token via one-time nonce (5-min TTL, single-use) |
The token download flow issues a short-lived nonce when a service token is created
via `POST /accounts/{id}/token`. The bearer must be authenticated; the nonce is
deleted on first download to prevent replay. This avoids exposing the raw token
value in an HTMX fragment or flash message.
### Token Delegation Endpoints (admin only)
| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/accounts/{id}/token/delegates` | admin JWT | Grant a human account permission to issue tokens for a system account |
| DELETE | `/accounts/{id}/token/delegates/{grantee}` | admin JWT | Revoke token-issue delegation |
### Account Endpoints
| Method | Path | Auth required | Description | | Method | Path | Auth required | Description |
|---|---|---|---| |---|---|---|---|
@@ -376,6 +395,7 @@ All endpoints use JSON request/response bodies. All responses include a
| GET | `/v1/accounts/{id}` | admin JWT | Get account details | | GET | `/v1/accounts/{id}` | admin JWT | Get account details |
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) | | PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account | | DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
| POST | `/v1/accounts/{id}/token` | bearer JWT (admin or delegate) | Issue/rotate service account token |
### Password Endpoints ### Password Endpoints
@@ -401,11 +421,23 @@ All endpoints use JSON request/response bodies. All responses include a
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code | | POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) | | 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 ### Postgres Credential Endpoints
| Method | Path | Auth required | Description | | Method | Path | Auth required | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials | | GET | `/v1/pgcreds` | bearer JWT | List all credentials accessible to the caller (owned + explicitly granted) |
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials for a specific account |
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials | | PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
### Tag Endpoints (admin only) ### Tag Endpoints (admin only)
@@ -479,6 +511,7 @@ cookie pattern (`mcias_csrf`).
| `/policies` | Policy rules management — create, enable/disable, delete | | `/policies` | Policy rules management — create, enable/disable, delete |
| `/audit` | Audit log viewer | | `/audit` | Audit log viewer |
| `/profile` | User profile — self-service password change (any authenticated user) | | `/profile` | User profile — self-service password change (any authenticated user) |
| `/service-accounts` | Delegated service account list for non-admin users; issue/rotate token with one-time download |
**HTMX fragments:** Mutating operations (role updates, tag edits, credential **HTMX fragments:** Mutating operations (role updates, tag edits, credential
saves, policy toggles, access grants) use HTMX partial-page updates for a saves, policy toggles, access grants) use HTMX partial-page updates for a
@@ -658,6 +691,43 @@ CREATE TABLE policy_rules (
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339) not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339) expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339)
); );
-- Token issuance delegation: tracks which human accounts may issue tokens for
-- a given system account without holding the global admin role. Admins manage
-- delegates; delegates can issue/rotate tokens for the specific system account
-- only and cannot modify any other account settings.
CREATE TABLE service_account_delegates (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- target system account
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- human account granted access
granted_by INTEGER REFERENCES accounts(id), -- admin who granted access
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, grantee_id)
);
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 ### Schema Notes
@@ -665,9 +735,10 @@ CREATE TABLE policy_rules (
- Passwords are stored as PHC-format Argon2id strings (e.g., - Passwords are stored as PHC-format Argon2id strings (e.g.,
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm `$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
parameters. Future parameter upgrades are transparent. parameters. Future parameter upgrades are transparent.
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a - TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
master key held only in server memory (derived at startup from a passphrase encrypted with AES-256-GCM using a master key held only in server memory
or keyfile). The nonce is stored adjacent to the ciphertext. (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 - 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. 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 - The signing key encryption is layered: the Ed25519 private key is wrapped
@@ -735,30 +806,45 @@ mcias/
│ │ └── main.go │ │ └── main.go
│ ├── mciasctl/ # REST admin CLI │ ├── mciasctl/ # REST admin CLI
│ │ └── main.go │ │ └── main.go
│ ├── mciasdb/ # direct SQLite maintenance tool (Phase 6) │ ├── mciasdb/ # direct SQLite maintenance tool
│ │ └── main.go │ │ └── main.go
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7) │ └── mciasgrpcctl/ # gRPC admin CLI companion
│ └── main.go │ └── main.go
├── internal/ ├── internal/
│ ├── audit/ # audit log event detail marshaling
│ ├── auth/ # login flow, TOTP verification, account lockout │ ├── auth/ # login flow, TOTP verification, account lockout
│ ├── config/ # config file parsing and validation │ ├── config/ # config file parsing and validation
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation │ ├── crypto/ # key management, AES-GCM helpers, master key derivation
│ ├── db/ # SQLite access layer (schema, migrations, queries) │ ├── db/ # SQLite access layer (schema, migrations, queries)
├── grpcserver/ # gRPC handler implementations (Phase 7) │ └── migrations/ # numbered SQL migrations (currently 9)
│ ├── grpcserver/ # gRPC handler implementations
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy) │ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.) │ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
│ ├── policy/ # in-process authorization policy engine (§20) │ ├── policy/ # in-process authorization policy engine (§20)
│ ├── server/ # HTTP handlers, router setup │ ├── server/ # HTTP handlers, router setup
│ ├── token/ # JWT issuance, validation, revocation │ ├── token/ # JWT issuance, validation, revocation
│ ├── ui/ # web UI context, CSRF, session, template handlers │ ├── ui/ # web UI context, CSRF, session, template handlers
── validate/ # input validation helpers (username, password strength) ── validate/ # input validation helpers (username, password strength)
│ ├── vault/ # master key lifecycle: seal/unseal state, key derivation
│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
├── web/ ├── web/
│ ├── static/ # CSS and static assets │ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
── templates/ # HTML templates (base layout, pages, HTMX fragments) ── templates/ # HTML templates (base layout, pages, HTMX fragments)
│ └── embed.go # fs.FS embedding of static files and templates
├── proto/ ├── proto/
│ └── mcias/v1/ # Protobuf service definitions (Phase 7) │ └── mcias/v1/ # Protobuf service definitions
├── gen/ ├── gen/
│ └── mcias/v1/ # Generated Go stubs from protoc (committed; Phase 7) │ └── mcias/v1/ # Generated Go stubs from protoc (committed)
├── clients/
│ ├── go/ # Go client library
│ ├── python/ # Python client library
│ ├── rust/ # Rust client library
│ └── lisp/ # Common Lisp client library
├── test/
│ ├── e2e/ # end-to-end test suite
│ └── mock/ # Go mock server for client integration tests
├── dist/ # operational artifacts: systemd unit, install script, config templates
├── man/man1/ # man pages (mciassrv.1, mciasctl.1, mciasdb.1, mciasgrpcctl.1)
└── go.mod └── go.mod
``` ```
@@ -810,6 +896,8 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) | | `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
| `policy_rule_deleted` | Policy rule deleted | | `policy_rule_deleted` | Policy rule deleted |
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) | | `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
| `token_delegate_granted` | Admin granted a human account permission to issue tokens for a system account |
| `token_delegate_revoked` | Admin revoked a human account's token-issue delegation |
| `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` | | `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` |
| `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` | | `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |
@@ -970,7 +1058,8 @@ proto/
└── v1/ └── v1/
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove ├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
├── token.proto # Validate, Issue, Revoke ├── token.proto # Validate, Issue, Revoke
├── account.proto # CRUD for accounts and roles ├── account.proto # CRUD for accounts, roles, and credentials
├── policy.proto # Policy rule CRUD (PolicyService)
├── admin.proto # Health, public-key retrieval ├── admin.proto # Health, public-key retrieval
└── common.proto # Shared message types (Error, Timestamp wrappers) └── common.proto # Shared message types (Error, Timestamp wrappers)
@@ -991,6 +1080,7 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` | | `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` | | `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
| `CredentialService` | `GetPGCreds`, `SetPGCreds` | | `CredentialService` | `GetPGCreds`, `SetPGCreds` |
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
| `AdminService` | `Health`, `GetPublicKey` | | `AdminService` | `Health`, `GetPublicKey` |
All request/response messages follow the same credential-exclusion rules as All request/response messages follow the same credential-exclusion rules as
@@ -1203,8 +1293,9 @@ The Makefile `docker` target automates the build step with the version tag.
| `generate` | `go generate ./...` (re-generates proto stubs) | | `generate` | `go generate ./...` (re-generates proto stubs) |
| `man` | Build man pages; compress to `.gz` in `man/` | | `man` | Build man pages; compress to `.gz` in `man/` |
| `install` | Run `dist/install.sh` | | `install` | Run `dist/install.sh` |
| `docker` | `docker build -t mcias:$(VERSION) .` | | `docker` | `docker build -t mcias:$(VERSION) -t mcias:latest .` |
| `clean` | Remove `bin/` and compressed man pages | | `docker-clean` | Remove local `mcias:$(VERSION)` and `mcias:latest` images; prune dangling images with the mcias label |
| `clean` | Remove `bin/`, compressed man pages, and local Docker images |
| `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 | | `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
### Upgrade Path ### Upgrade Path
@@ -1373,6 +1464,8 @@ needed:
- A human account should be able to access credentials for one specific service - A human account should be able to access credentials for one specific service
without being a full admin. without being a full admin.
- A human account should be able to issue/rotate tokens for one specific service
account without holding the global `admin` role (see token delegation, §21).
- A system account (`deploy-agent`) should only operate on hosts tagged - A system account (`deploy-agent`) should only operate on hosts tagged
`env:staging`, not `env:production`. `env:staging`, not `env:production`.
- A "secrets reader" role should read pgcreds for any service but change nothing. - A "secrets reader" role should read pgcreds for any service but change nothing.
@@ -1465,7 +1558,7 @@ type Resource struct {
// Rule is a single policy statement. All populated fields are ANDed. // Rule is a single policy statement. All populated fields are ANDed.
// A zero/empty field is a wildcard (matches anything). // A zero/empty field is a wildcard (matches anything).
type Rule struct { type Rule struct {
ID int64 // database primary key; 0 for built-in rules ID int64 // database primary key; negative for built-in rules (-1 … -7)
Description string Description string
// Principal match conditions // Principal match conditions
@@ -1681,3 +1774,157 @@ introduced.
| `policy_rule_deleted` | Rule deleted | | `policy_rule_deleted` | Rule deleted |
| `tag_added` | Tag added to an account | | `tag_added` | Tag added to an account |
| `tag_removed` | Tag removed from an account | | `tag_removed` | Tag removed from an account |
---
## 21. Token Issuance Delegation
### Motivation
The initial design required the `admin` role to issue a service account token.
This blocks a common workflow: a developer who owns one personal app (e.g.
`payments-api`) wants to rotate its service token without granting another
person full admin access to all of MCIAS.
Token issuance delegation solves this by allowing admins to grant specific
human accounts the right to issue/rotate tokens for specific system accounts —
and nothing else.
### Model
The `service_account_delegates` table stores the delegation relationship:
```
service_account_delegates(account_id, grantee_id, granted_by, granted_at)
```
- `account_id` — the **system account** whose token the delegate may issue
- `grantee_id` — the **human account** granted the right
- `granted_by` — the admin who created the grant (for audit purposes)
A human account is a delegate if a row exists with their ID as `grantee_id`.
Delegates may:
- Issue/rotate the token for the specific system account
- Download the newly issued token via the one-time nonce endpoint
- View the system account on their `/service-accounts` page
Delegates may **not**:
- Modify roles, tags, or status on the system account
- Read or modify pgcreds for the system account
- List other accounts or perform any other admin operation
### Token Download Flow
Issuing a service token via `POST /accounts/{id}/token` (admin or delegate)
stores the raw token string in an in-memory `sync.Map` under a random nonce
with a 5-minute TTL. The handler returns the nonce in the HTMX fragment.
The caller redeems the nonce via `GET /token/download/{nonce}`, which:
1. Looks up the nonce in the map (missing → 404).
2. Deletes the nonce immediately (prevents replay).
3. Returns the token as `Content-Disposition: attachment; filename=token.txt`.
The nonce is not stored in the database and is lost on server restart. This
is intentional: if the download window is missed, the operator simply issues
a new token.
### Authorization Check
`POST /accounts/{id}/token` is authenticated (bearer JWT + CSRF) but not
admin-only. The handler performs an explicit check:
```
if claims.HasRole("admin") OR db.HasTokenIssueAccess(targetID, callerID):
proceed
else:
403 Forbidden
```
This check is done in the handler rather than middleware because the
delegation relationship requires a DB lookup that depends on the caller's
identity and the specific target account.
### Admin Management
| Endpoint | Description |
|---|---|
| `POST /accounts/{id}/token/delegates` | Grant delegation (admin only) |
| `DELETE /accounts/{id}/token/delegates/{grantee}` | Revoke delegation (admin only) |
Both operations produce audit events (`token_delegate_granted`,
`token_delegate_revoked`) and are visible in the account detail UI under
the "Token Issue Access" section.
### Audit Events
| Event | Trigger |
|---|---|
| `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.

View File

@@ -10,6 +10,7 @@
# make clean — remove bin/ and generated artifacts # make clean — remove bin/ and generated artifacts
# make dist — build release tarballs for linux/amd64 and linux/arm64 # make dist — build release tarballs for linux/amd64 and linux/arm64
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest # make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
# make docker-clean — remove local mcias Docker images
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Variables # Variables
@@ -98,6 +99,7 @@ install: build
clean: clean:
rm -rf $(BIN_DIR) rm -rf $(BIN_DIR)
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES)) rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# dist — cross-compiled release tarballs for linux/amd64 and linux/arm64 # dist — cross-compiled release tarballs for linux/amd64 and linux/arm64
@@ -134,6 +136,14 @@ dist: man
docker: docker:
docker build -t mcias:$(VERSION) -t mcias:latest . docker build -t mcias:$(VERSION) -t mcias:latest .
# ---------------------------------------------------------------------------
# docker-clean — remove local mcias Docker images
# ---------------------------------------------------------------------------
.PHONY: docker-clean
docker-clean:
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
-docker image prune -f --filter label=org.opencontainers.image.title=mcias 2>/dev/null || true
.PHONY: install-local .PHONY: install-local
install-local: build install-local: build
cp bin/* $(HOME)/.local/bin/ cp bin/* $(HOME)/.local/bin/
@@ -152,4 +162,5 @@ help:
@echo " install Install to /usr/local/bin (requires root)" @echo " install Install to /usr/local/bin (requires root)"
@echo " clean Remove build artifacts" @echo " clean Remove build artifacts"
@echo " dist Build release tarballs for Linux amd64/arm64" @echo " dist Build release tarballs for Linux amd64/arm64"
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest" @echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
@echo " docker-clean Remove local mcias Docker images"

514
POLICY.md Normal file
View File

@@ -0,0 +1,514 @@
# MCIAS Policy Engine
Reference guide for the MCIAS attribute-based access control (ABAC) policy
engine. Covers concepts, rule authoring, the full action/resource catalogue,
built-in defaults, time-scoped rules, and worked examples.
For the authoritative design rationale and middleware integration details see
[ARCHITECTURE.md §20](ARCHITECTURE.md).
---
## 1. Concepts
### Evaluation model
The policy engine is a **pure function**: given a `PolicyInput` (assembled from
JWT claims and database lookups) and a slice of `Rule` values, it returns an
`Effect` (`allow` or `deny`) and a pointer to the matching rule.
Evaluation proceeds in three steps:
1. **Sort** all rules (built-in defaults + operator rules) by `Priority`
ascending. Lower number = evaluated first. Stable sort preserves insertion
order within the same priority.
2. **Deny-wins**: the first matching `deny` rule terminates evaluation
immediately and returns `Deny`.
3. **First-allow**: if no `deny` matched, the first matching `allow` rule
returns `Allow`.
4. **Default-deny**: if no rule matched at all, the request is denied.
The engine never touches the database. The caller (middleware) is responsible
for assembling `PolicyInput` from JWT claims and DB lookups before calling
`engine.Evaluate`.
### Rule matching
A rule matches a request when **every populated field** satisfies its
condition. An empty/zero field is a wildcard (matches anything).
| Rule field | Match condition |
|---|---|
| `roles` | Principal holds **at least one** of the listed roles |
| `account_types` | Principal's account type is in the list (`"human"`, `"system"`) |
| `subject_uuid` | Principal UUID equals this value exactly |
| `actions` | Request action is in the list |
| `resource_type` | Target resource type equals this value |
| `owner_matches_subject` | (if `true`) resource owner UUID equals the principal UUID |
| `service_names` | Target service account username is in the list |
| `required_tags` | Target account carries **all** of the listed tags |
All conditions are AND-ed. To express OR across principals or resources, create
multiple rules.
### Priority
| Range | Intended use |
|---|---|
| 0 | Built-in defaults (compiled in; cannot be overridden via API) |
| 149 | High-precedence operator deny rules (explicit blocks) |
| 5099 | Normal operator allow rules |
| 100 | Default for new rules created via API or CLI |
| 101+ | Low-precedence fallback rules |
Because deny-wins applies within the matched set (not just within a priority
band), a `deny` rule at priority 100 still overrides an `allow` at priority 50
if both match. Use explicit deny rules at low priority numbers (e.g. 10) when
you want them to fire before any allow can be considered.
### Built-in default rules
These rules are compiled into the binary (`internal/policy/defaults.go`). They
have IDs -1 through -7, priority 0, and **cannot be disabled or deleted via
the API**. They reproduce the previous binary admin/non-admin behavior exactly.
| ID | Description | Conditions | Effect |
|---|---|---|---|
| -1 | Admin wildcard | `roles=[admin]` | allow |
| -2 | Self-service logout / token renewal | `actions=[auth:logout, tokens:renew]` | allow |
| -3 | Self-service TOTP enrollment | `actions=[totp:enroll]` | allow |
| -7 | Self-service password change | `account_types=[human]`, `actions=[auth:change_password]` | allow |
| -4 | System account reads own pgcreds | `account_types=[system]`, `actions=[pgcreds:read]`, `resource_type=pgcreds`, `owner_matches_subject=true` | allow |
| -5 | System account issues/renews own token | `account_types=[system]`, `actions=[tokens:issue, tokens:renew]`, `resource_type=token`, `owner_matches_subject=true` | allow |
| -6 | Public endpoints | `actions=[tokens:validate, auth:login]` | allow |
Custom operator rules extend this baseline; they do not replace it.
---
## 2. Actions and Resource Types
### Actions
Actions follow the `resource:verb` convention. Use the exact string values
shown below when authoring rules.
| Action string | Description | Notes |
|---|---|---|
| `accounts:list` | List all accounts | admin |
| `accounts:create` | Create an account | admin |
| `accounts:read` | Read account details | admin |
| `accounts:update` | Update account (status, etc.) | admin |
| `accounts:delete` | Soft-delete an account | admin |
| `roles:read` | Read role assignments | admin |
| `roles:write` | Grant or revoke roles | admin |
| `tags:read` | Read account tags | admin |
| `tags:write` | Set account tags | admin |
| `tokens:issue` | Issue or rotate a service token | admin or delegate |
| `tokens:revoke` | Revoke a token | admin |
| `tokens:validate` | Validate a token | public |
| `tokens:renew` | Renew own token | self-service |
| `pgcreds:read` | Read Postgres credentials | admin or delegated |
| `pgcreds:write` | Set Postgres credentials | admin |
| `audit:read` | Read audit log | admin |
| `totp:enroll` | Enroll TOTP | self-service |
| `totp:remove` | Remove TOTP from an account | admin |
| `auth:login` | Authenticate (username + password) | public |
| `auth:logout` | Invalidate own session token | self-service |
| `auth:change_password` | Change own password | self-service |
| `policy:list` | List policy rules | admin |
| `policy:manage` | Create, update, or delete policy rules | admin |
### Resource types
| Resource type string | Description |
|---|---|
| `account` | A human or system account record |
| `token` | A JWT or service bearer token |
| `pgcreds` | A Postgres credential record |
| `audit_log` | The audit event log |
| `totp` | A TOTP enrollment record |
| `policy` | A policy rule record |
---
## 3. Rule Schema
Rules are stored in the `policy_rules` table. The `rule_json` column holds a
JSON-encoded `RuleBody`. All other fields are dedicated columns.
### Database columns
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | Auto-assigned |
| `priority` | INTEGER | Default 100; lower = evaluated first |
| `description` | TEXT | Human-readable label (required) |
| `enabled` | BOOLEAN | Disabled rules are excluded from the cache |
| `not_before` | DATETIME (nullable) | Rule inactive before this UTC timestamp |
| `expires_at` | DATETIME (nullable) | Rule inactive at and after this UTC timestamp |
| `rule_json` | TEXT | JSON-encoded `RuleBody` (see below) |
### RuleBody JSON fields
```json
{
"effect": "allow" | "deny",
"roles": ["role1", "role2"],
"account_types": ["human"] | ["system"] | ["human", "system"],
"subject_uuid": "<UUID string>",
"actions": ["action:verb", ...],
"resource_type": "<resource type string>",
"owner_matches_subject": true | false,
"service_names": ["svc-username", ...],
"required_tags": ["tag:value", ...]
}
```
All fields are optional except `effect`. Omitted fields are wildcards.
---
## 4. Managing Rules
### Via mciasctl
```sh
# List all rules
mciasctl policy list
# Create a rule from a JSON file
mciasctl policy create -description "My rule" -json rule.json
# Create a time-scoped rule
mciasctl policy create \
-description "Temp production access" \
-json rule.json \
-not-before 2026-04-01T00:00:00Z \
-expires-at 2026-04-01T04:00:00Z
# Enable or disable a rule
mciasctl policy update -id 7 -enabled=false
# Delete a rule
mciasctl policy delete -id 7
```
### Via REST API (admin JWT required)
| Method | Path | Description |
|---|---|---|
| GET | `/v1/policy/rules` | List all rules |
| POST | `/v1/policy/rules` | Create a rule |
| GET | `/v1/policy/rules/{id}` | Get a single rule |
| PATCH | `/v1/policy/rules/{id}` | Update priority, enabled, or description |
| DELETE | `/v1/policy/rules/{id}` | Delete a rule |
### Via Web UI
The `/policies` page lists all rules with enable/disable toggles and a create
form. Mutating operations use HTMX partial-page updates.
### Cache reload
The `Engine` caches the active rule set in memory. It reloads automatically
after any `policy_rule_*` admin event. To force a reload without a rule change,
send `SIGHUP` to `mciassrv`.
---
## 5. Account Tags
Tags are key:value strings attached to accounts (human or system) and used as
resource match conditions in rules. They are stored in the `account_tags` table.
### Recommended tag conventions
| Tag | Meaning |
|---|---|
| `env:production` | Account belongs to the production environment |
| `env:staging` | Account belongs to the staging environment |
| `env:dev` | Account belongs to the development environment |
| `svc:payments-api` | Account is associated with the payments-api service |
| `machine:db-west-01` | Account is associated with a specific host |
| `team:platform` | Account is owned by the platform team |
Tag names are not enforced by the schema; the conventions above are
recommendations only.
### Managing tags
```sh
# Set tags on an account (replaces the full tag set atomically)
mciasctl accounts update -id <uuid> -tags "env:staging,svc:payments-api"
# Via REST (admin JWT)
PUT /v1/accounts/{id}/tags
Content-Type: application/json
["env:staging", "svc:payments-api"]
```
---
## 6. Worked Examples
### Example A — Named service delegation
**Goal:** Alice needs to read Postgres credentials for `payments-api` only.
1. Grant Alice the role `svc:payments-api`:
```sh
mciasctl accounts roles grant -id <alice-uuid> -role svc:payments-api
```
2. Create the allow rule (`rule.json`):
```json
{
"effect": "allow",
"roles": ["svc:payments-api"],
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"service_names": ["payments-api"]
}
```
```sh
mciasctl policy create -description "Alice: read payments-api pgcreds" \
-json rule.json -priority 50
```
When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware
sets `resource.ServiceName = "payments-api"`. The rule matches and access is
granted. A call against any other service account sets a different
`ServiceName`; no rule matches and default-deny applies.
---
### Example B — Machine-tag gating (staging only)
**Goal:** `deploy-agent` may read pgcreds for staging accounts but must be
explicitly blocked from production.
1. Tag all staging system accounts:
```sh
mciasctl accounts update -id <svc-uuid> -tags "env:staging"
```
2. Explicit deny for production (low priority number = evaluated first):
```json
{
"effect": "deny",
"subject_uuid": "<deploy-agent-uuid>",
"resource_type": "pgcreds",
"required_tags": ["env:production"]
}
```
```sh
mciasctl policy create -description "deploy-agent: deny production pgcreds" \
-json deny.json -priority 10
```
3. Allow for staging:
```json
{
"effect": "allow",
"subject_uuid": "<deploy-agent-uuid>",
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"required_tags": ["env:staging"]
}
```
```sh
mciasctl policy create -description "deploy-agent: allow staging pgcreds" \
-json allow.json -priority 50
```
The deny rule (priority 10) fires before the allow rule (priority 50) for any
production-tagged resource. For staging resources the deny does not match and
the allow rule permits access.
---
### Example C — Blanket "secrets reader" role
**Goal:** Any account holding the `secrets-reader` role may read pgcreds for
any service.
```json
{
"effect": "allow",
"roles": ["secrets-reader"],
"actions": ["pgcreds:read"],
"resource_type": "pgcreds"
}
```
```sh
mciasctl policy create -description "secrets-reader: read any pgcreds" \
-json rule.json -priority 50
```
No `service_names` or `required_tags` means the rule matches any target
account. Grant the role to any account that needs broad read access:
```sh
mciasctl accounts roles grant -id <uuid> -role secrets-reader
```
---
### Example D — Time-scoped emergency access
**Goal:** `deploy-agent` needs temporary access to production pgcreds for a
4-hour maintenance window on 2026-04-01.
```json
{
"effect": "allow",
"subject_uuid": "<deploy-agent-uuid>",
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"required_tags": ["env:production"]
}
```
```sh
mciasctl policy create \
-description "deploy-agent: temp production access (maintenance window)" \
-json rule.json \
-priority 50 \
-not-before 2026-04-01T02:00:00Z \
-expires-at 2026-04-01T06:00:00Z
```
The engine excludes this rule from the cache before `not_before` and after
`expires_at`. No manual cleanup is required; the rule becomes inert
automatically. Both fields are nullable — omitting either means no constraint
on that end.
---
### Example E — Per-account subject rule
**Goal:** Bob (a contractor) may issue/rotate the token for `worker-bot` only,
without any admin role.
1. Grant delegation via the delegation API (preferred for token issuance; see
ARCHITECTURE.md §21):
```sh
mciasctl accounts token delegates grant \
-id <worker-bot-uuid> -grantee <bob-uuid>
```
Or, equivalently, via a policy rule:
```json
{
"effect": "allow",
"subject_uuid": "<bob-uuid>",
"actions": ["tokens:issue", "tokens:renew"],
"resource_type": "token",
"service_names": ["worker-bot"]
}
```
```sh
mciasctl policy create -description "Bob: issue worker-bot token" \
-json rule.json -priority 50
```
2. Bob uses the `/service-accounts` UI page or `mciasctl` to rotate the token
and download it via the one-time nonce endpoint.
---
### Example F — Deny a specific account from all access
**Goal:** Temporarily block `mallory` (UUID known) from all operations without
deleting the account.
```json
{
"effect": "deny",
"subject_uuid": "<mallory-uuid>"
}
```
```sh
mciasctl policy create -description "Block mallory (incident response)" \
-json rule.json -priority 1
```
Priority 1 ensures this deny fires before any allow rule. Because deny-wins
applies globally (not just within a priority band), this blocks mallory even
though the admin wildcard (priority 0, allow) would otherwise match. Note: the
admin wildcard is an `allow` rule; a `deny` at any priority overrides it for
the matched principal.
To lift the block, delete or disable the rule:
```sh
mciasctl policy update -id <rule-id> -enabled=false
```
---
## 7. Security Recommendations
1. **Prefer explicit deny rules for sensitive resources.** Use `required_tags`
or `service_names` to scope allow rules narrowly, and add a corresponding
deny rule at a lower priority number for the resources that must never be
accessible.
2. **Use time-scoped rules for temporary access.** Set `expires_at` instead of
creating a rule and relying on manual deletion. The engine enforces expiry
automatically at cache-load time.
3. **Avoid wildcard allow rules without resource scoping.** A rule with only
`roles` and `actions` but no `resource_type`, `service_names`, or
`required_tags` matches every resource of every type. Scope rules as
narrowly as the use case allows.
4. **Audit deny events.** Every explicit deny produces a `policy_deny` audit
event. Review the audit log (`GET /v1/audit` or the `/audit` UI page)
regularly to detect unexpected access patterns.
5. **Do not rely on priority alone for security boundaries.** Priority controls
evaluation order, not security strength. A deny rule at priority 100 still
overrides an allow at priority 50 if both match. Use deny rules explicitly
rather than assuming a lower-priority allow will be shadowed.
6. **Keep the built-in defaults intact.** The compiled-in rules reproduce the
baseline admin/self-service behavior. Custom rules extend this baseline;
they cannot disable the defaults. Do not attempt to work around them by
creating conflicting operator rules — the deny-wins semantics mean an
operator deny at priority 1 will block even the admin wildcard for the
matched principal.
7. **Reload after bulk changes.** After importing many rules via the REST API,
send `SIGHUP` to `mciassrv` to force an immediate cache reload rather than
waiting for the next individual rule event.
---
## 8. Audit Events
| Event | Trigger |
|---|---|
| `policy_deny` | Engine denied a request; payload: `{action, resource_type, service_name, required_tags, matched_rule_id}` — never contains credential material |
| `policy_rule_created` | New operator rule created |
| `policy_rule_updated` | Rule priority, enabled flag, or description changed |
| `policy_rule_deleted` | Rule deleted |
| `tag_added` | Tag added to an account |
| `tag_removed` | Tag removed from an account |
All events are written to the `audit_events` table and are visible via
`GET /v1/audit` (admin JWT required) or the `/audit` web UI page.

View File

@@ -2,7 +2,139 @@
Source of truth for current development state. Source of truth for current development state.
--- ---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only). Phases 014 complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-16 — TOTP enrollment via web UI
**Task:** Add TOTP enrollment and management to the web UI profile page.
**Changes:**
- **Dependency:** `github.com/skip2/go-qrcode` for server-side QR code generation
- **Profile page:** TOTP section showing enabled status or enrollment form
- **Enrollment flow:** Password re-auth → generate secret → show QR code + manual entry → confirm with 6-digit code
- **QR code:** Generated server-side as `data:image/png;base64,...` URI (CSP-compliant)
- **Account detail:** Admin "Remove TOTP" button with HTMX delete + confirm
- **Enrollment nonces:** `pendingTOTPEnrolls sync.Map` with 5-minute TTL, single-use
- **Template fragments:** `totp_section.html`, `totp_enroll_qr.html`
- **Handler:** `internal/ui/handlers_totp.go` with `handleTOTPEnrollStart`, `handleTOTPConfirm`, `handleAdminTOTPRemove`
- **Security:** Password re-auth (SEC-01), lockout check, CSRF, single-use nonces, TOTP counter replay prevention (CRIT-01)
---
### 2026-03-16 — Phase 14: FIDO2/WebAuthn and Passkey Authentication
**Task:** Add FIDO2/WebAuthn support for passwordless passkey login and security key 2FA.
**Changes:**
- **Dependency:** `github.com/go-webauthn/webauthn v0.16.1`
- **Config:** `WebAuthnConfig` struct with RPID, RPOrigin, DisplayName; validation; `WebAuthnEnabled()` method
- **Model:** `WebAuthnCredential` struct with encrypted credential fields; 4 audit events; 2 policy actions
- **Migration 000009:** `webauthn_credentials` table with encrypted credential ID/pubkey, sign counter, discoverable flag
- **DB layer:** Full CRUD in `internal/db/webauthn.go` (create, get, delete with ownership, admin delete, delete all, sign count, last used, has, count)
- **Adapter:** `internal/webauthn/` package — library initialization, `AccountUser` interface, AES-256-GCM encrypt/decrypt round-trip
- **Policy:** Default rule -8 for self-service enrollment
- **REST API:** 6 endpoints (register begin/finish, login begin/finish, list credentials, delete credential) with `sync.Map` ceremony store
- **Web UI:** Profile page enrollment+management, login page passkey button, admin account detail passkeys section, CSP-compliant `webauthn.js`
- **gRPC:** `ListWebAuthnCredentials` and `RemoveWebAuthnCredential` RPCs with handler
- **mciasdb:** `webauthn list/delete/reset` subcommands and `account reset-webauthn` alias
- **OpenAPI:** All 6 endpoints documented; `WebAuthnCredentialInfo` schema; `webauthn_enabled`/`webauthn_count` on Account
- **Tests:** DB CRUD tests, adapter encrypt/decrypt round-trip, interface compliance, wrong-key rejection
- **Docs:** ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14, PROGRESS.md
---
### 2026-03-16 — Documentation sync (ARCHITECTURE.md, PROJECT_PLAN.md)
**Task:** Full documentation audit to sync ARCHITECTURE.md and PROJECT_PLAN.md with v1.0.0 implementation.
**ARCHITECTURE.md changes:**
- §8 Postgres Credential Endpoints: added missing `GET /v1/pgcreds`
- §12 Directory/Package Structure: added `internal/audit/`, `internal/vault/`, `web/embed.go`; added `clients/`, `test/`, `dist/`, `man/` top-level dirs; removed stale "(Phase N)" labels
- §17 Proto Package Layout: added `policy.proto`
- §17 Service Definitions: added `PolicyService` row
- §18 Makefile Targets: added `docker-clean`; corrected `docker` and `clean` descriptions
**PROJECT_PLAN.md changes:**
- All phases 09 marked `[COMPLETE]`
- Added status summary at top (v1.0.0, 2026-03-15)
- Phase 4.1: added `mciasctl pgcreds list` subcommand (implemented, was missing from plan)
- Phase 7.1: added `policy.proto` to proto file list
- Phase 8.5: added `docker-clean` target; corrected `docker` and `clean` target descriptions
- Added Phase 10: Web UI (HTMX)
- Added Phase 11: Authorization Policy Engine
- Added Phase 12: Vault Seal/Unseal Lifecycle
- Added Phase 13: Token Delegation and pgcred Access Grants
- Updated implementation order to include phases 1013
**No code changes.** Documentation only.
---
### 2026-03-15 — Makefile: docker image cleanup
**Task:** Ensure `make clean` removes Docker build images; add dedicated `docker-clean` target.
**Changes:**
- `clean` target now runs `docker rmi mcias:$(VERSION) mcias:latest` (errors suppressed so clean works without Docker).
- New `docker-clean` target removes the versioned and `latest` tags and prunes dangling images with the mcias label.
- Header comment and `help` target updated to document `docker-clean`.
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
---
### 2026-03-15 — Fix Swagger server URLs
**Task:** Update Swagger `servers` section to use correct auth server URLs.
**Changes:**
- `openapi.yaml` and `web/static/openapi.yaml`: replaced `https://auth.example.com:8443` with `https://mcias.metacircular.net:8443` (Production) and `https://localhost:8443` (Local test server).
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
---
### 2026-03-15 — Fix /docs Swagger UI (bundle assets locally)
**Problem:** `/docs` was broken because `docs.html` loaded `swagger-ui-bundle.js` and `swagger-ui.css` from `unpkg.com` CDN, which is blocked by the server's `Content-Security-Policy: default-src 'self'` header.
**Solution:**
- Downloaded `swagger-ui-dist@5.32.0` via npm and copied `swagger-ui-bundle.js` and `swagger-ui.css` into `web/static/` (embedded at build time).
- Updated `docs.html` to reference `/static/swagger-ui-bundle.js` and `/static/swagger-ui.css`.
- Added `GET /static/swagger-ui-bundle.js` and `GET /static/swagger-ui.css` handlers in `server.go` serving the embedded bytes with correct `Content-Type` headers.
- No CSP changes required; strict `default-src 'self'` is preserved.
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
---
### 2026-03-15 — Checkpoint: lint fixes
**Task:** Checkpoint — lint clean, tests pass, commit.
**Lint fixes (13 issues resolved):**
- `errorlint`: `internal/vault/vault_test.go` — replaced `err != ErrSealed` with `errors.Is(err, ErrSealed)`.
- `gofmt`: `internal/config/config.go`, `internal/config/config_test.go`, `internal/middleware/middleware_test.go` — reformatted with `goimports`.
- `govet/fieldalignment`: `internal/vault/vault.go`, `internal/ui/csrf.go`, `internal/audit/detail_test.go`, `internal/middleware/middleware_test.go` — reordered struct fields for optimal alignment.
- `unused`: `internal/ui/csrf.go` — removed unused `newCSRFManager` function (superseded by `newCSRFManagerFromVault`).
- `revive/early-return`: `cmd/mciassrv/main.go` — inverted condition to eliminate else-after-return.
**Verification:** `golangci-lint run ./...` → 0 issues; `go test ./...` → all packages pass.
---
### 2026-03-15 — Documentation: ARCHITECTURE.md update + POLICY.md
**Task:** Ensure ARCHITECTURE.md is accurate; add POLICY.md describing the policy engine.
**ARCHITECTURE.md fix:**
- Corrected `Rule.ID` comment: built-in default rules use negative IDs (-1 … -7), not 0 (§20 Core Types code block).
**New file: POLICY.md**
- Operator reference guide for the ABAC policy engine.
- Covers: evaluation model (deny-wins, default-deny, stable priority sort), rule matching semantics, priority conventions, all built-in default rules (IDs -1 … -7) with conditions, full action and resource-type catalogue, rule schema (DB columns + RuleBody JSON), rule management via `mciasctl` / REST API / Web UI, account tag conventions, cache reload, six worked examples (named service delegation, machine-tag gating, blanket role, time-scoped access, per-account subject rule, incident-response deny), security recommendations, and audit events.
---
### 2026-03-15 — Service account token delegation and download ### 2026-03-15 — Service account token delegation and download

View File

@@ -5,7 +5,19 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 0 — Repository Bootstrap ## Status
**v1.0.0 tagged (2026-03-15). All phases complete.**
All packages pass `go test ./...`; `golangci-lint run ./...` clean.
See PROGRESS.md for the detailed development log.
Phases 09 match the original plan. Phases 1013 document significant
features implemented beyond the original plan scope.
---
## Phase 0 — Repository Bootstrap **[COMPLETE]**
### Step 0.1: Go module and dependency setup ### Step 0.1: Go module and dependency setup
**Acceptance criteria:** **Acceptance criteria:**
@@ -23,7 +35,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 1 — Foundational Packages ## Phase 1 — Foundational Packages **[COMPLETE]**
### Step 1.1: `internal/model` — shared data types ### Step 1.1: `internal/model` — shared data types
**Acceptance criteria:** **Acceptance criteria:**
@@ -69,7 +81,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 2 — Authentication Core ## Phase 2 — Authentication Core **[COMPLETE]**
### Step 2.1: `internal/token` — JWT issuance and validation ### Step 2.1: `internal/token` — JWT issuance and validation
**Acceptance criteria:** **Acceptance criteria:**
@@ -107,7 +119,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 3 — HTTP Server ## Phase 3 — HTTP Server **[COMPLETE]**
### Step 3.1: `internal/middleware` — HTTP middleware ### Step 3.1: `internal/middleware` — HTTP middleware
**Acceptance criteria:** **Acceptance criteria:**
@@ -143,6 +155,7 @@ See ARCHITECTURE.md for design rationale.
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment - `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
- `DELETE /v1/auth/totp` — admin; removes TOTP from account - `DELETE /v1/auth/totp` — admin; removes TOTP from account
- `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials - `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials
- `GET /v1/pgcreds` — list all accessible credentials (owned + granted)
- Credential fields (password hash, TOTP secret, Postgres password) are - Credential fields (password hash, TOTP secret, Postgres password) are
**never** included in any API response **never** included in any API response
- Tests: each endpoint happy path; auth middleware applied correctly; invalid - Tests: each endpoint happy path; auth middleware applied correctly; invalid
@@ -160,7 +173,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 4 — Admin CLI ## Phase 4 — Admin CLI **[COMPLETE]**
### Step 4.1: `cmd/mciasctl` — admin CLI ### Step 4.1: `cmd/mciasctl` — admin CLI
**Acceptance criteria:** **Acceptance criteria:**
@@ -177,6 +190,7 @@ See ARCHITECTURE.md for design rationale.
- `mciasctl role revoke -id UUID -role ROLE` - `mciasctl role revoke -id UUID -role ROLE`
- `mciasctl token issue -id UUID` (system accounts) - `mciasctl token issue -id UUID` (system accounts)
- `mciasctl token revoke -jti JTI` - `mciasctl token revoke -jti JTI`
- `mciasctl pgcreds list`
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U` - `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
- `mciasctl pgcreds get -id UUID` - `mciasctl pgcreds get -id UUID`
- `mciasctl auth login` - `mciasctl auth login`
@@ -191,7 +205,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 5 — End-to-End Tests and Hardening ## Phase 5 — End-to-End Tests and Hardening **[COMPLETE]**
### Step 5.1: End-to-end test suite ### Step 5.1: End-to-end test suite
**Acceptance criteria:** **Acceptance criteria:**
@@ -228,7 +242,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 6 — mciasdb: Database Maintenance Tool ## Phase 6 — mciasdb: Database Maintenance Tool **[COMPLETE]**
See ARCHITECTURE.md §16 for full design rationale, trust model, and command See ARCHITECTURE.md §16 for full design rationale, trust model, and command
surface. surface.
@@ -314,9 +328,7 @@ surface.
--- ---
--- ## Phase 7 — gRPC Interface **[COMPLETE]**
## Phase 7 — gRPC Interface
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
transport security requirements. transport security requirements.
@@ -324,7 +336,8 @@ transport security requirements.
### Step 7.1: Protobuf definitions and generated code ### Step 7.1: Protobuf definitions and generated code
**Acceptance criteria:** **Acceptance criteria:**
- `proto/mcias/v1/` directory contains `.proto` files for all service groups: - `proto/mcias/v1/` directory contains `.proto` files for all service groups:
`auth.proto`, `token.proto`, `account.proto`, `admin.proto` `auth.proto`, `token.proto`, `account.proto`, `policy.proto`, `admin.proto`,
`common.proto`
- All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17) - All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17)
- `proto/generate.go` contains a `//go:generate protoc ...` directive that - `proto/generate.go` contains a `//go:generate protoc ...` directive that
produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and
@@ -357,10 +370,11 @@ transport security requirements.
- gRPC server uses the same TLS certificate and key as the REST server (loaded - gRPC server uses the same TLS certificate and key as the REST server (loaded
from config); minimum TLS 1.2 enforced via `tls.Config` from config); minimum TLS 1.2 enforced via `tls.Config`
- Unary server interceptor chain: - Unary server interceptor chain:
1. Request logger (method name, peer IP, status, duration) 1. Sealed interceptor (blocks all RPCs when vault sealed, except Health)
2. Auth interceptor (extracts Bearer token, validates, injects claims into 2. Request logger (method name, peer IP, status, duration)
3. Auth interceptor (extracts Bearer token, validates, injects claims into
`context.Context`) `context.Context`)
3. Rate-limit interceptor (per-IP token bucket, same parameters as REST) 4. Rate-limit interceptor (per-IP token bucket, same parameters as REST)
- No credential material logged by any interceptor - No credential material logged by any interceptor
- Tests: interceptor chain applied correctly; rate-limit triggers after burst - Tests: interceptor chain applied correctly; rate-limit triggers after burst
@@ -396,7 +410,7 @@ transport security requirements.
--- ---
## Phase 8 — Operational Artifacts ## Phase 8 — Operational Artifacts **[COMPLETE]**
See ARCHITECTURE.md §18 for full design rationale and artifact inventory. See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
@@ -461,7 +475,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
- `generate``go generate ./...` (proto stubs from Phase 7) - `generate``go generate ./...` (proto stubs from Phase 7)
- `man` — build compressed man pages - `man` — build compressed man pages
- `install` — run `dist/install.sh` - `install` — run `dist/install.sh`
- `clean` — remove `bin/` and generated artifacts - `docker``docker build -t mcias:$(VERSION) -t mcias:latest .`
- `docker-clean` — remove local `mcias:$(VERSION)` and `mcias:latest` images;
prune dangling images with the mcias label
- `clean` — remove `bin/`, compressed man pages, and local Docker images
- `dist` — build release tarballs for linux/amd64 and linux/arm64 (using - `dist` — build release tarballs for linux/amd64 and linux/arm64 (using
`GOOS`/`GOARCH` cross-compilation) `GOOS`/`GOARCH` cross-compilation)
- `make build` works from a clean checkout after `go mod download` - `make build` works from a clean checkout after `go mod download`
@@ -483,13 +500,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
- `dist/mcias.conf.docker.example` — config template suitable for container - `dist/mcias.conf.docker.example` — config template suitable for container
deployment: `listen_addr = "0.0.0.0:8443"`, `grpc_addr = "0.0.0.0:9443"`, deployment: `listen_addr = "0.0.0.0:8443"`, `grpc_addr = "0.0.0.0:9443"`,
`db_path = "/data/mcias.db"`, TLS cert/key paths under `/etc/mcias/` `db_path = "/data/mcias.db"`, TLS cert/key paths under `/etc/mcias/`
- `Makefile` gains a `docker` target: `docker build -t mcias:$(VERSION) .`
where `VERSION` defaults to the output of `git describe --tags --always`
- Tests: - Tests:
- `docker build .` completes without error (run in CI if Docker available; - `docker build .` completes without error (run in CI if Docker available;
skip gracefully if not) skip gracefully if not)
- `docker run --rm mcias:latest mciassrv --help` exits 0 - `docker run --rm mcias:latest mciassrv --help` exits 0
- Image size documented in PROGRESS.md (target: under 50 MB)
### Step 8.7: Documentation ### Step 8.7: Documentation
**Acceptance criteria:** **Acceptance criteria:**
@@ -501,7 +515,7 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
--- ---
## Phase 9 — Client Libraries ## Phase 9 — Client Libraries **[COMPLETE]**
See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language
implementation notes. implementation notes.
@@ -606,6 +620,203 @@ implementation notes.
--- ---
## Phase 10 — Web UI (HTMX) **[COMPLETE]**
Not in the original plan. Implemented alongside and after Phase 3.
See ARCHITECTURE.md §8 (Web Management UI) for design details.
### Step 10.1: `internal/ui` — HTMX web interface
**Acceptance criteria:**
- Go `html/template` pages embedded at compile time via `web/embed.go`
- CSRF protection: HMAC-signed double-submit cookie (`mcias_csrf`)
- Session: JWT stored as `HttpOnly; Secure; SameSite=Strict` cookie
- Security headers: `Content-Security-Policy: default-src 'self'`,
`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin`
- Pages: login, dashboard, account list/detail, role editor, tag editor,
pgcreds, audit log viewer, policy rules, user profile, service-accounts
- HTMX partial-page updates for mutations (role updates, tag edits, policy
toggles, access grants)
- Empty-state handling on all list pages (zero records case tested)
### Step 10.2: Swagger UI at `/docs`
**Acceptance criteria:**
- `GET /docs` serves Swagger UI for `openapi.yaml`
- swagger-ui-bundle.js and swagger-ui.css bundled locally in `web/static/`
(CDN blocked by CSP `default-src 'self'`)
- `GET /docs/openapi.yaml` serves the OpenAPI spec
- `openapi.yaml` kept in sync with REST API surface
---
## Phase 11 — Authorization Policy Engine **[COMPLETE]**
Not in the original plan (CLI subcommands for policy were planned in Phase 4,
but the engine itself was not a discrete plan phase).
See ARCHITECTURE.md §20 for full design, evaluation algorithm, and built-in
default rules.
### Step 11.1: `internal/policy` — in-process ABAC engine
**Acceptance criteria:**
- Pure evaluation: `Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule)`
- Deny-wins: any explicit deny overrides all allows
- Default-deny: no matching rule → deny
- Built-in default rules (IDs -1 … -7) compiled in; reproduce previous
binary admin/non-admin behavior exactly; cannot be disabled via API
- Match fields: roles, account types, subject UUID, actions, resource type,
owner-matches-subject, service names, required tags (all ANDed; zero value
= wildcard)
- Temporal constraints on DB-backed rules: `not_before`, `expires_at`
- `Engine` wrapper: caches rule set in memory; reloads on policy mutations
- Tests: all built-in rules; deny-wins over allow; default-deny fallback;
temporal filtering; concurrent access
### Step 11.2: Middleware and REST integration
**Acceptance criteria:**
- `RequirePolicy(engine, action, resourceType)` middleware replaces
`RequireRole("admin")` where policy-gated
- Every explicit deny produces a `policy_deny` audit event
- REST endpoints: `GET|POST /v1/policy/rules`, `GET|PATCH|DELETE /v1/policy/rules/{id}`
- DB schema: `policy_rules` and `account_tags` tables (migrations 000004,
000006)
- `PATCH /v1/policy/rules/{id}` supports updating `priority`, `enabled`,
`not_before`, `expires_at`
---
## Phase 12 — Vault Seal/Unseal Lifecycle **[COMPLETE]**
Not in the original plan.
See ARCHITECTURE.md §8 (Vault Endpoints) for the API surface.
### Step 12.1: `internal/vault` — master key lifecycle
**Acceptance criteria:**
- Thread-safe `Vault` struct with `sync.RWMutex`-protected state
- Methods: `IsSealed()`, `Unseal(passphrase)`, `Seal()`, `MasterKey()`,
`PrivKey()`, `PubKey()`
- `Seal()` zeroes all key material before nilling (memguard-style cleanup)
- `DeriveFromPassphrase()` and `DecryptSigningKey()` extracted to `derive.go`
for reuse by unseal handlers
- Tests: state transitions; key zeroing verified; concurrent read/write safety
### Step 12.2: REST and UI integration
**Acceptance criteria:**
- `POST /v1/vault/unseal` — rate-limited (3/s burst 5); derives key, unseals
- `GET /v1/vault/status` — always accessible; returns `{"sealed": bool}`
- `POST /v1/vault/seal` — admin only; zeroes key material
- `GET /v1/health` returns `{"status":"sealed"}` when sealed
- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed
- UI redirects all paths to `/unseal` when sealed (except `/static/`)
- gRPC: `sealedInterceptor` first in chain; blocks all RPCs except Health
- Startup: server may start in sealed state if passphrase env var is absent
- Audit events: `vault_sealed`, `vault_unsealed`
---
## Phase 13 — Token Delegation and pgcred Access Grants **[COMPLETE]**
Not in the original plan.
See ARCHITECTURE.md §21 (Token Issuance Delegation) for design details.
### Step 13.1: Service account token delegation
**Acceptance criteria:**
- DB migration 000008: `service_account_delegates` table
- `POST /accounts/{id}/token/delegates` — admin grants delegation
- `DELETE /accounts/{id}/token/delegates/{grantee}` — admin revokes delegation
- `POST /accounts/{id}/token` — accepts admin or delegate (not admin-only)
- One-time token download: nonce stored in `sync.Map` with 5-minute TTL;
`GET /token/download/{nonce}` serves token as attachment, deletes nonce
- `/service-accounts` page for non-admin delegates
- Audit events: `token_delegate_granted`, `token_delegate_revoked`
### Step 13.2: pgcred fine-grained access grants
**Acceptance criteria:**
- DB migration 000005: `pgcred_access_grants` table
- `POST /accounts/{id}/pgcreds/access` — owner grants read access to grantee
- `DELETE /accounts/{id}/pgcreds/access/{grantee}` — owner revokes access
- `GET /v1/pgcreds` — lists all credentials accessible to caller (owned +
granted); includes credential ID for reference
- Grantees may view connection metadata; password is never decrypted for them
- Audit events: `pgcred_access_granted`, `pgcred_access_revoked`
---
## Phase 14 — FIDO2/WebAuthn and Passkey Authentication
**Goal:** Add FIDO2/WebAuthn support for passwordless passkey login and hardware
security key 2FA. Discoverable credentials enable passwordless login;
non-discoverable credentials serve as 2FA. Either WebAuthn or TOTP satisfies
the 2FA requirement.
### Step 14.1: Dependency, config, and model types
**Acceptance criteria:**
- `github.com/go-webauthn/webauthn` dependency added
- `WebAuthnConfig` struct in config with RPID, RPOrigin, DisplayName
- Validation: if any field set, RPID+RPOrigin required; RPOrigin must be HTTPS
- `WebAuthnCredential` model type with encrypted-at-rest fields
- Audit events: `webauthn_enrolled`, `webauthn_removed`, `webauthn_login_ok`, `webauthn_login_fail`
- Policy actions: `ActionEnrollWebAuthn`, `ActionRemoveWebAuthn`
### Step 14.2: Database migration and CRUD
**Acceptance criteria:**
- Migration 000009: `webauthn_credentials` table with encrypted credential fields
- Full CRUD: Create, Get (by ID, by account), Delete (ownership-checked and admin),
DeleteAll, UpdateSignCount, UpdateLastUsed, Has, Count
- DB tests for all operations including ownership checks and cascade behavior
### Step 14.3: WebAuthn adapter package
**Acceptance criteria:**
- `internal/webauthn/` package with adapter, user, and converter
- `NewWebAuthn(cfg)` factory wrapping library initialization
- `AccountUser` implementing `webauthn.User` interface
- `EncryptCredential`/`DecryptCredential`/`DecryptCredentials` round-trip encryption
- Tests for encrypt/decrypt, interface compliance, wrong-key rejection
### Step 14.4: REST endpoints
**Acceptance criteria:**
- `POST /v1/auth/webauthn/register/begin` — password re-auth, returns creation options
- `POST /v1/auth/webauthn/register/finish` — completes registration, encrypts credential
- `POST /v1/auth/webauthn/login/begin` — discoverable and username-scoped flows
- `POST /v1/auth/webauthn/login/finish` — validates assertion, issues JWT
- `GET /v1/accounts/{id}/webauthn` — admin, returns metadata only
- `DELETE /v1/accounts/{id}/webauthn/{credentialId}` — admin remove
- Challenge store: `sync.Map` with 120s TTL, background cleanup
### Step 14.5: Web UI
**Acceptance criteria:**
- Profile page: passkey enrollment form, credential list with delete
- Login page: "Sign in with passkey" button with discoverable flow
- Account detail page: passkey section with admin remove
- CSP-compliant `webauthn.js` (external script, base64url helpers)
- Empty state handling for zero credentials
### Step 14.6: gRPC handlers
**Acceptance criteria:**
- Proto messages and RPCs: `ListWebAuthnCredentials`, `RemoveWebAuthnCredential`
- gRPC handler implementation delegating to shared packages
- Regenerated protobuf stubs
### Step 14.7: mciasdb offline management
**Acceptance criteria:**
- `mciasdb webauthn list --id UUID`
- `mciasdb webauthn delete --id UUID --credential-id N`
- `mciasdb webauthn reset --id UUID` (deletes all)
- `mciasdb account reset-webauthn --id UUID` alias
- All operations write audit events
### Step 14.8: OpenAPI and documentation
**Acceptance criteria:**
- All 6 REST endpoints documented in openapi.yaml
- `WebAuthnCredentialInfo` schema, `webauthn_enabled`/`webauthn_count` on Account
- ARCHITECTURE.md §22 with design details
- PROJECT_PLAN.md Phase 14
- PROGRESS.md updated
---
## Implementation Order ## Implementation Order
``` ```
@@ -618,6 +829,13 @@ Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence)
→ Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6) → Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6)
→ Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6) → Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6)
→ Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6) → Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6)
→ Phase 10 (interleaved with Phase 3 and later phases)
→ Phase 11 (interleaved with Phase 34)
→ Phase 12 (post Phase 3)
→ Phase 13 (post Phase 3 and 11)
→ Phase 14 (post v1.0.0)
``` ```
Each step must have passing tests before the next step begins. Each step must have passing tests before the next step begins.
Phases 013 complete as of v1.0.0 (2026-03-15).
Phase 14 complete as of 2026-03-16.

View File

@@ -13,7 +13,7 @@ import (
func (t *tool) runAccount(args []string) { func (t *tool) runAccount(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp") fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp, reset-webauthn")
} }
switch args[0] { switch args[0] {
case "list": case "list":
@@ -28,6 +28,8 @@ func (t *tool) runAccount(args []string) {
t.accountSetStatus(args[1:]) t.accountSetStatus(args[1:])
case "reset-totp": case "reset-totp":
t.accountResetTOTP(args[1:]) t.accountResetTOTP(args[1:])
case "reset-webauthn":
t.webauthnReset(args[1:])
default: default:
fatalf("unknown account subcommand %q", args[0]) fatalf("unknown account subcommand %q", args[0])
} }

View File

@@ -109,6 +109,8 @@ func main() {
tool.runAudit(subArgs) tool.runAudit(subArgs)
case "pgcreds": case "pgcreds":
tool.runPGCreds(subArgs) tool.runPGCreds(subArgs)
case "webauthn":
tool.runWebAuthn(subArgs)
case "rekey": case "rekey":
tool.runRekey(subArgs) tool.runRekey(subArgs)
default: default:
@@ -245,6 +247,11 @@ Commands:
account set-password --id UUID (prompts interactively) account set-password --id UUID (prompts interactively)
account set-status --id UUID --status active|inactive|deleted account set-status --id UUID --status active|inactive|deleted
account reset-totp --id UUID account reset-totp --id UUID
account reset-webauthn --id UUID
webauthn list --id UUID
webauthn delete --id UUID --credential-id N
webauthn reset --id UUID
role list --id UUID role list --id UUID
role grant --id UUID --role ROLE role grant --id UUID --role ROLE

121
cmd/mciasdb/webauthn.go Normal file
View File

@@ -0,0 +1,121 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
)
func (t *tool) runWebAuthn(args []string) {
if len(args) == 0 {
fatalf("webauthn requires a subcommand: list, delete, reset")
}
switch args[0] {
case "list":
t.webauthnList(args[1:])
case "delete":
t.webauthnDelete(args[1:])
case "reset":
t.webauthnReset(args[1:])
default:
fatalf("unknown webauthn subcommand %q", args[0])
}
}
func (t *tool) webauthnList(args []string) {
fs := flag.NewFlagSet("webauthn list", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("webauthn list: --id is required")
}
a, err := t.db.GetAccountByUUID(*id)
if err != nil {
fatalf("get account: %v", err)
}
creds, err := t.db.GetWebAuthnCredentials(a.ID)
if err != nil {
fatalf("list webauthn credentials: %v", err)
}
if len(creds) == 0 {
fmt.Printf("No WebAuthn credentials for account %s\n", a.Username)
return
}
fmt.Printf("WebAuthn credentials for %s:\n\n", a.Username)
fmt.Printf("%-6s %-20s %-12s %-8s %-20s %-20s\n",
"ID", "NAME", "DISCOVERABLE", "COUNT", "CREATED", "LAST USED")
fmt.Println(strings.Repeat("-", 96))
for _, c := range creds {
disc := "no"
if c.Discoverable {
disc = "yes"
}
lastUsed := "never"
if c.LastUsedAt != nil {
lastUsed = c.LastUsedAt.UTC().Format("2006-01-02 15:04:05")
}
fmt.Printf("%-6d %-20s %-12s %-8d %-20s %-20s\n",
c.ID, c.Name, disc, c.SignCount,
c.CreatedAt.UTC().Format("2006-01-02 15:04:05"), lastUsed)
}
}
func (t *tool) webauthnDelete(args []string) {
fs := flag.NewFlagSet("webauthn delete", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
credID := fs.Int64("credential-id", 0, "credential DB row ID (required)")
_ = fs.Parse(args)
if *id == "" || *credID == 0 {
fatalf("webauthn delete: --id and --credential-id are required")
}
a, err := t.db.GetAccountByUUID(*id)
if err != nil {
fatalf("get account: %v", err)
}
if err := t.db.DeleteWebAuthnCredential(*credID, a.ID); err != nil {
fatalf("delete webauthn credential: %v", err)
}
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
fmt.Sprintf(`{"actor":"mciasdb","credential_id":%d}`, *credID)); err != nil {
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
}
fmt.Printf("WebAuthn credential %d deleted from account %s\n", *credID, a.Username)
}
func (t *tool) webauthnReset(args []string) {
fs := flag.NewFlagSet("webauthn reset", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("webauthn reset: --id is required")
}
a, err := t.db.GetAccountByUUID(*id)
if err != nil {
fatalf("get account: %v", err)
}
count, err := t.db.DeleteAllWebAuthnCredentials(a.ID)
if err != nil {
fatalf("delete all webauthn credentials: %v", err)
}
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
fmt.Sprintf(`{"actor":"mciasdb","action":"reset_webauthn","count":%d}`, count)); err != nil {
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
}
fmt.Printf("Removed %d WebAuthn credential(s) from account %s\n", count, a.Username)
}

View File

@@ -87,17 +87,16 @@ func run(configPath string, logger *slog.Logger) error {
masterKey, mkErr := loadMasterKey(cfg, database) masterKey, mkErr := loadMasterKey(cfg, database)
if mkErr != nil { if mkErr != nil {
// Check if we can start sealed (passphrase mode, empty env var). // Check if we can start sealed (passphrase mode, empty env var).
if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" { if cfg.MasterKey.KeyFile != "" || os.Getenv(cfg.MasterKey.PassphraseEnv) != "" {
// Verify that this is not a first run — the signing key must already exist.
enc, nonce, scErr := database.ReadServerConfig()
if scErr != nil || enc == nil || nonce == nil {
return fmt.Errorf("first run requires passphrase: %w", mkErr)
}
v = vault.NewSealed()
logger.Info("vault starting in sealed state")
} else {
return fmt.Errorf("load master key: %w", mkErr) return fmt.Errorf("load master key: %w", mkErr)
} }
// Verify that this is not a first run — the signing key must already exist.
enc, nonce, scErr := database.ReadServerConfig()
if scErr != nil || enc == nil || nonce == nil {
return fmt.Errorf("first run requires passphrase: %w", mkErr)
}
v = vault.NewSealed()
logger.Info("vault starting in sealed state")
} else { } else {
// Load or generate the Ed25519 signing key. // Load or generate the Ed25519 signing key.
// Security: The private signing key is stored AES-256-GCM encrypted in the // Security: The private signing key is stored AES-256-GCM encrypted in the

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v6.33.4 // protoc v3.20.3
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v6.33.4 // - protoc v3.20.3
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1

View File

@@ -569,6 +569,288 @@ func (*RemoveTOTPResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11} return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11}
} }
// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials.
type ListWebAuthnCredentialsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListWebAuthnCredentialsRequest) Reset() {
*x = ListWebAuthnCredentialsRequest{}
mi := &file_mcias_v1_auth_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListWebAuthnCredentialsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListWebAuthnCredentialsRequest) ProtoMessage() {}
func (x *ListWebAuthnCredentialsRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListWebAuthnCredentialsRequest.ProtoReflect.Descriptor instead.
func (*ListWebAuthnCredentialsRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{12}
}
func (x *ListWebAuthnCredentialsRequest) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential.
// Credential material (IDs, public keys) is never included.
type WebAuthnCredentialInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Aaguid string `protobuf:"bytes,3,opt,name=aaguid,proto3" json:"aaguid,omitempty"`
SignCount uint32 `protobuf:"varint,4,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"`
Discoverable bool `protobuf:"varint,5,opt,name=discoverable,proto3" json:"discoverable,omitempty"`
Transports string `protobuf:"bytes,6,opt,name=transports,proto3" json:"transports,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WebAuthnCredentialInfo) Reset() {
*x = WebAuthnCredentialInfo{}
mi := &file_mcias_v1_auth_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WebAuthnCredentialInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WebAuthnCredentialInfo) ProtoMessage() {}
func (x *WebAuthnCredentialInfo) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WebAuthnCredentialInfo.ProtoReflect.Descriptor instead.
func (*WebAuthnCredentialInfo) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{13}
}
func (x *WebAuthnCredentialInfo) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *WebAuthnCredentialInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *WebAuthnCredentialInfo) GetAaguid() string {
if x != nil {
return x.Aaguid
}
return ""
}
func (x *WebAuthnCredentialInfo) GetSignCount() uint32 {
if x != nil {
return x.SignCount
}
return 0
}
func (x *WebAuthnCredentialInfo) GetDiscoverable() bool {
if x != nil {
return x.Discoverable
}
return false
}
func (x *WebAuthnCredentialInfo) GetTransports() string {
if x != nil {
return x.Transports
}
return ""
}
func (x *WebAuthnCredentialInfo) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *WebAuthnCredentialInfo) GetLastUsedAt() *timestamppb.Timestamp {
if x != nil {
return x.LastUsedAt
}
return nil
}
// ListWebAuthnCredentialsResponse returns credential metadata.
type ListWebAuthnCredentialsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Credentials []*WebAuthnCredentialInfo `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListWebAuthnCredentialsResponse) Reset() {
*x = ListWebAuthnCredentialsResponse{}
mi := &file_mcias_v1_auth_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListWebAuthnCredentialsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListWebAuthnCredentialsResponse) ProtoMessage() {}
func (x *ListWebAuthnCredentialsResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListWebAuthnCredentialsResponse.ProtoReflect.Descriptor instead.
func (*ListWebAuthnCredentialsResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{14}
}
func (x *ListWebAuthnCredentialsResponse) GetCredentials() []*WebAuthnCredentialInfo {
if x != nil {
return x.Credentials
}
return nil
}
// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin).
type RemoveWebAuthnCredentialRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
CredentialId int64 `protobuf:"varint,2,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveWebAuthnCredentialRequest) Reset() {
*x = RemoveWebAuthnCredentialRequest{}
mi := &file_mcias_v1_auth_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RemoveWebAuthnCredentialRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RemoveWebAuthnCredentialRequest) ProtoMessage() {}
func (x *RemoveWebAuthnCredentialRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RemoveWebAuthnCredentialRequest.ProtoReflect.Descriptor instead.
func (*RemoveWebAuthnCredentialRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{15}
}
func (x *RemoveWebAuthnCredentialRequest) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
func (x *RemoveWebAuthnCredentialRequest) GetCredentialId() int64 {
if x != nil {
return x.CredentialId
}
return 0
}
// RemoveWebAuthnCredentialResponse confirms removal.
type RemoveWebAuthnCredentialResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveWebAuthnCredentialResponse) Reset() {
*x = RemoveWebAuthnCredentialResponse{}
mi := &file_mcias_v1_auth_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RemoveWebAuthnCredentialResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RemoveWebAuthnCredentialResponse) ProtoMessage() {}
func (x *RemoveWebAuthnCredentialResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RemoveWebAuthnCredentialResponse.ProtoReflect.Descriptor instead.
func (*RemoveWebAuthnCredentialResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{16}
}
var File_mcias_v1_auth_proto protoreflect.FileDescriptor var File_mcias_v1_auth_proto protoreflect.FileDescriptor
const file_mcias_v1_auth_proto_rawDesc = "" + const file_mcias_v1_auth_proto_rawDesc = "" +
@@ -601,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\x11RemoveTOTPRequest\x12\x1d\n" + "\x11RemoveTOTPRequest\x12\x1d\n" +
"\n" + "\n" +
"account_id\x18\x01 \x01(\tR\taccountId\"\x14\n" + "account_id\x18\x01 \x01(\tR\taccountId\"\x14\n" +
"\x12RemoveTOTPResponse2\xab\x03\n" + "\x12RemoveTOTPResponse\"?\n" +
"\x1eListWebAuthnCredentialsRequest\x12\x1d\n" +
"\n" +
"account_id\x18\x01 \x01(\tR\taccountId\"\xb0\x02\n" +
"\x16WebAuthnCredentialInfo\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
"\x06aaguid\x18\x03 \x01(\tR\x06aaguid\x12\x1d\n" +
"\n" +
"sign_count\x18\x04 \x01(\rR\tsignCount\x12\"\n" +
"\fdiscoverable\x18\x05 \x01(\bR\fdiscoverable\x12\x1e\n" +
"\n" +
"transports\x18\x06 \x01(\tR\n" +
"transports\x129\n" +
"\n" +
"created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
"\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"lastUsedAt\"e\n" +
"\x1fListWebAuthnCredentialsResponse\x12B\n" +
"\vcredentials\x18\x01 \x03(\v2 .mcias.v1.WebAuthnCredentialInfoR\vcredentials\"e\n" +
"\x1fRemoveWebAuthnCredentialRequest\x12\x1d\n" +
"\n" +
"account_id\x18\x01 \x01(\tR\taccountId\x12#\n" +
"\rcredential_id\x18\x02 \x01(\x03R\fcredentialId\"\"\n" +
" RemoveWebAuthnCredentialResponse2\x8e\x05\n" +
"\vAuthService\x128\n" + "\vAuthService\x128\n" +
"\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\n" + "\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\n" +
"\x06Logout\x12\x17.mcias.v1.LogoutRequest\x1a\x18.mcias.v1.LogoutResponse\x12G\n" + "\x06Logout\x12\x17.mcias.v1.LogoutRequest\x1a\x18.mcias.v1.LogoutResponse\x12G\n" +
@@ -611,7 +917,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"EnrollTOTP\x12\x1b.mcias.v1.EnrollTOTPRequest\x1a\x1c.mcias.v1.EnrollTOTPResponse\x12J\n" + "EnrollTOTP\x12\x1b.mcias.v1.EnrollTOTPRequest\x1a\x1c.mcias.v1.EnrollTOTPResponse\x12J\n" +
"\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" + "\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" +
"\n" + "\n" +
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" +
"\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" +
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_auth_proto_rawDescOnce sync.Once file_mcias_v1_auth_proto_rawDescOnce sync.Once
@@ -625,42 +933,54 @@ func file_mcias_v1_auth_proto_rawDescGZIP() []byte {
return file_mcias_v1_auth_proto_rawDescData return file_mcias_v1_auth_proto_rawDescData
} }
var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_mcias_v1_auth_proto_goTypes = []any{ var file_mcias_v1_auth_proto_goTypes = []any{
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest (*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse (*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest (*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse (*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest (*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse (*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest (*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse (*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest (*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse (*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest (*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse (*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp (*ListWebAuthnCredentialsRequest)(nil), // 12: mcias.v1.ListWebAuthnCredentialsRequest
(*WebAuthnCredentialInfo)(nil), // 13: mcias.v1.WebAuthnCredentialInfo
(*ListWebAuthnCredentialsResponse)(nil), // 14: mcias.v1.ListWebAuthnCredentialsResponse
(*RemoveWebAuthnCredentialRequest)(nil), // 15: mcias.v1.RemoveWebAuthnCredentialRequest
(*RemoveWebAuthnCredentialResponse)(nil), // 16: mcias.v1.RemoveWebAuthnCredentialResponse
(*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp
} }
var file_mcias_v1_auth_proto_depIdxs = []int32{ var file_mcias_v1_auth_proto_depIdxs = []int32{
12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp 17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp 17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest 17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp
2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest 17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp
4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest 13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo
6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest 0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest 2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest 4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse 6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse 8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse 10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse 12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest
9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse 15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest
11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse 1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
8, // [8:14] is the sub-list for method output_type 3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
2, // [2:8] is the sub-list for method input_type 5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
2, // [2:2] is the sub-list for extension type_name 7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
2, // [2:2] is the sub-list for extension extendee 9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
0, // [0:2] is the sub-list for field type_name 11, // 18: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
14, // 19: mcias.v1.AuthService.ListWebAuthnCredentials:output_type -> mcias.v1.ListWebAuthnCredentialsResponse
16, // 20: mcias.v1.AuthService.RemoveWebAuthnCredential:output_type -> mcias.v1.RemoveWebAuthnCredentialResponse
13, // [13:21] is the sub-list for method output_type
5, // [5:13] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
} }
func init() { file_mcias_v1_auth_proto_init() } func init() { file_mcias_v1_auth_proto_init() }
@@ -674,7 +994,7 @@ func file_mcias_v1_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 12, NumMessages: 17,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@@ -21,12 +21,14 @@ import (
const _ = grpc.SupportPackageIsVersion9 const _ = grpc.SupportPackageIsVersion9
const ( const (
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login" AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout" AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken" AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP" AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP" AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP" AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
AuthService_ListWebAuthnCredentials_FullMethodName = "/mcias.v1.AuthService/ListWebAuthnCredentials"
AuthService_RemoveWebAuthnCredential_FullMethodName = "/mcias.v1.AuthService/RemoveWebAuthnCredential"
) )
// AuthServiceClient is the client API for AuthService service. // AuthServiceClient is the client API for AuthService service.
@@ -53,6 +55,12 @@ type AuthServiceClient interface {
// RemoveTOTP removes TOTP from an account (admin only). // RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata. // Requires: admin JWT in metadata.
RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error) RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error)
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
// Requires: admin JWT in metadata.
ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error)
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
// Requires: admin JWT in metadata.
RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error)
} }
type authServiceClient struct { type authServiceClient struct {
@@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques
return out, nil return out, nil
} }
func (c *authServiceClient) ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListWebAuthnCredentialsResponse)
err := c.cc.Invoke(ctx, AuthService_ListWebAuthnCredentials_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveWebAuthnCredentialResponse)
err := c.cc.Invoke(ctx, AuthService_RemoveWebAuthnCredential_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServiceServer is the server API for AuthService service. // AuthServiceServer is the server API for AuthService service.
// All implementations must embed UnimplementedAuthServiceServer // All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility. // for forward compatibility.
@@ -147,6 +175,12 @@ type AuthServiceServer interface {
// RemoveTOTP removes TOTP from an account (admin only). // RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata. // Requires: admin JWT in metadata.
RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error)
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
// Requires: admin JWT in metadata.
ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error)
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
// Requires: admin JWT in metadata.
RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error)
mustEmbedUnimplementedAuthServiceServer() mustEmbedUnimplementedAuthServiceServer()
} }
@@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) { func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented") return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented")
} }
func (UnimplementedAuthServiceServer) ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListWebAuthnCredentials not implemented")
}
func (UnimplementedAuthServiceServer) RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveWebAuthnCredential not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {} func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {} func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
@@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AuthService_ListWebAuthnCredentials_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListWebAuthnCredentialsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_ListWebAuthnCredentials_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, req.(*ListWebAuthnCredentialsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_RemoveWebAuthnCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveWebAuthnCredentialRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_RemoveWebAuthnCredential_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, req.(*RemoveWebAuthnCredentialRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service. // AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RemoveTOTP", MethodName: "RemoveTOTP",
Handler: _AuthService_RemoveTOTP_Handler, Handler: _AuthService_RemoveTOTP_Handler,
}, },
{
MethodName: "ListWebAuthnCredentials",
Handler: _AuthService_ListWebAuthnCredentials_Handler,
},
{
MethodName: "RemoveWebAuthnCredential",
Handler: _AuthService_RemoveWebAuthnCredential_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "mcias/v1/auth.proto", Metadata: "mcias/v1/auth.proto",

18
go.mod
View File

@@ -7,8 +7,8 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.49.0
golang.org/x/term v0.37.0 golang.org/x/term v0.41.0
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.7 google.golang.org/protobuf v1.36.7
modernc.org/sqlite v1.46.1 modernc.org/sqlite v1.46.1
@@ -16,13 +16,21 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/webauthn v0.16.1 // indirect
github.com/go-webauthn/x v0.2.2 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

50
go.sum
View File

@@ -2,10 +2,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
@@ -14,6 +22,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -32,8 +42,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
@@ -46,25 +60,25 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=

View File

@@ -7,9 +7,9 @@ import (
func TestJSON(t *testing.T) { func TestJSON(t *testing.T) {
tests := []struct { tests := []struct {
verify func(t *testing.T, result string)
name string name string
pairs []string pairs []string
verify func(t *testing.T, result string)
}{ }{
{ {
name: "single pair", name: "single pair",
@@ -109,9 +109,9 @@ func TestJSON(t *testing.T) {
func TestJSONWithRoles(t *testing.T) { func TestJSONWithRoles(t *testing.T) {
tests := []struct { tests := []struct {
verify func(t *testing.T, result string)
name string name string
roles []string roles []string
verify func(t *testing.T, result string)
}{ }{
{ {
name: "multiple roles", name: "multiple roles",

View File

@@ -8,18 +8,29 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"strings"
"time" "time"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
) )
// Config is the top-level configuration structure parsed from the TOML file. // 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"` Server ServerConfig `toml:"server"`
MasterKey MasterKeyConfig `toml:"master_key"` MasterKey MasterKeyConfig `toml:"master_key"`
Database DatabaseConfig `toml:"database"` Database DatabaseConfig `toml:"database"`
Tokens TokensConfig `toml:"tokens"` Tokens TokensConfig `toml:"tokens"`
Argon2 Argon2Config `toml:"argon2"` 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. // ServerConfig holds HTTP listener and TLS settings.
@@ -174,8 +185,8 @@ func (c *Config) validate() error {
// generous to accommodate a range of legitimate deployments while // generous to accommodate a range of legitimate deployments while
// catching obvious typos (e.g. "876000h" instead of "8760h"). // catching obvious typos (e.g. "876000h" instead of "8760h").
const ( const (
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
maxAdminExpiry = 24 * time.Hour // 24 hours maxAdminExpiry = 24 * time.Hour // 24 hours
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
) )
if c.Tokens.DefaultExpiry.Duration <= 0 { if c.Tokens.DefaultExpiry.Duration <= 0 {
@@ -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")) 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...) 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. // ServiceExpiry returns the configured service token expiry duration.
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration } func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
func (c *Config) WebAuthnEnabled() bool {
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
}

View File

@@ -213,9 +213,9 @@ threads = 4
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP. // TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
func TestTrustedProxyValidation(t *testing.T) { func TestTrustedProxyValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
proxy string proxy string
wantErr bool wantErr bool
}{ }{
{"empty is valid (disabled)", "", false}, {"empty is valid (disabled)", "", false},
{"valid IPv4", "127.0.0.1", false}, {"valid IPv4", "127.0.0.1", false},

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -361,8 +361,8 @@ func TestClientIP(t *testing.T) {
remoteAddr string remoteAddr string
xForwardedFor string xForwardedFor string
xRealIP string xRealIP string
trustedProxy net.IP
want string want string
trustedProxy net.IP
}{ }{
{ {
name: "no proxy configured: uses RemoteAddr", name: "no proxy configured: uses RemoteAddr",
@@ -377,11 +377,11 @@ func TestClientIP(t *testing.T) {
want: "198.51.100.9", want: "198.51.100.9",
}, },
{ {
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP", name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
remoteAddr: "10.0.0.1:8080", remoteAddr: "10.0.0.1:8080",
xRealIP: "203.0.113.42", xRealIP: "203.0.113.42",
trustedProxy: proxy, trustedProxy: proxy,
want: "203.0.113.42", want: "203.0.113.42",
}, },
{ {
name: "request from trusted proxy with X-Forwarded-For: uses first entry", name: "request from trusted proxy with X-Forwarded-For: uses first entry",
@@ -407,10 +407,10 @@ func TestClientIP(t *testing.T) {
want: "203.0.113.55", want: "203.0.113.55",
}, },
{ {
name: "proxy request with no forwarding headers falls back to RemoteAddr host", name: "proxy request with no forwarding headers falls back to RemoteAddr host",
remoteAddr: "10.0.0.1:8080", remoteAddr: "10.0.0.1:8080",
trustedProxy: proxy, trustedProxy: proxy,
want: "10.0.0.1", want: "10.0.0.1",
}, },
{ {
// Security: attacker fakes X-Forwarded-For but connects directly. // Security: attacker fakes X-Forwarded-For but connects directly.

View File

@@ -213,6 +213,11 @@ const (
EventTokenDelegateGranted = "token_delegate_granted" EventTokenDelegateGranted = "token_delegate_granted"
EventTokenDelegateRevoked = "token_delegate_revoked" 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 // ServiceAccountDelegate records that a specific account has been granted
@@ -229,6 +234,26 @@ type ServiceAccountDelegate struct {
GranteeID int64 `json:"-"` 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. // PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields). // RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns. // The ID, Priority, and Description are stored as dedicated columns.

View File

@@ -81,6 +81,16 @@ var defaultRules = []Rule{
OwnerMatchesSubject: true, OwnerMatchesSubject: true,
Effect: Allow, 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 // Public endpoints: token validation and login do not require
// authentication. The middleware exempts them from RequireAuth entirely; // authentication. The middleware exempts them from RequireAuth entirely;

View File

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

View File

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

View File

@@ -232,6 +232,14 @@ func (s *Server) Handler() http.Handler {
if err != nil { if err != nil {
panic(fmt.Sprintf("server: read openapi.yaml: %v", err)) panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
} }
swaggerJS, err := fs.ReadFile(staticFS, "swagger-ui-bundle.js")
if err != nil {
panic(fmt.Sprintf("server: read swagger-ui-bundle.js: %v", err))
}
swaggerCSS, err := fs.ReadFile(staticFS, "swagger-ui.css")
if err != nil {
panic(fmt.Sprintf("server: read swagger-ui.css: %v", err))
}
// Security (DEF-09): apply defensive HTTP headers to the docs handlers. // Security (DEF-09): apply defensive HTTP headers to the docs handlers.
// The Swagger UI page at /docs loads JavaScript from the same origin // The Swagger UI page at /docs loads JavaScript from the same origin
// and renders untrusted content (API descriptions), so it benefits from // and renders untrusted content (API descriptions), so it benefits from
@@ -246,6 +254,16 @@ func (s *Server) Handler() http.Handler {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(specYAML) _, _ = w.Write(specYAML)
}))) })))
mux.Handle("GET /static/swagger-ui-bundle.js", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(swaggerJS)
})))
mux.Handle("GET /static/swagger-ui.css", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/css")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(swaggerCSS)
})))
// Vault endpoints (exempt from sealed middleware and auth). // Vault endpoints (exempt from sealed middleware and auth).
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy) unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
@@ -290,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/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm))) 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). // Policy-gated endpoints (formerly admin-only; now controlled by the engine).
mux.Handle("DELETE /v1/auth/totp", mux.Handle("DELETE /v1/auth/totp",
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove))) requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
@@ -320,6 +345,11 @@ func (s *Server) Handler() http.Handler {
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds))) requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/pgcreds", mux.Handle("PUT /v1/accounts/{id}/pgcreds",
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds))) 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", mux.Handle("GET /v1/audit",
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit))) requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags", mux.Handle("GET /v1/accounts/{id}/tags",

View File

@@ -29,15 +29,9 @@ import (
// on the next unseal. This is safe because sealed middleware prevents // on the next unseal. This is safe because sealed middleware prevents
// reaching CSRF-protected routes. // reaching CSRF-protected routes.
type CSRFManager struct { type CSRFManager struct {
mu sync.Mutex
key []byte
vault *vault.Vault vault *vault.Vault
} key []byte
mu sync.Mutex
// newCSRFManager creates a CSRFManager with a static key derived from masterKey.
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
func newCSRFManager(masterKey []byte) *CSRFManager {
return &CSRFManager{key: deriveCSRFKey(masterKey)}
} }
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily // newCSRFManagerFromVault creates a CSRFManager that derives its key lazily

View File

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

View File

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

View File

@@ -129,46 +129,69 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
priority = p priority = p
} }
effectStr := r.FormValue("effect") var ruleJSON []byte
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
return
}
body := policy.RuleBody{ if rawJSON := strings.TrimSpace(r.FormValue("rule_json")); rawJSON != "" {
Effect: policy.Effect(effectStr), // JSON mode: parse and re-marshal to normalise and validate the input.
} var body policy.RuleBody
if err := json.Unmarshal([]byte(rawJSON), &body); err != nil {
// Multi-value fields. u.renderError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid rule JSON: %v", err))
if roles := r.Form["roles"]; len(roles) > 0 { return
body.Roles = roles }
} if body.Effect != policy.Allow && body.Effect != policy.Deny {
if types := r.Form["account_types"]; len(types) > 0 { u.renderError(w, r, http.StatusBadRequest, "rule JSON must include effect 'allow' or 'deny'")
body.AccountTypes = types return
} }
if actions := r.Form["actions"]; len(actions) > 0 { var err error
acts := make([]policy.Action, len(actions)) ruleJSON, err = json.Marshal(body)
for i, a := range actions { if err != nil {
acts[i] = policy.Action(a) u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
} else {
// Form mode: build RuleBody from individual fields.
effectStr := r.FormValue("effect")
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
return
} }
body.Actions = acts
}
if resType := r.FormValue("resource_type"); resType != "" {
body.ResourceType = policy.ResourceType(resType)
}
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
if svcNames := r.FormValue("service_names"); svcNames != "" {
body.ServiceNames = splitCommas(svcNames)
}
if tags := r.FormValue("required_tags"); tags != "" {
body.RequiredTags = splitCommas(tags)
}
ruleJSON, err := json.Marshal(body) body := policy.RuleBody{
if err != nil { Effect: policy.Effect(effectStr),
u.renderError(w, r, http.StatusInternalServerError, "internal error") }
return
// Multi-value fields.
if roles := r.Form["roles"]; len(roles) > 0 {
body.Roles = roles
}
if types := r.Form["account_types"]; len(types) > 0 {
body.AccountTypes = types
}
if actions := r.Form["actions"]; len(actions) > 0 {
acts := make([]policy.Action, len(actions))
for i, a := range actions {
acts[i] = policy.Action(a)
}
body.Actions = acts
}
if resType := r.FormValue("resource_type"); resType != "" {
body.ResourceType = policy.ResourceType(resType)
}
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
if svcNames := r.FormValue("service_names"); svcNames != "" {
body.ServiceNames = splitCommas(svcNames)
}
if tags := r.FormValue("required_tags"); tags != "" {
body.RequiredTags = splitCommas(tags)
}
var err error
ruleJSON, err = json.Marshal(body)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
} }
// Parse optional time-scoped validity window from datetime-local inputs. // Parse optional time-scoped validity window from datetime-local inputs.

View File

@@ -0,0 +1,287 @@
package ui
import (
"encoding/base32"
"encoding/base64"
"fmt"
"net/http"
qrcode "github.com/skip2/go-qrcode"
"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"
)
// handleTOTPEnrollStart processes the password re-auth step and generates
// the TOTP secret + QR code for the user to scan.
//
// Security (SEC-01): the current password is required to prevent a stolen
// session from enrolling attacker-controlled TOTP. Lockout is checked and
// failures are recorded to prevent brute-force use as a password oracle.
func (u *UIServer) handleTOTPEnrollStart(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
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
}
// Already enrolled — show enabled status.
if acct.TOTPRequired {
u.renderTOTPSection(w, r, ProfileData{TOTPEnabled: true})
return
}
password := r.FormValue("password")
if password == "" {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is required"})
return
}
// Security: check lockout before verifying password.
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (UI TOTP enroll)", "error", lockErr)
}
if locked {
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "account temporarily locked, please try again later"})
return
}
// Security: verify current password with constant-time Argon2id path.
ok, verifyErr := auth.VerifyPassword(password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = u.db.RecordLoginFailure(acct.ID)
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is incorrect"})
return
}
// Generate TOTP secret.
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
if err != nil {
u.logger.Error("generate TOTP secret", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
// Encrypt and store as pending (totp_required stays 0 until confirmed).
masterKey, err := u.vault.MasterKey()
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
// remains 0 until the user proves possession via ConfirmTOTP.
if err := u.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
u.logger.Error("store pending TOTP", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
// Generate QR code PNG.
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
if err != nil {
u.logger.Error("generate QR code", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
// Issue enrollment nonce for the confirm step.
nonce, err := u.issueTOTPEnrollNonce(acct.ID)
if err != nil {
u.logger.Error("issue TOTP enroll nonce", "error", err)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "totp_enroll_qr", ProfileData{
PageData: PageData{CSRFToken: csrfToken},
TOTPSecret: b32Secret,
TOTPQR: qrDataURI,
TOTPEnrollNonce: nonce,
})
}
// handleTOTPConfirm validates the TOTP code and activates enrollment.
//
// Security (CRIT-01): the counter is recorded to prevent replay of the same
// code within its validity window.
func (u *UIServer) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
nonce := r.FormValue("totp_enroll_nonce")
totpCode := r.FormValue("totp_code")
// Security: consume the nonce (single-use); reject if unknown or expired.
accountID, ok := u.consumeTOTPEnrollNonce(nonce)
if !ok {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session expired, please start enrollment again"})
return
}
acct, err := u.db.GetAccountByID(accountID)
if err != nil {
u.logger.Error("get account for TOTP confirm", "error", err, "account_id", accountID)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
// Security: verify nonce accountID matches session claims.
if acct.UUID != claims.Subject {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session mismatch"})
return
}
if acct.TOTPSecretEnc == nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "enrollment not started"})
return
}
// Decrypt and validate TOTP code.
masterKey, err := u.vault.MasterKey()
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
if err != nil {
u.logger.Error("decrypt TOTP secret for confirm", "error", err, "account_id", acct.ID)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
valid, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
if err != nil || !valid {
// Re-issue a fresh nonce so the user can retry without restarting.
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
return
}
// Security (CRIT-01): reject replay of a code already used.
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
return
}
// Activate TOTP (sets totp_required=1).
if err := u.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
u.logger.Error("set TOTP", "error", err, "account_id", acct.ID)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, nil, "")
u.renderTOTPSection(w, r, ProfileData{
TOTPEnabled: true,
TOTPSuccess: "Two-factor authentication enabled successfully.",
})
}
// reissueTOTPEnrollQR re-renders the QR code page with a fresh nonce after
// a failed code confirmation, so the user can retry without restarting.
func (u *UIServer) reissueTOTPEnrollQR(w http.ResponseWriter, r *http.Request, acct *model.Account, secret []byte, errMsg string) {
b32Secret := base32.StdEncoding.EncodeToString(secret)
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
if err != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
newNonce, nonceErr := u.issueTOTPEnrollNonce(acct.ID)
if nonceErr != nil {
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return
}
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "totp_enroll_qr", ProfileData{
PageData: PageData{CSRFToken: csrfToken},
TOTPSecret: b32Secret,
TOTPQR: qrDataURI,
TOTPEnrollNonce: newNonce,
TOTPError: errMsg,
})
}
// handleAdminTOTPRemove removes TOTP from an account (admin only).
func (u *UIServer) handleAdminTOTPRemove(w http.ResponseWriter, r *http.Request) {
accountUUID := r.PathValue("id")
if accountUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "missing account ID")
return
}
acct, err := u.db.GetAccountByUUID(accountUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if err := u.db.ClearTOTP(acct.ID); err != nil {
u.logger.Error("clear TOTP (admin)", "error", err, "account_id", acct.ID)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
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.EventTOTPRemoved, actorID, &acct.ID,
audit.JSON("admin", "true"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, `Disabled <span class="text-muted text-small">(removed)</span>`)
}
// renderTOTPSection is a helper to render the totp_section fragment with
// common page data fields populated.
func (u *UIServer) renderTOTPSection(w http.ResponseWriter, r *http.Request, data ProfileData) {
csrfToken, _ := u.setCSRFCookies(w)
data.CSRFToken = csrfToken
data.ActorName = u.actorName(r)
data.IsAdmin = isAdmin(r)
u.render(w, "totp_section", data)
}

View File

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

View File

@@ -71,14 +71,15 @@ const tokenDownloadTTL = 5 * time.Minute
// UIServer serves the HTMX-based management UI. // UIServer serves the HTMX-based management UI.
type UIServer struct { type UIServer struct {
tmpls map[string]*template.Template // page name → template set tmpls map[string]*template.Template // page name → template set
db *db.DB db *db.DB
cfg *config.Config cfg *config.Config
logger *slog.Logger logger *slog.Logger
csrf *CSRFManager csrf *CSRFManager
vault *vault.Vault vault *vault.Vault
pendingLogins sync.Map // nonce (string) → *pendingLogin pendingLogins sync.Map // nonce (string) → *pendingLogin
tokenDownloads sync.Map // nonce (string) → *tokenDownload tokenDownloads sync.Map // nonce (string) → *tokenDownload
pendingTOTPEnrolls sync.Map // nonce (string) → *pendingTOTPEnroll
} }
// issueTOTPNonce creates a random single-use nonce for the TOTP step and // issueTOTPNonce creates a random single-use nonce for the TOTP step and
@@ -113,6 +114,48 @@ func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
return pl.accountID, true return pl.accountID, true
} }
// pendingTOTPEnroll stores the account ID for a TOTP enrollment ceremony
// that has passed password re-auth and generated a secret, awaiting code
// confirmation.
type pendingTOTPEnroll struct {
expiresAt time.Time
accountID int64
}
const totpEnrollTTL = 5 * time.Minute
// issueTOTPEnrollNonce creates a random single-use nonce for the TOTP
// enrollment confirmation step.
func (u *UIServer) issueTOTPEnrollNonce(accountID int64) (string, error) {
raw := make([]byte, totpNonceBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("ui: generate TOTP enroll nonce: %w", err)
}
nonce := hex.EncodeToString(raw)
u.pendingTOTPEnrolls.Store(nonce, &pendingTOTPEnroll{
accountID: accountID,
expiresAt: time.Now().Add(totpEnrollTTL),
})
return nonce, nil
}
// consumeTOTPEnrollNonce looks up and deletes the enrollment nonce,
// returning the associated account ID. Returns (0, false) if unknown or expired.
func (u *UIServer) consumeTOTPEnrollNonce(nonce string) (int64, bool) {
v, ok := u.pendingTOTPEnrolls.LoadAndDelete(nonce)
if !ok {
return 0, false
}
pe, ok2 := v.(*pendingTOTPEnroll)
if !ok2 {
return 0, false
}
if time.Now().After(pe.expiresAt) {
return 0, false
}
return pe.accountID, true
}
// dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy // dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy
// verification when an account is unknown or inactive (F-07). // verification when an account is unknown or inactive (F-07).
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation. // Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
@@ -177,6 +220,13 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
} }
return *actorID == *cred.OwnerID 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 }, "add": func(a, b int) int { return a + b },
"sub": 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 }, "gt": func(a, b int) bool { return a > b },
@@ -213,6 +263,10 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
"templates/fragments/password_reset_form.html", "templates/fragments/password_reset_form.html",
"templates/fragments/password_change_form.html", "templates/fragments/password_change_form.html",
"templates/fragments/token_delegates.html", "templates/fragments/token_delegates.html",
"templates/fragments/webauthn_credentials.html",
"templates/fragments/webauthn_enroll.html",
"templates/fragments/totp_section.html",
"templates/fragments/totp_enroll_qr.html",
} }
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...) base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil { if err != nil {
@@ -261,6 +315,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
// accumulate indefinitely, enabling a memory-exhaustion attack. // accumulate indefinitely, enabling a memory-exhaustion attack.
go srv.cleanupPendingLogins() go srv.cleanupPendingLogins()
go srv.cleanupTokenDownloads() go srv.cleanupTokenDownloads()
go srv.cleanupPendingTOTPEnrolls()
return srv, nil return srv, nil
} }
@@ -333,6 +388,22 @@ func (u *UIServer) cleanupTokenDownloads() {
} }
} }
// cleanupPendingTOTPEnrolls periodically evicts expired TOTP enrollment nonces.
func (u *UIServer) cleanupPendingTOTPEnrolls() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
u.pendingTOTPEnrolls.Range(func(key, value any) bool {
pe, ok := value.(*pendingTOTPEnroll)
if !ok || now.After(pe.expiresAt) {
u.pendingTOTPEnrolls.Delete(key)
}
return true
})
}
}
// Register attaches all UI routes to mux, wrapped with security headers. // Register attaches all UI routes to mux, wrapped with security headers.
// All UI responses (pages, fragments, redirects, static assets) carry the // All UI responses (pages, fragments, redirects, static assets) carry the
// headers added by securityHeaders. // headers added by securityHeaders.
@@ -378,6 +449,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.HandleFunc("GET /login", u.handleLoginPage) uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost))) uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
uiMux.HandleFunc("POST /logout", u.handleLogout) 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. // Protected routes.
// //
@@ -432,6 +506,17 @@ func (u *UIServer) Register(mux *http.ServeMux) {
// Profile routes — accessible to any authenticated user (not admin-only). // Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage))) uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword)))) 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))))
// TOTP profile routes (enrollment).
uiMux.Handle("POST /profile/totp/enroll", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPEnrollStart))))
uiMux.Handle("POST /profile/totp/confirm", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPConfirm))))
// Admin WebAuthn management.
uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete))
// Admin TOTP removal.
uiMux.Handle("DELETE /accounts/{id}/totp", admin(u.handleAdminTOTPRemove))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a // 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 // catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -729,6 +814,8 @@ type LoginData struct {
// a short-lived server-side nonce is issued after successful password // a short-lived server-side nonce is issued after successful password
// verification, and only the nonce is embedded in the TOTP step form. // verification, and only the nonce is embedded in the TOTP step form.
Nonce string // single-use server-side nonce replacing the password hidden field 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. // DashboardData is the view model for the dashboard page.
@@ -746,7 +833,7 @@ type AccountsData struct {
} }
// AccountDetailData is the view model for the account detail page. // 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 Account *model.Account
// PGCred is nil if none stored or the account is not a system account. // PGCred is nil if none stored or the account is not a system account.
PGCred *model.PGCredential PGCred *model.PGCredential
@@ -772,10 +859,15 @@ type AccountDetailData struct {
AllRoles []string AllRoles []string
Tags []string Tags []string
Tokens []*model.TokenRecord 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 // CanIssueToken is true when the viewing actor may issue tokens for this
// system account (admin role or explicit delegate grant). // system account (admin role or explicit delegate grant).
// Placed last to minimise GC scan area. // Placed last to minimise GC scan area.
CanIssueToken bool CanIssueToken bool
WebAuthnEnabled bool
} }
// ServiceAccountsData is the view model for the /service-accounts page. // ServiceAccountsData is the view model for the /service-accounts page.
@@ -832,8 +924,18 @@ type PoliciesData struct {
} }
// ProfileData is the view model for the profile/settings page. // 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 PageData
WebAuthnCreds []*model.WebAuthnCredential
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
WebAuthnEnabled bool
// TOTP enrollment fields (populated only during enrollment flow).
TOTPEnabled bool
TOTPSecret string // base32-encoded; shown once during enrollment
TOTPQR string // data:image/png;base64,... QR code
TOTPEnrollNonce string // single-use nonce for confirm step
TOTPError string // enrollment-specific error message
TOTPSuccess string // success flash after confirmation
} }
// PGCredsData is the view model for the "My PG Credentials" list page. // PGCredsData is the view model for the "My PG Credentials" list page.

View File

@@ -24,10 +24,10 @@ var ErrSealed = errors.New("vault is sealed")
// Vault holds the server's cryptographic key material behind a mutex. // Vault holds the server's cryptographic key material behind a mutex.
// All three servers (REST, UI, gRPC) share a single Vault by pointer. // All three servers (REST, UI, gRPC) share a single Vault by pointer.
type Vault struct { type Vault struct {
mu sync.RWMutex
masterKey []byte masterKey []byte
privKey ed25519.PrivateKey privKey ed25519.PrivateKey
pubKey ed25519.PublicKey pubKey ed25519.PublicKey
mu sync.RWMutex
sealed bool sealed bool
} }

View File

@@ -3,6 +3,7 @@ package vault
import ( import (
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"errors"
"sync" "sync"
"testing" "testing"
) )
@@ -25,13 +26,13 @@ func TestNewSealed(t *testing.T) {
if !v.IsSealed() { if !v.IsSealed() {
t.Fatal("NewSealed() should be sealed") t.Fatal("NewSealed() should be sealed")
} }
if _, err := v.MasterKey(); err != ErrSealed { if _, err := v.MasterKey(); !errors.Is(err, ErrSealed) {
t.Fatalf("MasterKey() error = %v, want ErrSealed", err) t.Fatalf("MasterKey() error = %v, want ErrSealed", err)
} }
if _, err := v.PrivKey(); err != ErrSealed { if _, err := v.PrivKey(); !errors.Is(err, ErrSealed) {
t.Fatalf("PrivKey() error = %v, want ErrSealed", err) t.Fatalf("PrivKey() error = %v, want ErrSealed", err)
} }
if _, err := v.PubKey(); err != ErrSealed { if _, err := v.PubKey(); !errors.Is(err, ErrSealed) {
t.Fatalf("PubKey() error = %v, want ErrSealed", err) t.Fatalf("PubKey() error = %v, want ErrSealed", err)
} }
} }

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -14,8 +14,10 @@ info:
10 requests per second per IP, burst of 10. 10 requests per second per IP, burst of 10.
servers: servers:
- url: https://auth.example.com:8443 - url: https://mcias.metacircular.net:8443
description: Production description: Production
- url: https://localhost:8443
description: Local test server
components: components:
securitySchemes: securitySchemes:
@@ -84,6 +86,54 @@ components:
type: boolean type: boolean
description: Whether TOTP is enrolled and required for this account. description: Whether TOTP is enrolled and required for this account.
example: false 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: AuditEvent:
type: object type: object
@@ -845,6 +895,213 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $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: /v1/token/issue:
post: post:
summary: Issue service account token (admin) summary: Issue service account token (admin)

View File

@@ -75,6 +75,40 @@ message RemoveTOTPRequest {
// RemoveTOTPResponse confirms removal. // RemoveTOTPResponse confirms removal.
message RemoveTOTPResponse {} message RemoveTOTPResponse {}
// --- WebAuthn ---
// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials.
message ListWebAuthnCredentialsRequest {
string account_id = 1; // UUID
}
// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential.
// Credential material (IDs, public keys) is never included.
message WebAuthnCredentialInfo {
int64 id = 1;
string name = 2;
string aaguid = 3;
uint32 sign_count = 4;
bool discoverable = 5;
string transports = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp last_used_at = 8;
}
// ListWebAuthnCredentialsResponse returns credential metadata.
message ListWebAuthnCredentialsResponse {
repeated WebAuthnCredentialInfo credentials = 1;
}
// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin).
message RemoveWebAuthnCredentialRequest {
string account_id = 1; // UUID
int64 credential_id = 2;
}
// RemoveWebAuthnCredentialResponse confirms removal.
message RemoveWebAuthnCredentialResponse {}
// AuthService handles all authentication flows. // AuthService handles all authentication flows.
service AuthService { service AuthService {
// Login authenticates with username+password (+optional TOTP) and returns a JWT. // Login authenticates with username+password (+optional TOTP) and returns a JWT.
@@ -100,4 +134,12 @@ service AuthService {
// RemoveTOTP removes TOTP from an account (admin only). // RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata. // Requires: admin JWT in metadata.
rpc RemoveTOTP(RemoveTOTPRequest) returns (RemoveTOTPResponse); rpc RemoveTOTP(RemoveTOTPRequest) returns (RemoveTOTPResponse);
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
// Requires: admin JWT in metadata.
rpc ListWebAuthnCredentials(ListWebAuthnCredentialsRequest) returns (ListWebAuthnCredentialsResponse);
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
// Requires: admin JWT in metadata.
rpc RemoveWebAuthnCredential(RemoveWebAuthnCredentialRequest) returns (RemoveWebAuthnCredentialResponse);
} }

View File

@@ -4,11 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCIAS API Reference</title> <title>MCIAS API Reference</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"> <link rel="stylesheet" href="/static/swagger-ui.css">
</head> </head>
<body> <body>
<div id="swagger-ui"></div> <div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script> <script src="/static/swagger-ui-bundle.js"></script>
<script> <script>
SwaggerUIBundle({ SwaggerUIBundle({
url: "/docs/openapi.yaml", url: "/docs/openapi.yaml",

View File

@@ -14,8 +14,10 @@ info:
10 requests per second per IP, burst of 10. 10 requests per second per IP, burst of 10.
servers: servers:
- url: https://auth.example.com:8443 - url: https://mcias.metacircular.net:8443
description: Production description: Production
- url: https://localhost:8443
description: Local test server
components: components:
securitySchemes: securitySchemes:
@@ -84,6 +86,54 @@ components:
type: boolean type: boolean
description: Whether TOTP is enrolled and required for this account. description: Whether TOTP is enrolled and required for this account.
example: false 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: AuditEvent:
type: object type: object
@@ -845,6 +895,213 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $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: /v1/token/issue:
post: post:
summary: Issue service account token (admin) summary: Issue service account token (admin)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

215
web/static/webauthn.js Normal file
View File

@@ -0,0 +1,215 @@
// webauthn.js — WebAuthn/passkey helpers for the MCIAS web UI.
// CSP-compliant: loaded as an external script, no inline code.
(function () {
'use strict';
// Base64URL encode/decode helpers for WebAuthn ArrayBuffer <-> JSON transport.
function base64urlEncode(buffer) {
var bytes = new Uint8Array(buffer);
var str = '';
for (var i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64urlDecode(str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) { str += '='; }
var binary = atob(str);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
// Get the CSRF token from the cookie for mutating requests.
function getCSRFToken() {
var match = document.cookie.match(/(?:^|;\s*)mcias_csrf=([^;]+)/);
return match ? match[1] : '';
}
function showError(id, msg) {
var el = document.getElementById(id);
if (el) { el.textContent = msg; el.style.display = ''; }
}
function hideError(id) {
var el = document.getElementById(id);
if (el) { el.style.display = 'none'; el.textContent = ''; }
}
// mciasWebAuthnRegister initiates a passkey/security-key registration.
window.mciasWebAuthnRegister = function (password, name, onSuccess, onError) {
if (!window.PublicKeyCredential) {
onError('WebAuthn is not supported in this browser.');
return;
}
var csrf = getCSRFToken();
var savedNonce = '';
fetch('/profile/webauthn/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ password: password, name: name })
})
.then(function (resp) {
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Registration failed'); });
return resp.json();
})
.then(function (data) {
savedNonce = data.nonce;
var opts = data.options;
opts.publicKey.challenge = base64urlDecode(opts.publicKey.challenge);
if (opts.publicKey.user && opts.publicKey.user.id) {
opts.publicKey.user.id = base64urlDecode(opts.publicKey.user.id);
}
if (opts.publicKey.excludeCredentials) {
for (var i = 0; i < opts.publicKey.excludeCredentials.length; i++) {
opts.publicKey.excludeCredentials[i].id = base64urlDecode(opts.publicKey.excludeCredentials[i].id);
}
}
return navigator.credentials.create(opts);
})
.then(function (credential) {
if (!credential) throw new Error('Registration cancelled');
var credJSON = {
id: credential.id,
rawId: base64urlEncode(credential.rawId),
type: credential.type,
response: {
attestationObject: base64urlEncode(credential.response.attestationObject),
clientDataJSON: base64urlEncode(credential.response.clientDataJSON)
}
};
if (credential.response.getTransports) {
credJSON.response.transports = credential.response.getTransports();
}
return fetch('/profile/webauthn/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ nonce: savedNonce, name: name, credential: credJSON })
});
})
.then(function (resp) {
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Registration failed'); });
return resp.json();
})
.then(function (result) { onSuccess(result); })
.catch(function (err) { onError(err.message || 'Registration failed'); });
};
// mciasWebAuthnLogin initiates a passkey login.
window.mciasWebAuthnLogin = function (username, onSuccess, onError) {
if (!window.PublicKeyCredential) {
onError('WebAuthn is not supported in this browser.');
return;
}
var savedNonce = '';
fetch('/login/webauthn/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username || '' })
})
.then(function (resp) {
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); });
return resp.json();
})
.then(function (data) {
savedNonce = data.nonce;
var opts = data.options;
opts.publicKey.challenge = base64urlDecode(opts.publicKey.challenge);
if (opts.publicKey.allowCredentials) {
for (var i = 0; i < opts.publicKey.allowCredentials.length; i++) {
opts.publicKey.allowCredentials[i].id = base64urlDecode(opts.publicKey.allowCredentials[i].id);
}
}
return navigator.credentials.get(opts);
})
.then(function (assertion) {
if (!assertion) throw new Error('Login cancelled');
var credJSON = {
id: assertion.id,
rawId: base64urlEncode(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: base64urlEncode(assertion.response.authenticatorData),
clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
signature: base64urlEncode(assertion.response.signature)
}
};
if (assertion.response.userHandle) {
credJSON.response.userHandle = base64urlEncode(assertion.response.userHandle);
}
return fetch('/login/webauthn/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nonce: savedNonce, credential: credJSON })
});
})
.then(function (resp) {
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); });
return resp.json();
})
.then(function () { onSuccess(); })
.catch(function (err) { onError(err.message || 'Login failed'); });
};
// Auto-wire the profile page enrollment button.
document.addEventListener('DOMContentLoaded', function () {
var enrollBtn = document.getElementById('webauthn-enroll-btn');
if (enrollBtn) {
enrollBtn.addEventListener('click', function () {
var pw = document.getElementById('webauthn-password').value;
var name = document.getElementById('webauthn-name').value || 'Passkey';
hideError('webauthn-enroll-error');
hideError('webauthn-enroll-success');
if (!pw) {
showError('webauthn-enroll-error', 'Password is required.');
return;
}
enrollBtn.disabled = true;
enrollBtn.textContent = 'Waiting for authenticator...';
window.mciasWebAuthnRegister(pw, name, function () {
enrollBtn.disabled = false;
enrollBtn.textContent = 'Add Passkey';
document.getElementById('webauthn-password').value = '';
var msg = document.getElementById('webauthn-enroll-success');
if (msg) { msg.textContent = 'Passkey registered successfully.'; msg.style.display = ''; }
// Reload the credentials list.
window.location.reload();
}, function (err) {
enrollBtn.disabled = false;
enrollBtn.textContent = 'Add Passkey';
showError('webauthn-enroll-error', err);
});
});
}
// Auto-wire the login page passkey button.
var loginBtn = document.getElementById('webauthn-login-btn');
if (loginBtn) {
loginBtn.addEventListener('click', function () {
hideError('webauthn-login-error');
loginBtn.disabled = true;
loginBtn.textContent = 'Waiting for authenticator...';
window.mciasWebAuthnLogin('', function () {
window.location.href = '/dashboard';
}, function (err) {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with passkey';
showError('webauthn-login-error', err);
});
});
}
});
})();

View File

@@ -14,7 +14,19 @@
<dt class="text-muted">Type</dt><dd>{{.Account.AccountType}}</dd> <dt class="text-muted">Type</dt><dd>{{.Account.AccountType}}</dd>
<dt class="text-muted">Status</dt> <dt class="text-muted">Status</dt>
<dd id="status-cell">{{template "account_status" .}}</dd> <dd id="status-cell">{{template "account_status" .}}</dd>
<dt class="text-muted">TOTP</dt><dd>{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}</dd> <dt class="text-muted">TOTP</dt>
<dd id="totp-admin-status">
{{if .Account.TOTPRequired}}
Enabled
<button class="btn btn-sm btn-danger" style="margin-left:.5rem"
hx-delete="/accounts/{{.Account.UUID}}/totp"
hx-target="#totp-admin-status"
hx-swap="innerHTML"
hx-confirm="Remove TOTP for this account?"
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>Remove</button>
{{else}}Disabled{{end}}
</dd>
{{if .WebAuthnEnabled}}<dt class="text-muted">Passkeys</dt><dd>{{len .WebAuthnCreds}} registered</dd>{{end}}
<dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd> <dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd>
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd> <dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
</dl> </dl>
@@ -44,6 +56,12 @@
<div id="token-delegates-section">{{template "token_delegates" .}}</div> <div id="token-delegates-section">{{template "token_delegates" .}}</div>
</div> </div>
{{end}} {{end}}
{{if .WebAuthnEnabled}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
{{template "webauthn_credentials" .}}
</div>
{{end}}
<div class="card"> <div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
<div id="tags-editor">{{template "tags_editor" .}}</div> <div id="tags-editor">{{template "tags_editor" .}}</div>

View File

@@ -1,87 +1,140 @@
{{define "policy_form"}} {{define "policy_form"}}
<div style="margin-bottom:.75rem;border-bottom:1px solid var(--border-color);padding-bottom:.5rem;display:flex;gap:.5rem">
<button type="button" id="tab-form" class="btn btn-sm btn-secondary"
onclick="showTab('form')" style="font-size:.8rem">Form</button>
<button type="button" id="tab-json" class="btn btn-sm"
onclick="showTab('json')" style="font-size:.8rem;opacity:.6">JSON</button>
</div>
<form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin"> <form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin">
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem"> <div id="pf-form-mode">
<input class="form-control" type="text" name="description" <div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
placeholder="Description" required> <input class="form-control" type="text" name="description"
<input class="form-control" type="number" name="priority" placeholder="Description" required>
placeholder="100" value="100" min="0" max="9999"> <input class="form-control" type="number" name="priority"
<select class="form-control" name="effect" required> placeholder="100" value="100" min="0" max="9999">
<option value="allow">allow</option> <select class="form-control" name="effect" required>
<option value="deny">deny</option> <option value="allow">allow</option>
</select> <option value="deny">deny</option>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Roles (select multiple)</label>
<select class="form-control" name="roles" multiple size="4" style="font-size:.85rem">
<option value="admin">admin</option>
<option value="user">user</option>
<option value="service">service</option>
</select> </select>
</div> </div>
<div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<label class="text-small text-muted">Account types</label> <div>
<select class="form-control" name="account_types" multiple size="4" style="font-size:.85rem"> <label class="text-small text-muted">Roles (select multiple)</label>
<option value="human">human</option> <select class="form-control" name="roles" multiple size="6" style="font-size:.85rem">
<option value="system">system</option> <option value="admin">admin</option>
<option value="user">user</option>
<option value="guest">guest</option>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="commenter">commenter</option>
</select>
</div>
<div>
<label class="text-small text-muted">Account types</label>
<select class="form-control" name="account_types" multiple size="4" style="font-size:.85rem">
<option value="human">human</option>
<option value="system">system</option>
</select>
</div>
</div>
<div style="margin-bottom:.5rem">
<label class="text-small text-muted">Actions (select multiple)</label>
<select class="form-control" name="actions" multiple size="5" style="font-family:monospace;font-size:.8rem">
{{range .AllActions}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select> </select>
</div> </div>
</div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div style="margin-bottom:.5rem"> <div>
<label class="text-small text-muted">Actions (select multiple)</label> <label class="text-small text-muted">Resource type</label>
<select class="form-control" name="actions" multiple size="5" style="font-family:monospace;font-size:.8rem"> <select class="form-control" name="resource_type" style="font-size:.85rem">
{{range .AllActions}} <option value="">(any)</option>
<option value="{{.}}">{{.}}</option> <option value="account">account</option>
{{end}} <option value="token">token</option>
</select> <option value="pgcreds">pgcreds</option>
</div> <option value="audit_log">audit_log</option>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem"> <option value="totp">totp</option>
<div> <option value="policy">policy</option>
<label class="text-small text-muted">Resource type</label> </select>
<select class="form-control" name="resource_type" style="font-size:.85rem"> </div>
<option value="">(any)</option> <div>
<option value="account">account</option> <label class="text-small text-muted">Subject UUID (optional)</label>
<option value="token">token</option> <input class="form-control" type="text" name="subject_uuid"
<option value="pgcreds">pgcreds</option> placeholder="Only match this account UUID" style="font-size:.85rem">
<option value="audit_log">audit_log</option> </div>
<option value="totp">totp</option>
<option value="policy">policy</option>
</select>
</div> </div>
<div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<label class="text-small text-muted">Subject UUID (optional)</label> <div>
<input class="form-control" type="text" name="subject_uuid" <label class="text-small text-muted">Service names (comma-separated)</label>
placeholder="Only match this account UUID" style="font-size:.85rem"> <input class="form-control" type="text" name="service_names"
placeholder="e.g. payments-api,billing" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Required tags (comma-separated)</label>
<input class="form-control" type="text" name="required_tags"
placeholder="e.g. env:production,svc:billing" style="font-size:.85rem">
</div>
</div>
<div style="margin-bottom:.5rem">
<label style="font-size:.85rem;display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="checkbox" name="owner_matches_subject" value="1">
Owner must match subject (self-service rules only)
</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Not before (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="not_before" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Expires at (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
</div>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div> <div id="pf-json-mode" style="display:none">
<label class="text-small text-muted">Service names (comma-separated)</label> <div style="display:grid;grid-template-columns:1fr 80px;gap:.5rem;margin-bottom:.5rem">
<input class="form-control" type="text" name="service_names" <input class="form-control" type="text" name="description"
placeholder="e.g. payments-api,billing" style="font-size:.85rem"> placeholder="Description" id="pf-json-desc">
<input class="form-control" type="number" name="priority"
placeholder="100" value="100" min="0" max="9999" id="pf-json-priority">
</div> </div>
<div> <div style="margin-bottom:.5rem">
<label class="text-small text-muted">Required tags (comma-separated)</label> <label class="text-small text-muted">Rule JSON (<code>effect</code> required; other fields optional)</label>
<input class="form-control" type="text" name="required_tags" <textarea class="form-control" name="rule_json" rows="12"
placeholder="e.g. env:production,svc:billing" style="font-size:.85rem"> style="font-family:monospace;font-size:.8rem;white-space:pre"
</div> placeholder='{"effect":"allow","roles":["admin"],"actions":["accounts:list"]}'></textarea>
</div> </div>
<div style="margin-bottom:.5rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<label style="font-size:.85rem;display:flex;align-items:center;gap:.4rem;cursor:pointer"> <div>
<input type="checkbox" name="owner_matches_subject" value="1"> <label class="text-small text-muted">Not before (UTC, optional)</label>
Owner must match subject (self-service rules only) <input class="form-control" type="datetime-local" name="not_before" id="pf-json-nb" style="font-size:.85rem">
</label> </div>
</div> <div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem"> <label class="text-small text-muted">Expires at (UTC, optional)</label>
<div> <input class="form-control" type="datetime-local" name="expires_at" id="pf-json-ea" style="font-size:.85rem">
<label class="text-small text-muted">Not before (UTC, optional)</label> </div>
<input class="form-control" type="datetime-local" name="not_before" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Expires at (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
</div> </div>
</div> </div>
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button> <button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
</form> </form>
<script>
(function() {
var active = 'form';
window.showTab = function(tab) {
active = tab;
document.getElementById('pf-form-mode').style.display = tab === 'form' ? '' : 'none';
document.getElementById('pf-json-mode').style.display = tab === 'json' ? '' : 'none';
document.getElementById('tab-form').style.opacity = tab === 'form' ? '1' : '.6';
document.getElementById('tab-json').style.opacity = tab === 'json' ? '1' : '.6';
var formBtn = document.getElementById('tab-form');
var jsonBtn = document.getElementById('tab-json');
formBtn.className = tab === 'form' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
jsonBtn.className = tab === 'json' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
};
})();
</script>
{{end}} {{end}}

View File

@@ -0,0 +1,35 @@
{{define "totp_enroll_qr"}}
<div id="totp-section">
{{if .TOTPError}}<div class="alert alert-error" role="alert">{{.TOTPError}}</div>{{end}}
<p class="text-small" style="margin-bottom:.75rem">
Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.
</p>
<div style="text-align:center;margin:1rem 0">
<img src="{{.TOTPQR}}" alt="TOTP QR Code" width="200" height="200"
style="image-rendering:pixelated">
</div>
<details style="margin-bottom:1rem">
<summary class="text-small text-muted" style="cursor:pointer">Manual entry</summary>
<code style="font-size:.8rem;word-break:break-all;display:block;margin-top:.5rem;
padding:.5rem;background:var(--color-bg-alt,#f5f5f5);border-radius:4px">
{{.TOTPSecret}}
</code>
</details>
<form hx-post="/profile/totp/confirm"
hx-target="#totp-section"
hx-swap="outerHTML"
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
<input type="hidden" name="totp_enroll_nonce" value="{{.TOTPEnrollNonce}}">
<div class="form-group">
<label for="totp-confirm-code">Authenticator Code</label>
<input type="text" id="totp-confirm-code" name="totp_code"
class="form-control" autocomplete="one-time-code"
inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
required autofocus placeholder="6-digit code">
</div>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.5rem">
Verify &amp; Enable
</button>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,29 @@
{{define "totp_section"}}
<div id="totp-section">
{{if .TOTPSuccess}}<div class="alert alert-success" role="alert">{{.TOTPSuccess}}</div>{{end}}
{{if .TOTPEnabled}}
<p class="text-small" style="margin-bottom:.5rem">
<span style="color:var(--color-success,#27ae60);font-weight:600">&#x2713; Enabled</span>
</p>
<p class="text-muted text-small">To remove TOTP, contact an administrator.</p>
{{else}}
<p class="text-muted text-small" style="margin-bottom:.75rem">
Add a time-based one-time password for two-factor authentication.
</p>
{{if .TOTPError}}<div class="alert alert-error" role="alert">{{.TOTPError}}</div>{{end}}
<form hx-post="/profile/totp/enroll"
hx-target="#totp-section"
hx-swap="outerHTML"
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
<div class="form-group">
<label for="totp-enroll-password">Current Password</label>
<input type="password" id="totp-enroll-password" name="password"
class="form-control" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.5rem">
Set Up Authenticator
</button>
</form>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,30 @@
{{define "webauthn_credentials"}}
<div id="webauthn-credentials-section">
{{if .WebAuthnCreds}}
<table class="table" style="font-size:.85rem;margin-bottom:1rem">
<thead>
<tr><th>Name</th><th>Created</th><th>Last Used</th><th>Sign Count</th><th></th></tr>
</thead>
<tbody>
{{range .WebAuthnCreds}}
<tr>
<td>{{.Name}}{{if .Discoverable}} <span class="badge" title="Passkey (discoverable)">passkey</span>{{end}}</td>
<td class="text-small">{{formatTime .CreatedAt}}</td>
<td class="text-small">{{if .LastUsedAt}}{{formatTime (derefTime .LastUsedAt)}}{{else}}Never{{end}}</td>
<td>{{.SignCount}}</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="{{$.DeletePrefix}}/{{.ID}}"
hx-target="#webauthn-credentials-section"
hx-swap="outerHTML"
hx-confirm="Remove this passkey?">Remove</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted text-small">No passkeys registered.</p>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,19 @@
{{define "webauthn_enroll"}}
<div id="webauthn-enroll-section">
<div id="webauthn-enroll-error" class="alert alert-error" style="display:none" role="alert"></div>
<div id="webauthn-enroll-success" class="alert alert-success" style="display:none" role="alert"></div>
<div class="form-group">
<label for="webauthn-name">Passkey Name</label>
<input class="form-control" type="text" id="webauthn-name" placeholder="e.g. YubiKey 5" value="Passkey">
</div>
<div class="form-group">
<label for="webauthn-password">Current Password</label>
<input class="form-control" type="password" id="webauthn-password" autocomplete="current-password">
</div>
<div class="form-actions">
<button class="btn btn-primary" type="button" id="webauthn-enroll-btn">
Add Passkey
</button>
</div>
</div>
{{end}}

View File

@@ -29,10 +29,24 @@
<button class="btn btn-primary" type="submit" style="width:100%">Sign in</button> <button class="btn btn-primary" type="submit" style="width:100%">Sign in</button>
</div> </div>
</form> </form>
{{if .WebAuthnEnabled}}
<div style="margin-top:1rem;text-align:center">
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
<hr style="flex:1;border:0;border-top:1px solid #ddd">
<span class="text-muted text-small">or</span>
<hr style="flex:1;border:0;border-top:1px solid #ddd">
</div>
<div id="webauthn-login-error" class="alert alert-error" style="display:none" role="alert"></div>
<button class="btn btn-secondary" type="button" id="webauthn-login-btn" style="width:100%">
Sign in with passkey
</button>
</div>
{{end}}
</div> </div>
</div> </div>
</div> </div>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
{{if .WebAuthnEnabled}}<script src="/static/webauthn.js"></script>{{end}}
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -4,6 +4,22 @@
<div class="page-header"> <div class="page-header">
<h1>Profile</h1> <h1>Profile</h1>
</div> </div>
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Two-Factor Authentication (TOTP)</h2>
{{template "totp_section" .}}
</div>
{{if .WebAuthnEnabled}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
<p class="text-muted text-small" style="margin-bottom:.75rem">
Passkeys let you sign in without a password using your device's biometrics or a security key.
</p>
{{template "webauthn_credentials" .}}
<h3 style="font-size:.9rem;font-weight:600;margin:1rem 0 .5rem">Add a Passkey</h3>
{{template "webauthn_enroll" .}}
</div>
<script src="/static/webauthn.js"></script>
{{end}}
<div class="card"> <div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2>
<p class="text-muted text-small" style="margin-bottom:.75rem"> <p class="text-muted text-small" style="margin-bottom:.75rem">