53 Commits

Author SHA1 Message Date
db7cd73a6e Fix WebAuthn login: username pre-fill and policy check
- webauthn.js: read #username value before calling
  mciasWebAuthnLogin so non-discoverable keys work when
  a username is typed (previously always passed empty string,
  forcing discoverable/resident-key flow only)

- handleWebAuthnLoginFinish: evaluate auth:login policy after
  credential verification, mirroring the gate in handleLogin;
  returns 403 on deny so policy rules apply equally to both
  password and passkey authentication paths

Security: policy is checked post-verification so 403 vs 401
distinguishes a policy restriction from a bad credential without
leaking account existence. No service context is sent (WebAuthn
login carries no service_name/tags), so per-service deny rules
don't fire on passkey login; account-level deny rules do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:04:51 -07:00
39d9ffb79a Add service-context login policy enforcement
Services send service_name and tags in POST /v1/auth/login.
MCIAS evaluates auth:login policy with these as the resource
context after credentials are verified, enabling rules like:
  deny guest/viewer human accounts from env:restricted services
  deny guest accounts from specific named services

- loginRequest: add ServiceName and Tags fields
- handleLogin: evaluate policy after credential+TOTP check;
  policy deny returns 403 (not 401) to distinguish access
  restriction from bad credentials
- Go client: Options.ServiceName/Tags stored on Client,
  sent automatically in every Login() call
- Python client: service_name/tags on __init__, sent in login()
- Rust client: ClientOptions.service_name/tags, LoginRequest
  fields, Client stores and sends them in login()
- openapi.yaml: document service_name/tags request fields
  and 403 response for policy-denied logins
- engineering-standards.md: document service_name/tags in
  [mcias] config section with policy examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:11:35 -07:00
b0afe3b993 Align with engineering standards (steps 1-5)
- Rename dist/ -> deploy/ with subdirs examples/, scripts/,
  systemd/ per standard repository layout
- Update .gitignore: gitignore all of dist/ (build output only)
- Makefile: all target is now vet->lint->test->build; add vet,
  proto-lint, devserver targets; CGO_ENABLED=0 for builds
  (modernc.org/sqlite is pure-Go, no C toolchain needed);
  CGO_ENABLED=1 retained for tests (race detector)
- Dockerfile: builder -> golang:1.26-alpine, runtime ->
  alpine:3.21; drop libc6 dep; add /srv/mcias/certs and
  /srv/mcias/backups to image
- deploy/systemd/mcias.service: add RestrictSUIDSGID=true
- deploy/systemd/mcias-backup.service: new oneshot backup unit
- deploy/systemd/mcias-backup.timer: daily 02:00 UTC, 5m jitter
- deploy/scripts/install.sh: install backup units and enable
  timer; create certs/ and backups/ subdirs in /srv/mcias
- buf.yaml: add proto linting config for proto-lint target
- internal/db: add Snapshot and SnapshotDir methods (VACUUM INTO)
- cmd/mciasdb: add snapshot subcommand; no master key required
2026-03-16 20:26:43 -07:00
446b3df52d Fix WebAuthn CSRF; clarify security key UI
- Fix webauthn.js CSRF token: read HMAC header value from
  body hx-headers attribute instead of cookie nonce
- Update profile labels to mention security keys/FIDO2
  alongside passkeys

Security: CSRF double-submit was broken for fetch()-based
WebAuthn requests — JS was sending the cookie nonce as the
header value instead of the HMAC. Fixed by reading the
server-rendered header token from the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:27:44 -07:00
0b37fde155 Add WebAuthn config; Docker single-mount
- Add [webauthn] section to all config examples
- Add active WebAuthn config to run/mcias.conf
- Update Dockerfile to use /srv/mcias single mount
- Add WebAuthn and TOTP sections to RUNBOOK.md
- Fix TOTP QR display (template.URL type)
- Add --force-rm to docker build in Makefile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:57:06 -07:00
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
9657f18784 Fix OpenAPI spec parsing errors in Swagger UI
- Replace type: [string, "null"] array syntax with
  type: string + nullable: true on AuditEvent.actor_id,
  AuditEvent.target_id, PolicyRule.not_before, and
  PolicyRule.expires_at; Swagger UI 5 cannot parse the
  JSON Schema array form
- Add missing username field to /v1/token/validate response
  schema (added to handler in d6cc827 but never synced)
- Add missing GET /v1/pgcreds endpoint to spec
- Sync web/static/openapi.yaml (served file) with root;
  the static copy was many commits out of date, missing
  all policy/tags schemas and endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:29:53 -07:00
d4e8ef90ee Add policy-based authz and token delegation
- Replace requireAdmin (role-based) guards on all REST endpoints
  with RequirePolicy middleware backed by the existing policy engine;
  built-in admin wildcard rule (-1) preserves existing admin behaviour
  while operator rules can now grant targeted access to non-admin
  accounts (e.g. a system account allowed to list accounts)
- Wire policy engine into Server: loaded from DB at startup,
  reloaded after every policy-rule create/update/delete so changes
  take effect immediately without a server restart
- Add service_account_delegates table (migration 000008) so a human
  account can be delegated permission to issue tokens for a specific
  system account without holding the admin role
- Add token-download nonce mechanism: a short-lived (5 min),
  single-use random nonce is stored server-side after token issuance;
  the browser downloads the token as a file via
  GET /token/download/{nonce} (Content-Disposition: attachment)
  instead of copying from a flash message
- Add /service-accounts UI page for non-admin delegates
- Add TestPolicyEnforcement and TestPolicyDenyRule integration tests

Security:
- Policy engine uses deny-wins, default-deny semantics; admin wildcard
  is a compiled-in built-in and cannot be deleted via the API
- Token download nonces are 128-bit crypto/rand values, single-use,
  and expire after 5 minutes; a background goroutine evicts stale entries
- alg header validation and Ed25519 signing unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:40:16 -07:00
d6cc82755d Add username to token validate response
- Include username field in validateResponse struct
- Look up account by UUID and populate username on success
- Add username field to Go client TokenClaims struct
- Fix OpenAPI nullable type syntax (use array form)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:06:11 -07:00
0d38bbae00 Add mciasdb rekey command
- internal/db/accounts.go: add ListAccountsWithTOTP,
  ListAllPGCredentials, TOTPRekeyRow, PGRekeyRow, and
  Rekey — atomic transaction that replaces master_key_salt,
  signing_key_enc/nonce, all TOTP enc/nonce, and all
  pg_password enc/nonce in one SQLite BEGIN/COMMIT
- cmd/mciasdb/rekey.go: runRekey — decrypts all secrets
  under old master key, prompts for new passphrase (with
  confirmation), derives new key from fresh Argon2id salt,
  re-encrypts everything, and commits atomically
- cmd/mciasdb/main.go: wire "rekey" command + update usage
- Tests: DB-layer tests for ListAccountsWithTOTP,
  ListAllPGCredentials, Rekey (happy path, empty DB, salt
  replacement); command-level TestRekeyCommandRoundTrip
  verifies full round-trip and adversarially confirms old
  key no longer decrypts after rekey

Security: fresh random salt is always generated so a
reused passphrase still produces an independent key; old
and new master keys are zeroed via defer; no passphrase or
key material appears in logs or audit events; the entire
re-encryption is done in-memory before the single atomic
DB write so the database is never in a mixed state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:27:29 -07:00
23a27be57e Fix login card nesting on htmx failure
- Add id="login-card" to the .card wrapper div
- Change hx-target to #login-card (was #login-form)
- Add hx-select="#login-card" so htmx extracts only
  the card element from the full-page response

Without hx-select, htmx replaced the form's outerHTML
with the entire page response, inserting a new .card
inside the existing .card on every failed attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 08:31:40 -07:00
b1b52000c4 Sync docs and fix flaky renewal e2e test
- ARCHITECTURE.md: add Vault Endpoints section, /unseal UI page,
  vault_sealed/vault_unsealed audit events, sealed interceptor in
  gRPC chain
- openapi.yaml: add /v1/vault/{status,unseal,seal} endpoints, update
  /v1/health sealed-state docs, add VaultSealed response component,
  add vault audit event types and Admin — Vault tag
- web/static/openapi.yaml: kept in sync with root
- test/e2e: increase renewal test token lifetime from 2s to 10s
  (sleep 6s) to eliminate race between token expiry and HTTP round-trip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 00:39:41 -07:00
d87b4b4042 Add vault seal/unseal lifecycle
- New internal/vault package: thread-safe Vault struct with
  seal/unseal state, key material zeroing, and key derivation
- REST: POST /v1/vault/unseal, POST /v1/vault/seal,
  GET /v1/vault/status; health returns sealed status
- UI: /unseal page with passphrase form, redirect when sealed
- gRPC: sealedInterceptor rejects RPCs when sealed
- Middleware: RequireUnsealed blocks all routes except exempt
  paths; RequireAuth reads pubkey from vault at request time
- Startup: server starts sealed when passphrase unavailable
- All servers share single *vault.Vault by pointer
- CSRF manager derives key lazily from vault

Security: Key material is zeroed on seal. Sealed middleware
runs before auth. Handlers fail closed if vault becomes sealed
mid-request. Unseal endpoint is rate-limited (3/s burst 5).
No CSRF on unseal page (no session to protect; chicken-and-egg
with master key). Passphrase never logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 23:55:37 -07:00
5c242f8abb Remediate PEN-01 through PEN-07 (pentest round 4)
- PEN-01: fix extractBearerFromRequest to validate Bearer prefix
  using strings.SplitN + EqualFold; add TestExtractBearerFromRequest
- PEN-02: security headers confirmed present after redeploy (live
  probe 2026-03-15)
- PEN-03: accepted — Swagger UI self-hosting disproportionate to risk
- PEN-04: accepted — OpenAPI spec intentionally public
- PEN-05: accepted — gRPC port 9443 intentionally public
- PEN-06: remove RecordLoginFailure from REST TOTP-missing branch
  to match gRPC handler (DEF-08); add
  TestTOTPMissingDoesNotIncrementLockout
- PEN-07: accepted — per-account hard lockout covers the same threat
- Update AUDIT.md: all 7 PEN findings resolved (4 fixed, 3 accepted)

Security: PEN-01 removed a defence-in-depth gap where any 8+ char
Authorization value was accepted as a Bearer token. PEN-06 closed an
account-lockout-via-omission attack vector on TOTP-enrolled accounts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 23:14:47 -07:00
1121b7d4fd Harden deployment and fix PEN-01
- Fix Bearer token extraction to validate prefix (PEN-01)
- Add TestExtractBearerFromRequest covering PEN-01 edge cases
- Fix flaky TestRenewToken timing (2s → 4s lifetime)
- Move default config/install paths to /srv/mcias
- Add RUNBOOK.md for operational procedures
- Update AUDIT.md with penetration test round 4

Security: extractBearerFromRequest now uses case-insensitive prefix
validation instead of fixed-offset slicing, rejecting non-Bearer
Authorization schemes that were previously accepted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:33:24 -07:00
2a85d4bf2b Update AUDIT.md: all SEC findings remediated
- Mark SEC-01 through SEC-12 as fixed with fix descriptions
- Update executive summary to reflect full remediation
- Move original finding descriptions to collapsible section
- Replace remediation priority table with status section

Security: documentation-only change, no code modifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:31:30 -07:00
8f09e0e81a Rename Go client package from mciasgoclient to mcias
- Update package declaration in client.go
- Update error message strings to reference new package name
- Update test package and imports to use new name
- Update README.md documentation and examples with new package name
- All tests pass
2026-03-14 19:01:07 -07:00
7e5fc9f111 Fix flaky gRPC renewal test timing
Increase token lifetime from 2s to 4s in TestRenewToken to prevent
the token from expiring before the gRPC call completes through bufconn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:08:44 -07:00
cf02b8e2d8 Merge SEC-01: require password for TOTP enrollment 2026-03-13 01:07:39 -07:00
fe780bf873 Merge SEC-03: require token proximity for renewal
# Conflicts:
#	internal/server/server_test.go
2026-03-13 01:07:34 -07:00
cb96650e59 Merge SEC-11: use json.Marshal for audit details 2026-03-13 01:06:55 -07:00
bef5a3269d Merge SEC-09: hide admin nav links from non-admin users
# Conflicts:
#	internal/ui/ui_test.go
2026-03-13 01:06:50 -07:00
6191c5e00a Merge SEC-02: normalize lockout response
# Conflicts:
#	internal/grpcserver/grpcserver_test.go
#	internal/server/server_test.go
2026-03-13 01:05:56 -07:00
fa45836612 Merge SEC-08: atomic system token issuance 2026-03-13 00:50:39 -07:00
0bc7943d8f Merge SEC-06: gRPC proxy-aware rate limiting 2026-03-13 00:50:32 -07:00
97ba7ab74c Merge SEC-04: API security headers 2026-03-13 00:50:27 -07:00
582645f9d6 Merge SEC-05: body size limit and max password length 2026-03-13 00:49:39 -07:00
8840317cce Merge SEC-10: add Permissions-Policy header 2026-03-13 00:49:34 -07:00
482300b8b1 Merge SEC-12: reduce default token expiry to 7 days 2026-03-13 00:49:29 -07:00
8545473703 Fix SEC-01: require password for TOTP enroll
- REST handleTOTPEnroll now requires password field in request body
- gRPC EnrollTOTP updated with password field in proto message
- Both handlers check lockout status and record failures on bad password
- Updated Go, Python, and Rust client libraries to pass password
- Updated OpenAPI specs with new requestBody schema
- Added TestTOTPEnrollRequiresPassword with no-password, wrong-password,
  and correct-password sub-tests

Security: TOTP enrollment now requires the current password to prevent
session-theft escalation to persistent account takeover. Lockout and
failure recording use the same Argon2id constant-time path as login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:48:31 -07:00
3b17f7f70b Fix SEC-11: use json.Marshal for audit details
- Add internal/audit package with JSON() and JSONWithRoles() helpers
  that use json.Marshal instead of fmt.Sprintf with %q
- Replace all fmt.Sprintf audit detail construction in:
  - internal/server/server.go (10 occurrences)
  - internal/ui/handlers_auth.go (4 occurrences)
  - internal/grpcserver/auth.go (4 occurrences)
- Add tests for the helpers including edge-case Unicode,
  null bytes, special characters, and odd argument counts
- Fix broken {"roles":%v} formatting that produced invalid JSON

Security: Audit log detail strings are now constructed via
json.Marshal, which correctly handles all Unicode edge cases
(U+2028, U+2029, null bytes, etc.) that fmt.Sprintf with %q
may mishandle. This prevents potential log injection or parsing
issues in audit event consumers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:46:00 -07:00
eef7d1bc1a Fix SEC-03: require token proximity for renewal
- Add 50% lifetime elapsed check to REST handleRenew and gRPC RenewToken
- Reject renewal attempts before 50% of token lifetime has elapsed
- Update existing renewal tests to use short-lived tokens with sleep
- Add TestRenewTokenTooEarly tests for both REST and gRPC

Security: Tokens can only be renewed after 50% of their lifetime has
elapsed, preventing indefinite renewal of stolen tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:45:35 -07:00
d7d7ba21d9 Fix SEC-09: hide admin nav links from non-admin users
- Add IsAdmin bool to PageData (embedded in all page view structs)
- Remove redundant IsAdmin from DashboardData
- Add isAdmin() helper to derive admin status from request claims
- Set IsAdmin in all page-level handlers that populate PageData
- Wrap admin-only nav links in base.html with {{if .IsAdmin}}
- Add tests: non-admin dashboard/profile hide admin links,
  admin dashboard shows them

Security: navigation links to /accounts, /audit, /policies,
and /pgcreds are now only rendered for admin users. Server-side
authorization (requireAdminRole middleware) was already in place;
this change removes the information leak of showing links that
return 403 to non-admin users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:44:30 -07:00
4d3d438253 Fix SEC-02: normalize lockout response
- REST login: change locked account response from HTTP 429
  "account_locked" to HTTP 401 "invalid credentials"
- gRPC login: change from ResourceExhausted to Unauthenticated
  with "invalid credentials" message
- UI login: change from "account temporarily locked" to
  "invalid credentials"
- REST password-change endpoint: same normalization
- Audit logs still record "account_locked" internally
- Added tests in all three layers verifying locked-account
  responses are indistinguishable from wrong-password responses

Security: lockout responses now return identical status codes and
messages as wrong-password failures across REST, gRPC, and UI,
preventing user-enumeration via lockout differentiation. Internal
audit logging of lockout events is preserved for operational use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:57 -07:00
7cc2c86300 Fix SEC-12: reduce default token expiry to 7 days
- Change default_expiry from 720h (30 days) to 168h (7 days)
  in dist/mcias.conf.example and dist/mcias.conf.docker.example
- Update man page, ARCHITECTURE.md, and config.go comment
- Max ceiling validation remains at 30 days (unchanged)

Security: Shorter default token lifetime reduces the window of
exposure if a token is leaked. 7 days balances convenience and
security for a personal SSO. The 30-day max ceiling is preserved
so operators can still override if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:20 -07:00
51a5277062 Fix SEC-08: make system token issuance atomic
- Add IssueSystemToken() method in internal/db/accounts.go that wraps
  revoke-old, track-new, and upsert-system_tokens in a single SQLite
  transaction
- Update handleTokenIssue in internal/server/server.go to use the new
  atomic method instead of three separate DB calls
- Update IssueServiceToken in internal/grpcserver/tokenservice.go with
  the same fix
- Add TestIssueSystemTokenAtomic test covering first issue and rotation

Security: token issuance now uses a single transaction to prevent
inconsistent state (e.g., old token revoked but new token not tracked)
if a crash occurs between operations. Follows the same pattern as
RenewToken which was already correctly transactional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:13 -07:00
d3b63b1f87 Fix SEC-06: proxy-aware gRPC rate limiting
- Add grpcClientIP() helper that mirrors middleware.ClientIP
  for proxy-aware IP extraction from gRPC metadata
- Update rateLimitInterceptor to use grpcClientIP with the
  TrustedProxy config setting
- Only trust x-forwarded-for/x-real-ip metadata when the
  peer address matches the configured trusted proxy
- Add 7 unit tests covering: no proxy, xff, x-real-ip
  preference, untrusted peer ignoring headers, no headers
  fallback, invalid header fallback, and no peer

Security: gRPC rate limiter now extracts real client IPs
behind a reverse proxy using the same trust model as the
REST middleware (DEF-03). Headers from untrusted peers are
ignored, preventing IP-spoofing for rate-limit bypass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:43:09 -07:00
70e4f715f7 Fix SEC-05: add body size limit to REST API and max password length
- Wrap r.Body with http.MaxBytesReader (1 MiB) in decodeJSON so all
  REST API endpoints reject oversized JSON payloads
- Add MaxPasswordLen = 128 constant and enforce it in validate.Password()
  to prevent Argon2id DoS via multi-MB passwords
- Add test for oversized JSON body rejection (>1 MiB -> 400)
- Add test for password max length enforcement

Security: decodeJSON now applies the same body size limit the UI layer
already uses, closing the asymmetry. MaxPasswordLen caps Argon2id input
to a reasonable length, preventing CPU-exhaustion attacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:42:11 -07:00
3f09d5eb4f Fix SEC-04: add security headers to API
- Add globalSecurityHeaders middleware wrapping root handler
- Sets X-Content-Type-Options, Strict-Transport-Security, Cache-Control
  on all responses (API and UI)
- Add tests verifying headers on /v1/health and /v1/auth/login

Security: API responses previously lacked HSTS, nosniff, and
cache-control headers. The new middleware applies these universally.
Headers are safe for all content types and do not conflict with
the UI's existing securityHeaders middleware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:41:48 -07:00
036a0b8be4 Fix SEC-07: disable static file directory listing
- Add noDirListing handler wrapper that returns 404 for directory
  requests (paths ending with "/" or empty path) instead of delegating
  to http.FileServerFS which would render an index page
- Wrap the static file server in Register() with noDirListing
- Add tests verifying GET /static/ returns 404 and GET /static/style.css
  still returns 200

Security: directory listings exposed the names of all static assets,
leaking framework details. The wrapper blocks directory index responses
while preserving normal file serving.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:41:46 -07:00
30fc3470fa Fix SEC-10: add Permissions-Policy header
- Add Permissions-Policy header disabling camera, microphone,
  geolocation, and payment browser features
- Update assertSecurityHeaders test helper to verify the new header

Security: Permissions-Policy restricts browser APIs that this
application does not use, reducing attack surface from content
injection vulnerabilities. No crypto or auth flow changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:41:20 -07:00
586d4e3355 Allow non-admin users to access dashboard
- Change dashboard route from adminGet to authed middleware
- Show account counts and audit events only for admin users
- Show welcome message for non-admin authenticated users

Security: non-admin users cannot access account lists or audit
events; admin-only data is gated by claims.HasRole("admin") in
the handler, not just at the route level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:40:21 -07:00
394a9fb754 Update docs for recent changes
- ARCHITECTURE.md: add gRPC listener, mciasgrpcctl, new roles,
  granular role endpoints, profile page, audit events, policy actions,
  trusted_proxy config, validate package, schema force command
- PROGRESS.md: document role expansion and UI privilege escalation fix
- PROJECT_PLAN.md: align mciasctl subcommands with implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:07:41 -07:00
1c16354725 fix UI privilege escalation vulnerability
- Add requireAdminRole middleware to web UI that checks
  claims.HasRole("admin") and returns 403 if absent
- Apply middleware to all admin routes (accounts, policies,
  audit, dashboard, credentials)
- Remove redundant inline admin check from handleAdminResetPassword
- Profile routes correctly require only authentication, not admin

Security: The admin/adminGet middleware wrappers only called
requireCookieAuth (JWT validation) but never verified the admin
role. Any authenticated user could access admin endpoints
including role assignment. Fixed by inserting requireAdminRole
into the middleware chain for all admin routes.
2026-03-12 21:59:02 -07:00
89f78a38dd Update web UI to support all compile-time roles
- Update knownRoles to include guest, viewer, editor, and commenter
- Replace hardcoded role strings with model constants
- Remove obsolete 'service' role from UI
- All tests pass
2026-03-12 21:14:22 -07:00
122 changed files with 13065 additions and 967 deletions

View File

@@ -11,7 +11,8 @@
"Bash(sqlite3 /Users/kyle/src/mcias/run/mcias.db \"PRAGMA table_info\\(policy_rules\\);\" 2>&1)",
"Bash(sqlite3 /Users/kyle/src/mcias/run/mcias.db \"SELECT * FROM schema_version;\" 2>&1; sqlite3 /Users/kyle/src/mcias/run/mcias.db \"SELECT * FROM schema_migrations;\" 2>&1)",
"Bash(go run:*)",
"Bash(go list:*)"
"Bash(go list:*)",
"Bash(go vet:*)"
]
},
"hooks": {

Binary file not shown.

3
.gitignore vendored
View File

@@ -20,7 +20,8 @@ mcias.toml
*~
go.work
go.work.sum
dist/mcias_*.tar.gz
# dist/ is purely build output (tarballs); never commit it
dist/
man/man1/*.gz
# Client library build artifacts

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,36 +15,46 @@ parties that delegate authentication decisions to it.
### Components
```
┌────────────────────────────────────────────────────┐
│ MCIAS Server (mciassrv) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Auth │ │ Token │ │ Account / Role │ │
│ │ Handler │ │ Manager │ │ Manager │ │
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
│ └─────────────┴─────────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ SQLite Database │ │
│ └────────────────────┘ │
└────────────────────────────────────────────────────┘
▲ ▲
│ HTTPS/REST │ HTTPS/REST │ direct file I/O
┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐
Personal │ │ mciasctl │ │ mciasdb
│ Apps │ │ (admin │ │ (DB tool) │
└─────────────┘ CLI) └─────────────┘
└──────────┘
┌─────────────────────────────────────────────────────────
MCIAS Server (mciassrv)
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐
│ │ Auth │ │ Token │ │ Account / Role │
│ │ Handler │ │ Manager │ │ Manager │
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘
│ └─────────────┴─────────────────┘
│ │
│ ┌─────────▼──────────┐
│ │ SQLite Database │
│ └────────────────────┘
│ │
┌──────────────────┐ ┌──────────────────────┐
REST listener │ │ gRPC listener │ │
│ │ (net/http) (google.golang.org/ │
│ │ :8443 │ │ grpc) :9443 │ │
└──────────────────┘ └──────────────────────┘
└─────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
│ │ │ │
┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐
│ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
└───────────┘ │ CLI) │ │ CLI) │ └────────────┘
└──────────┘ └──────────────┘
```
**mciassrv** — The authentication server. Exposes a REST API over HTTPS/TLS.
Handles login, token issuance, token validation, token renewal, and token
revocation.
**mciassrv** — The authentication server. Exposes a REST API and gRPC API over
HTTPS/TLS (dual-stack; see §17). Handles login, token issuance, token
validation, token renewal, and token revocation.
**mciasctl** — The administrator CLI. Communicates with mciassrv's REST API
using an admin JWT. Creates/manages human accounts, system accounts, roles,
and Postgres credential records.
**mciasgrpcctl** — The gRPC administrator CLI. Mirrors mciasctl's subcommands
but communicates over gRPC/TLS instead of REST. Both CLIs can coexist; neither
depends on the other.
**mciasdb** — The database maintenance tool. Operates directly on the SQLite
file, bypassing the server API. Intended for break-glass recovery, offline
inspection, schema verification, and maintenance tasks that cannot be
@@ -118,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
**Human accounts** — interactive users. Can authenticate via:
- Username + password (Argon2id hash stored in DB)
- Optional TOTP (RFC 6238); if enrolled, required on every login
- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1)
- Optional FIDO2/WebAuthn passkeys and security keys; discoverable credentials
enable passwordless login, non-discoverable credentials serve as 2FA
**System accounts** — non-interactive service identities. Have:
- A single active bearer token at a time (rotating the token revokes the old one)
@@ -127,13 +138,21 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
### Roles
Roles are simple string labels stored in the `account_roles` table.
Roles are simple string labels stored in the `account_roles` table. Only
compile-time allowlisted role names are accepted; attempting to grant an
unknown role returns an error (prevents typos like "admim" from silently
creating a useless role).
Reserved roles:
Compile-time allowlisted roles:
- `admin` — superuser; can manage all accounts, tokens, and credentials
- `user` — standard user role
- `guest` — limited read-only access
- `viewer` — read-only access
- `editor` — create/modify access
- `commenter` — comment/annotate access
- Any role named identically to a system account — grants that human account
the ability to issue/revoke tokens and retrieve Postgres credentials for that
system account
system account (via policy rules, not the allowlist)
Role assignment requires admin privileges.
@@ -340,7 +359,6 @@ All endpoints use JSON request/response bodies. All responses include a
| POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT |
| POST | `/v1/auth/logout` | bearer JWT | Revoke current token |
| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token |
| PUT | `/v1/auth/password` | bearer JWT | Self-service password change (requires current password) |
### Token Endpoints
@@ -350,7 +368,25 @@ All endpoints use JSON request/response bodies. All responses include a
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
| 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 |
|---|---|---|---|
@@ -359,6 +395,7 @@ All endpoints use JSON request/response bodies. All responses include a
| GET | `/v1/accounts/{id}` | admin JWT | Get account details |
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
| 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
@@ -372,7 +409,9 @@ All endpoints use JSON request/response bodies. All responses include a
| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/accounts/{id}/roles` | admin JWT | List roles for account |
| PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set |
| PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set (atomic) |
| POST | `/v1/accounts/{id}/roles` | admin JWT | Grant a single role |
| DELETE | `/v1/accounts/{id}/roles/{role}` | admin JWT | Revoke a single role |
### TOTP Endpoints
@@ -382,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 |
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |
### WebAuthn Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/v1/auth/webauthn/register/begin` | bearer JWT | Begin WebAuthn registration (requires password re-auth) |
| POST | `/v1/auth/webauthn/register/finish` | bearer JWT | Complete WebAuthn registration |
| POST | `/v1/auth/webauthn/login/begin` | none | Begin WebAuthn login (discoverable or username-scoped) |
| POST | `/v1/auth/webauthn/login/finish` | none | Complete WebAuthn login, returns JWT |
| GET | `/v1/accounts/{id}/webauthn` | admin JWT | List WebAuthn credential metadata |
| DELETE | `/v1/accounts/{id}/webauthn/{credentialId}` | admin JWT | Remove WebAuthn credential |
### Postgres Credential Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| 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 |
### Tag Endpoints (admin only)
@@ -412,11 +463,23 @@ All endpoints use JSON request/response bodies. All responses include a
|---|---|---|---|
| GET | `/v1/audit` | admin JWT | List audit log events |
### Vault Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/vault/status` | none | Returns `{"sealed": bool}`; always accessible |
| POST | `/v1/vault/unseal` | none | Accept passphrase, derive key, unseal (rate-limited 3/s burst 5) |
| POST | `/v1/vault/seal` | admin JWT | Zero key material and seal the vault; invalidates all JWTs |
When the vault is sealed, all endpoints except health, vault status, and unseal
return 503 with `{"error":"vault is sealed","code":"vault_sealed"}`. The UI
redirects non-exempt paths to `/unseal`.
### Admin / Server Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | `/v1/health` | none | Health check |
| GET | `/v1/health` | none | Health check — returns `{"status":"ok"}` or `{"status":"sealed"}` |
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
### Web Management UI
@@ -439,6 +502,7 @@ cookie pattern (`mcias_csrf`).
| Path | Description |
|---|---|
| `/unseal` | Passphrase form to unseal the vault; shown for all paths when sealed |
| `/login` | Username/password login with optional TOTP step |
| `/` | Dashboard (account summary) |
| `/accounts` | Account list |
@@ -446,6 +510,8 @@ cookie pattern (`mcias_csrf`).
| `/pgcreds` | Postgres credentials list (owned + granted) with create form |
| `/policies` | Policy rules management — create, enable/disable, delete |
| `/audit` | Audit log viewer |
| `/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
saves, policy toggles, access grants) use HTMX partial-page updates for a
@@ -490,6 +556,9 @@ CREATE TABLE accounts (
-- AES-256-GCM encrypted TOTP secret; NULL if not enrolled
totp_secret_enc BLOB,
totp_secret_nonce BLOB,
-- Last accepted TOTP counter value; prevents replay attacks within the
-- ±1 time-step window (RFC 6238 §5.2). NULL = no code accepted yet.
last_totp_counter INTEGER DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
deleted_at TEXT
@@ -622,6 +691,43 @@ CREATE TABLE policy_rules (
not_before TEXT DEFAULT NULL, -- optional: earliest activation 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
@@ -629,9 +735,10 @@ CREATE TABLE policy_rules (
- Passwords are stored as PHC-format Argon2id strings (e.g.,
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
parameters. Future parameter upgrades are transparent.
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
master key held only in server memory (derived at startup from a passphrase
or keyfile). The nonce is stored adjacent to the ciphertext.
- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
encrypted with AES-256-GCM using a master key held only in server memory
(derived at startup from a passphrase or keyfile). The nonce is stored
adjacent to the ciphertext.
- The master key salt is stored in `server_config.master_key_salt` so the
Argon2id KDF produces the same key on every restart. Generated on first run.
- The signing key encryption is layered: the Ed25519 private key is wrapped
@@ -661,17 +768,20 @@ or a keyfile path — never inline in the config file.
```toml
[server]
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
# trusted_proxy = "127.0.0.1" # optional; IP of reverse proxy — when set,
# X-Forwarded-For is trusted only from this IP
# for rate limiting and audit log IP extraction
[database]
path = "/var/lib/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
default_expiry = "720h" # 30 days
default_expiry = "168h" # 7 days
admin_expiry = "8h"
service_expiry = "8760h" # 365 days
@@ -696,29 +806,45 @@ mcias/
│ │ └── main.go
│ ├── mciasctl/ # REST admin CLI
│ │ └── main.go
│ ├── mciasdb/ # direct SQLite maintenance tool (Phase 6)
│ ├── mciasdb/ # direct SQLite maintenance tool
│ │ └── main.go
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7)
│ └── mciasgrpcctl/ # gRPC admin CLI companion
│ └── main.go
├── internal/
│ ├── audit/ # audit log event detail marshaling
│ ├── auth/ # login flow, TOTP verification, account lockout
│ ├── config/ # config file parsing and validation
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
│ ├── 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)
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
│ ├── policy/ # in-process authorization policy engine (§20)
│ ├── server/ # HTTP handlers, router setup
│ ├── 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)
│ ├── vault/ # master key lifecycle: seal/unseal state, key derivation
│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
├── web/
│ ├── static/ # CSS and static assets
── templates/ # HTML templates (base layout, pages, HTMX fragments)
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
── templates/ # HTML templates (base layout, pages, HTMX fragments)
│ └── embed.go # fs.FS embedding of static files and templates
├── proto/
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
│ └── mcias/v1/ # Protobuf service definitions
├── 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
```
@@ -761,12 +887,19 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
| `totp_removed` | TOTP removed from account |
| `pgcred_accessed` | Postgres credentials retrieved |
| `pgcred_updated` | Postgres credentials stored/updated |
| `pgcred_access_granted` | Read access to PG credentials granted to another account |
| `pgcred_access_revoked` | Read access to PG credentials revoked from an account |
| `password_changed` | Account password changed (self-service or admin reset) |
| `tag_added` | Tag added to account |
| `tag_removed` | Tag removed from account |
| `policy_rule_created` | Policy rule created |
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
| `policy_rule_deleted` | Policy rule deleted |
| `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_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |
---
@@ -838,6 +971,7 @@ mciasdb --config PATH <subcommand> [flags]
|---|---|
| `mciasdb schema verify` | Open DB, run migrations in dry-run mode, report version |
| `mciasdb schema migrate` | Apply any pending migrations and exit |
| `mciasdb schema force --version N` | Force schema version (clears dirty state); break-glass recovery |
| `mciasdb prune tokens` | Delete expired rows from `token_revocation` and `system_tokens` |
**Account management (offline):**
@@ -924,7 +1058,8 @@ proto/
└── v1/
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
├── 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
└── common.proto # Shared message types (Error, Timestamp wrappers)
@@ -943,8 +1078,9 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
|---|---|
| `AuthService` | `Login`, `Logout`, `RenewToken`, `EnrollTOTP`, `ConfirmTOTP`, `RemoveTOTP` |
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles` |
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
| `AdminService` | `Health`, `GetPublicKey` |
All request/response messages follow the same credential-exclusion rules as
@@ -979,9 +1115,12 @@ details.
### Interceptor Chain
```
[Request Logger] → [Auth Interceptor] → [Rate Limiter] → [Handler]
[Sealed Interceptor] → [Request Logger] → [Auth Interceptor] → [Rate Limiter] → [Handler]
```
- **Sealed Interceptor**: first in chain; blocks all RPCs with
`codes.Unavailable` ("vault sealed") when the vault is sealed, except
`AdminService/Health` which returns the sealed status.
- **Request Logger**: logs method, peer IP, status code, duration; never logs
the `authorization` metadata value.
- **Auth Interceptor**: validates Bearer JWT, injects claims. Public RPCs
@@ -1154,8 +1293,9 @@ The Makefile `docker` target automates the build step with the version tag.
| `generate` | `go generate ./...` (re-generates proto stubs) |
| `man` | Build man pages; compress to `.gz` in `man/` |
| `install` | Run `dist/install.sh` |
| `docker` | `docker build -t mcias:$(VERSION) .` |
| `clean` | Remove `bin/` and compressed man pages |
| `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` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
### Upgrade Path
@@ -1324,6 +1464,8 @@ needed:
- A human account should be able to access credentials for one specific service
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
`env:staging`, not `env:production`.
- A "secrets reader" role should read pgcreds for any service but change nothing.
@@ -1374,9 +1516,10 @@ const (
ActionReadAudit Action = "audit:read"
ActionEnrollTOTP Action = "totp:enroll" // self-service
ActionRemoveTOTP Action = "totp:remove" // admin
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionListRules Action = "policy:list"
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionChangePassword Action = "auth:change_password" // self-service
ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage"
// Resource types
@@ -1415,7 +1558,7 @@ type Resource struct {
// Rule is a single policy statement. All populated fields are ANDed.
// A zero/empty field is a wildcard (matches anything).
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
// Principal match conditions
@@ -1476,8 +1619,10 @@ at the same priority level.
```
Priority 0, Allow: roles=[admin], actions=<all> — admin wildcard
Priority 0, Allow: actions=[tokens:renew, auth:logout] — self-service logout/renew
Priority 0, Allow: actions=[auth:logout, tokens:renew] — self-service logout/renew
Priority 0, Allow: actions=[totp:enroll] — self-service TOTP enrollment
Priority 0, Allow: accountTypes=[human], actions=[auth:change_password]
— self-service password change
Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read],
resourceType=pgcreds, ownerMatchesSubject=true
— system account reads own creds
@@ -1629,3 +1774,157 @@ introduced.
| `policy_rule_deleted` | Rule deleted |
| `tag_added` | Tag added to 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.

359
AUDIT.md
View File

@@ -1,202 +1,349 @@
# MCIAS Security Audit Report
**Date:** 2026-03-12
**Scope:** Full codebase — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, and operational security.
**Methodology:** Static code analysis of all source files with adversarial focus on auth flows, crypto usage, input handling, and inter-component trust boundaries.
**Date:** 2026-03-14 (updated — penetration test round 4)
**Original audit date:** 2026-03-13
**Auditor role:** Penetration tester (code review + live instance probing)
**Scope:** Full codebase and running instance at mcias.metacircular.net:8443 — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, headers, and operational security.
**Methodology:** Static code analysis, live HTTP probing, architectural review.
---
## Executive Summary
MCIAS demonstrates strong security awareness throughout. The cryptographic foundations are sound, credential handling is careful, and the most common web/API authentication vulnerabilities have been explicitly addressed. The codebase shows consistent attention to defense-in-depth: constant-time comparisons, dummy Argon2 operations for unknown users, algorithm-confusion prevention in JWT validation, parameterized SQL, audit logging, and CSRF protection with HMAC-signed double-submit.
MCIAS has a strong security posture. All findings from the first three audit rounds (CRIT-01/CRIT-02, DEF-01 through DEF-10, and SEC-01 through SEC-12) have been remediated. The cryptographic foundations are sound, JWT validation is correct, SQL injection is not possible, XSS is prevented by Go's html/template auto-escaping, and CSRF protection is well-implemented.
**Two confirmed bugs with real security impact were found**, along with several defense-in-depth gaps that should be addressed before production deployment. The overall security posture is well above average for this class of system.
A fourth-round penetration test (PEN-01 through PEN-07) against the live instance at `mcias.metacircular.net:8443` identified 7 new findings: 2 medium, 2 low, and 3 informational. **Unauthorized access was not achieved** — the system's defense-in-depth held. See the open findings table below for details.
---
## Confirmed Vulnerabilities
## Open Findings (PEN-01 through PEN-07)
### CRIT-01 — TOTP Replay Attack (Medium-High)
Identified during the fourth-round penetration test on 2026-03-14 against the live instance at `mcias.metacircular.net:8443` and the source code at the same commit.
**File:** `internal/auth/auth.go:208-230`, `internal/grpcserver/auth.go:84`, `internal/ui/handlers_auth.go:152`
| ID | Severity | Finding | Status |
|----|----------|---------|--------|
| PEN-01 | Medium | `extractBearerFromRequest` does not validate "Bearer " prefix | **Fixed** — uses `strings.SplitN` + `strings.EqualFold` prefix validation, matching middleware implementation |
| PEN-02 | Medium | Security headers missing from live instance responses | **Fixed** — redeployed; all headers confirmed present on live instance 2026-03-15 |
| PEN-03 | Low | CSP `unsafe-inline` on `/docs` Swagger UI endpoint | **Accepted** — self-hosting Swagger UI (1.7 MB) to enable nonces adds complexity disproportionate to the risk; inline script is static, no user-controlled input |
| PEN-04 | Info | OpenAPI spec publicly accessible without authentication | **Accepted** — intentional; public access required for agents and external developers |
| PEN-05 | Info | gRPC port 9443 publicly accessible | **Accepted** — intentional; required for server-to-server access by external systems |
| PEN-06 | Low | REST login increments lockout counter for missing TOTP code | **Fixed**`RecordLoginFailure` removed from TOTP-missing branch; `TestTOTPMissingDoesNotIncrementLockout` added |
| PEN-07 | Info | Rate limiter is per-IP only, no per-account limiting | **Accepted** — per-account hard lockout (10 failures/15 min) already covers distributed brute-force; per-account rate limiting adds marginal benefit at this scale |
`ValidateTOTP` accepts any code falling in the current ±1 time-step window (±30 seconds, so a given code is valid for ~90 seconds) but **never records which codes have already been used**. The same valid TOTP code can be submitted an unlimited number of times within that window. There is no `last_used_totp_counter` or `last_used_totp_at` field in the schema.
<details>
<summary>Finding descriptions (click to expand)</summary>
**Attack scenario:** An attacker who has observed a valid TOTP code (e.g. from a compromised session, shoulder surfing, or a MITM that delayed delivery) can reuse that code to authenticate within its validity window.
### PEN-01 — `extractBearerFromRequest` Does Not Validate "Bearer " Prefix (Medium)
**Fix:** Track the last accepted TOTP counter per account in the database. Reject any counter ≤ the last accepted one. This requires a new column (`last_totp_counter INTEGER`) on the `accounts` table and a check-and-update in `ValidateTOTP`'s callers (or within it, with a DB reference passed in).
**File:** `internal/server/server.go` (lines 14141425)
---
### CRIT-02 — gRPC `EnrollTOTP` Enables TOTP Before Confirmation (Medium)
**File:** `internal/grpcserver/auth.go:202` vs `internal/server/server.go:724-728`
The REST `EnrollTOTP` handler explicitly uses `StorePendingTOTP` (which keeps `totp_required=0`) and a comment at line 724 explains why:
The server-level `extractBearerFromRequest` function extracts the token by slicing the `Authorization` header at offset 7 (`len("Bearer ")`) without first verifying that the header actually starts with `"Bearer "`. Any 8+ character `Authorization` value is accepted — e.g., `Authorization: XXXXXXXX` would extract `X` as the token string.
```go
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
// is not enabled until the user confirms the code.
// Current (vulnerable):
if len(auth) <= len(prefix) {
return "", fmt.Errorf("malformed Authorization header")
}
return auth[len(prefix):], nil // no prefix check
```
The gRPC `EnrollTOTP` handler at line 202 calls `SetTOTP` directly, which immediately sets `totp_required=1`. Any user who initiates TOTP enrollment over gRPC but does not immediately confirm will have their account locked out — they cannot log in because TOTP is required, but no working TOTP secret is confirmed.
The middleware-level `extractBearerToken` in `internal/middleware/middleware.go` (lines 303316) correctly uses `strings.SplitN` and `strings.EqualFold` to validate the prefix. The server-level function should be replaced with a call to the middleware version, or the same validation logic should be applied.
**Fix:** Change `grpcserver/auth.go:202` from `a.s.db.SetTOTP(...)` to `a.s.db.StorePendingTOTP(...)`, matching the REST server's behavior and the documented intent of those two DB methods.
**Impact:** Low in practice because the extracted garbage is then passed to JWT validation which will reject it. However, it violates defense-in-depth: a future change to token validation could widen the attack surface, and the inconsistency between the two extraction functions is a maintenance hazard.
**Recommendation:** Replace `extractBearerFromRequest` with a call to `middleware.extractBearerToken` (after exporting it or moving the function), or replicate the prefix validation.
**Fix:** `extractBearerFromRequest` now uses `strings.SplitN` and `strings.EqualFold` to validate the `"Bearer"` prefix before extracting the token, matching the middleware implementation. Test `TestExtractBearerFromRequest` covers valid tokens, missing headers, non-Bearer schemes (Token, Basic), empty tokens, case-insensitive matching, and the previously-accepted garbage input.
---
## Defense-in-Depth Gaps
### PEN-02 — Security Headers Missing from Live Instance Responses (Medium)
### DEF-01 — No Rate Limiting on the UI Login Endpoint (Medium)
**Live probe:** `https://mcias.metacircular.net:8443/login`
**File:** `internal/ui/ui.go:264`
The live instance's `/login` response did not include the security headers (`X-Content-Type-Options`, `Strict-Transport-Security`, `Cache-Control`, `Permissions-Policy`) that the source code's `globalSecurityHeaders` and UI `securityHeaders` middleware should be applying (SEC-04 and SEC-10 fixes).
This is likely a code/deployment discrepancy — the deployed binary may predate the SEC-04/SEC-10 fixes, or the middleware may not be wired into the route chain correctly for all paths.
**Impact:** Without HSTS, browsers will not enforce HTTPS-only access. Without `X-Content-Type-Options: nosniff`, MIME-type sniffing attacks are possible. Without `Cache-Control: no-store`, authenticated responses may be cached by proxies or browsers.
**Recommendation:** Redeploy the current source to the live instance and verify headers with `curl -I`.
**Fix:** Redeployed 2026-03-15. Live probe confirms all headers present on `/login`, `/v1/health`, and `/`:
`cache-control: no-store`, `content-security-policy`, `permissions-policy`, `referrer-policy`, `strict-transport-security: max-age=63072000; includeSubDomains`, `x-content-type-options: nosniff`, `x-frame-options: DENY`.
---
### PEN-03 — CSP `unsafe-inline` on `/docs` Swagger UI Endpoint (Low)
**File:** `internal/server/server.go` (lines 14501452)
The `docsSecurityHeaders` wrapper sets a Content-Security-Policy that includes `script-src 'self' 'unsafe-inline'` and `style-src 'self' 'unsafe-inline'`. This is required by Swagger UI's rendering approach, but it weakens CSP protection on the docs endpoint.
**Impact:** If an attacker can inject content into the Swagger UI page (e.g., via a reflected parameter in the OpenAPI spec URL), inline scripts would execute. The blast radius is limited to the `/docs` path, which requires no authentication (see PEN-04).
**Recommendation:** Consider serving Swagger UI from a separate subdomain or using CSP nonces instead of `unsafe-inline`. Alternatively, accept the risk given the limited scope.
---
### PEN-04 — OpenAPI Spec Publicly Accessible Without Authentication (Informational)
**Live probe:** `GET /openapi.yaml` returns the full API specification without authentication.
The OpenAPI spec reveals all API endpoints, request/response schemas, authentication flows, and error codes. While security-through-obscurity is not a defense, exposing the full API surface to unauthenticated users provides a roadmap for attackers.
**Recommendation:** Consider requiring authentication for `/openapi.yaml` and `/docs`, or accept the risk if the API surface is intended to be public.
---
### PEN-05 — gRPC Port 9443 Publicly Accessible (Informational)
**Live probe:** Port 9443 accepts TLS connections and serves gRPC.
The gRPC interface is accessible from the public internet. While it requires authentication for all RPCs, exposing it increases the attack surface (gRPC-specific vulnerabilities, protocol-level attacks).
**Recommendation:** If gRPC is only used for server-to-server communication, restrict access at the firewall/network level. If it must be public, ensure gRPC-specific rate limiting and monitoring are in place (SEC-06 fix applies here).
---
### PEN-06 — REST Login Increments Lockout Counter for Missing TOTP Code (Low)
**File:** `internal/server/server.go` (lines 271277)
When a TOTP-enrolled account submits a login request without a TOTP code, the REST handler calls `s.db.RecordLoginFailure(acct.ID)` before returning the `"TOTP code required"` error. This increments the lockout counter even though the user has not actually failed authentication — they simply omitted the TOTP field.
The gRPC handler was fixed for this exact issue in DEF-08, but the REST handler was not updated to match.
```go
uiMux.HandleFunc("POST /login", u.handleLoginPost)
// Current (REST — increments lockout for missing TOTP):
if acct.TOTPRequired {
if req.TOTPCode == "" {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`)
_ = s.db.RecordLoginFailure(acct.ID) // should not increment
middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required")
return
}
```
The REST `/v1/auth/login` endpoint is wrapped with `loginRateLimit` (10 req/s per IP). The UI `/login` endpoint has no equivalent middleware. Account lockout (10 failures per 15 minutes) partially mitigates brute force, but an attacker can still enumerate whether accounts exist at full network speed before triggering lockout, and can trigger lockout against many accounts in parallel with no rate friction.
**Impact:** An attacker who knows a username with TOTP enabled can lock the account by sending 10 login requests with a valid password but no TOTP code. The password must be correct (wrong passwords also increment the counter), but this lowers the bar from "must guess TOTP" to "must omit TOTP." More practically, legitimate users with buggy clients that forget the TOTP field could self-lock.
**Fix:** Apply the same `middleware.RateLimit(10, 10)` to `POST /login` in the UI mux. A simpler option is to wrap the entire `uiMux` with the rate limiter since the UI is also a sensitive surface.
**Recommendation:** Remove the `RecordLoginFailure` call from the TOTP-missing branch, matching the gRPC handler's behavior after the DEF-08 fix.
**Fix:** `RecordLoginFailure` removed from the TOTP-missing branch in `internal/server/server.go`. The branch now matches the gRPC handler exactly, including the rationale comment. `TestTOTPMissingDoesNotIncrementLockout` verifies the fix: it fully enrolls TOTP via the HTTP API, sets `LockoutThreshold=1`, issues a TOTP-missing login, and asserts the account is not locked.
---
### DEF-02`pendingLogins` Map Has No Expiry Cleanup (Low)
### PEN-07Rate Limiter Is Per-IP Only, No Per-Account Limiting (Informational)
**File:** `internal/ui/ui.go:57`
The rate limiter uses a per-IP token bucket. An attacker with access to multiple IP addresses (botnet, cloud instances, rotating proxies) can distribute brute-force attempts across IPs to bypass the per-IP limit.
The `pendingLogins sync.Map` stores short-lived TOTP nonces (90-second TTL). When consumed via `consumeTOTPNonce`, entries are deleted via `LoadAndDelete`. However, entries that are created but never consumed (user abandons login at the TOTP step, closes browser) **accumulate indefinitely** — they are checked for expiry on read but never proactively deleted.
The account lockout mechanism (10 failures in 15 minutes) provides a secondary defense, but it is a blunt instrument — it locks out the legitimate user as well.
In normal operation this is a minor memory leak. Under adversarial conditions — an attacker repeatedly sending username+password to step 1 without proceeding to step 2 — the map grows without bound. At scale this could be used for memory exhaustion.
**Recommendation:** Consider adding per-account rate limiting as a complement to per-IP limiting. This would cap login attempts per username regardless of source IP, without affecting other users. The account lockout already partially serves this role, but a softer rate limit (e.g., 1 req/s per username) would slow distributed attacks without locking out the user.
**Fix:** Add a background goroutine (matching the pattern in `middleware.RateLimit`) that periodically iterates the map and deletes expired entries. A 5-minute cleanup interval is sufficient given the 90-second TTL.
</details>
---
### DEF-03 — Rate Limiter Uses `RemoteAddr`, Not `X-Forwarded-For` (Low)
## Remediated Findings (SEC-01 through SEC-12)
**File:** `internal/middleware/middleware.go:200`
All findings from the SEC audit round have been remediated. The original descriptions are preserved below for reference.
The comment already acknowledges this: the rate limiter extracts the client IP from `r.RemoteAddr`. When the server is deployed behind a reverse proxy (nginx, Caddy, a load balancer), `RemoteAddr` will be the proxy's IP for all requests, collapsing all clients into a single rate-limit bucket. This effectively disables per-IP rate limiting in proxy deployments.
| ID | Severity | Finding | Status |
|----|----------|---------|--------|
| SEC-01 | Medium | TOTP enrollment did not require password re-authentication | **Fixed** — both REST and gRPC now require current password, with lockout counter on failure |
| SEC-02 | Medium | Account lockout response leaked account existence | **Fixed** — locked accounts now return same 401 `"invalid credentials"` as wrong password, with dummy Argon2 for timing uniformity |
| SEC-03 | Medium | Token renewal had no proximity or re-auth check | **Fixed** — renewal requires token to have consumed ≥50% of its lifetime |
| SEC-04 | Low-Med | REST API responses lacked security headers | **Fixed**`globalSecurityHeaders` middleware applies `X-Content-Type-Options`, HSTS, and `Cache-Control: no-store` to all routes |
| SEC-05 | Low | No request body size limit on REST API | **Fixed**`decodeJSON` wraps body with `http.MaxBytesReader` (1 MiB); max password length enforced |
| SEC-06 | Low | gRPC rate limiter ignored TrustedProxy | **Fixed**`grpcClientIP` extracts real client IP via metadata when peer matches trusted proxy |
| SEC-07 | Low | Static file directory listing enabled | **Fixed**`noDirListing` wrapper returns 404 for directory requests |
| SEC-08 | Low | System token issuance was not atomic | **Fixed**`IssueSystemToken` wraps revoke+track in a single SQLite transaction |
| SEC-09 | Info | Navigation bar exposed admin UI structure to non-admin users | **Fixed** — nav links conditionally rendered with `{{if .IsAdmin}}` |
| SEC-10 | Info | No `Permissions-Policy` header | **Fixed**`Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` added |
| SEC-11 | Info | Audit log details used `fmt.Sprintf` instead of `json.Marshal` | **Fixed**`audit.JSON` and `audit.JSONWithRoles` helpers use `json.Marshal` |
| SEC-12 | Info | Default token expiry was 30 days | **Fixed** — default reduced to 7 days (168h); renewal proximity check (SEC-03) further limits exposure |
**Fix:** Add a configurable `TrustedProxy` setting. When set, extract the real client IP from `X-Forwarded-For` or `X-Real-IP` headers only for requests coming from that proxy address. Never trust those headers unconditionally — doing so allows IP spoofing.
<details>
<summary>Original finding descriptions (click to expand)</summary>
### SEC-01 — TOTP Enrollment Does Not Require Password Re-authentication (Medium)
**Files:** `internal/server/server.go`, `internal/grpcserver/auth.go`
`POST /v1/auth/totp/enroll` and the gRPC `EnrollTOTP` RPC originally required only a valid JWT — no password confirmation. If an attacker stole a session token, they could enroll TOTP on the victim's account.
**Fix:** Both endpoints now require the current password, with lockout counter incremented on failure.
---
### DEF-04Missing `nbf` (Not Before) Claim on Issued Tokens (Low)
### SEC-02Account Lockout Response Leaks Account Existence (Medium)
**File:** `internal/token/token.go:73-82`
Locked accounts originally returned HTTP 429 / gRPC `ResourceExhausted` with `"account temporarily locked"`, distinguishable from the HTTP 401 `"invalid credentials"` returned for wrong passwords.
`IssueToken` sets `iss`, `sub`, `iat`, `exp`, and `jti`, but not `nbf`. Without a not-before constraint, a token is valid from the moment of issuance and a slightly clock-skewed client or intermediate could present it early. This is a defense-in-depth measure, not a practical attack at the moment, but it costs nothing to add.
**Fix:** Add `NotBefore: jwt.NewNumericDate(now)` to the `RegisteredClaims` struct. Add the corresponding validation step in `ValidateToken` (using `jwt.WithNotBefore()` or a manual check).
**Fix:** All login paths now return the same `"invalid credentials"` response for locked accounts, with dummy Argon2 to maintain timing uniformity.
---
### DEF-05No Maximum Token Expiry Ceiling in Config Validation (Low)
### SEC-03Token Renewal Has No Proximity or Re-auth Check (Medium)
**File:** `internal/config/config.go:150-158`
`POST /v1/auth/renew` originally accepted any valid token regardless of remaining lifetime.
The config validator enforces that expiry durations are positive but not that they are bounded above. An operator misconfiguration (e.g. `service_expiry = "876000h"`) would issue tokens valid for 100 years. For human sessions (`default_expiry`, `admin_expiry`) this is a significant risk in the event of token theft.
**Fix:** Add upper-bound checks in `validate()`. Suggested maximums: 30 days for `default_expiry`, 24 hours for `admin_expiry`, 5 years for `service_expiry`. At minimum, log a warning when values exceed reasonable thresholds.
**Fix:** Renewal now requires the token to have consumed ≥50% of its lifetime before it can be renewed.
---
### DEF-06`GetAccountByUsername` Comment Incorrect re: Case Sensitivity (Informational)
### SEC-04REST API Responses Lack Security Headers (Low-Medium)
**File:** `internal/db/accounts.go:73`
API endpoints originally returned only `Content-Type` — no `Cache-Control`, `X-Content-Type-Options`, or HSTS.
The comment reads "case-insensitive" but the query uses `WHERE username = ?` with SQLite's default BINARY collation, which is **case-sensitive**. This means `admin` and `Admin` would be treated as distinct accounts. This is not a security bug by itself, but it contradicts the comment and could mask confusion.
**Fix:** If case-insensitive matching is intended, add `COLLATE NOCASE` to the column definition or the query. If case-sensitive is correct (more common for SSO systems), remove the word "case-insensitive" from the comment.
**Fix:** `globalSecurityHeaders` middleware applies these headers to all routes (API and UI).
---
### DEF-07SQLite `synchronous=NORMAL` in WAL Mode (Low)
### SEC-05No Request Body Size Limit on REST API Endpoints (Low)
**File:** `internal/db/db.go:68`
`decodeJSON` originally read from `r.Body` without any size limit.
With `PRAGMA synchronous=NORMAL` and `journal_mode=WAL`, SQLite syncs the WAL file on checkpoints but not on every write. A power failure between a write and the next checkpoint could lose the most recent transactions. For an authentication system — where token issuance and revocation records must be durable — this is a meaningful risk.
**Fix:** Change to `PRAGMA synchronous=FULL`. For a single-node personal SSO the performance impact is negligible; durability of token revocations is worth it.
**Fix:** `http.MaxBytesReader` with 1 MiB limit added to `decodeJSON`. Maximum password length also enforced.
---
### DEF-08 — gRPC `Login` Counts TOTP-Missing as a Login Failure (Low)
### SEC-06 — gRPC Rate Limiter Ignores TrustedProxy (Low)
**File:** `internal/grpcserver/auth.go:76-77`
The gRPC rate limiter originally used `peer.FromContext` directly, always getting the proxy IP behind a reverse proxy.
When TOTP is required but no code is provided (`req.TotpCode == ""`), the gRPC handler calls `RecordLoginFailure`. In the two-step UI flow this is defensible, but via the gRPC single-step `Login` RPC, a well-behaved client that has not yet obtained the TOTP code (not an attacker) will increment the failure counter. Repeated retries could trigger account lockout unintentionally.
**Fix:** Either document that gRPC clients must always include the TOTP code and treat its omission as a deliberate attempt, or do not count "TOTP code required" as a failure (since the password was verified successfully at that point).
**Fix:** `grpcClientIP` now reads from gRPC metadata headers when the peer matches the trusted proxy.
---
### DEF-09 — Security Headers Missing on REST API Docs Endpoints (Informational)
### SEC-07 — Static File Directory Listing Enabled (Low)
**File:** `internal/server/server.go:85-94`
`http.FileServerFS` served directory listings by default.
The `/docs` and `/docs/openapi.yaml` endpoints are served from the parent `mux` and therefore do not receive the `securityHeaders` middleware applied to the UI sub-mux. The Swagger UI page at `/docs` is served without `X-Frame-Options`, `Content-Security-Policy`, etc.
**Fix:** Apply a security-headers middleware to the docs handlers, or move them into the UI sub-mux.
**Fix:** `noDirListing` wrapper returns 404 for directory requests.
---
### DEF-10 — Role Strings Not Validated Against an Allowlist (Low)
### SEC-08 — System Token Issuance Is Not Atomic (Low)
**File:** `internal/db/accounts.go:302-311` (`GrantRole`)
`handleTokenIssue` originally performed three sequential non-transactional operations.
There is no allowlist for role strings written to the `account_roles` table. Any string can be stored. While the admin-only constraint prevents non-admins from calling these endpoints, a typo by an admin (e.g. `"admim"`) would silently create an unknown role that silently grants nothing. The `RequireRole` check would never match it, causing a confusing failure mode.
**Fix:** Maintain a compile-time allowlist of valid roles (e.g. `"admin"`, `"user"`) and reject unknown role names at the handler layer before writing to the database.
**Fix:** `IssueSystemToken` wraps all operations in a single SQLite transaction.
---
## Positive Findings
### SEC-09 — Navigation Bar Exposes Admin UI Structure to Non-Admin Users (Informational)
The following implementation details are exemplary and should be preserved:
Nav links were rendered for all authenticated users.
**Fix:** Admin nav links wrapped in `{{if .IsAdmin}}` conditional.
---
### SEC-10 — No `Permissions-Policy` Header (Informational)
The security headers middleware did not include `Permissions-Policy`.
**Fix:** `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` added.
---
### SEC-11 — Audit Log Details Use `fmt.Sprintf` Instead of `json.Marshal` (Informational)
Audit details were constructed with `fmt.Sprintf` and `%q`, which is fragile for JSON.
**Fix:** `audit.JSON` and `audit.JSONWithRoles` helpers use `json.Marshal`.
---
### SEC-12 — Default Token Expiry Is 30 Days (Informational / Configuration)
Default expiry was 720h (30 days).
**Fix:** Reduced to 168h (7 days). Combined with SEC-03's renewal proximity check, exposure window is significantly reduced.
</details>
---
## Previously Remediated Findings (CRIT/DEF series)
The following findings from the initial audit (2026-03-12) were confirmed fixed in the 2026-03-13 audit:
| ID | Finding | Status |
|----|---------|--------|
| CRIT-01 | TOTP replay attack — no counter tracking | **Fixed**`CheckAndUpdateTOTPCounter` with atomic SQL, migration 000007 |
| CRIT-02 | gRPC `EnrollTOTP` called `SetTOTP` instead of `StorePendingTOTP` | **Fixed** — now calls `StorePendingTOTP` |
| DEF-01 | No rate limiting on UI login | **Fixed**`loginRateLimit` applied to `POST /login` |
| DEF-02 | `pendingLogins` map had no expiry cleanup | **Fixed**`cleanupPendingLogins` goroutine runs every 5 minutes |
| DEF-03 | Rate limiter ignored `X-Forwarded-For` | **Fixed**`ClientIP()` respects `TrustedProxy` config |
| DEF-04 | Missing `nbf` claim on tokens | **Fixed**`NotBefore: jwt.NewNumericDate(now)` added |
| DEF-05 | No max token expiry ceiling | **Fixed** — upper bounds enforced in config validation |
| DEF-06 | Incorrect case-sensitivity comment | **Fixed** — comment corrected |
| DEF-07 | SQLite `synchronous=NORMAL` | **Fixed** — changed to `PRAGMA synchronous=FULL` |
| DEF-08 | gRPC counted TOTP-missing as failure | **Fixed** — no longer increments lockout counter |
| DEF-09 | Security headers missing on docs endpoints | **Fixed**`docsSecurityHeaders` wrapper added |
| DEF-10 | Role strings not validated | **Fixed**`model.ValidateRole()` with compile-time allowlist |
---
## Positive Findings (Preserved)
These implementation details are exemplary and should be maintained:
| Area | Detail |
|------|--------|
| JWT alg confusion | `ValidateToken` enforces `alg=EdDSA` in the key function, before signature verification — the only correct place |
| Constant-time comparisons | `crypto/subtle.ConstantTimeCompare` used consistently for password hashes, TOTP codes, and CSRF tokens |
| Timing uniformity | Dummy Argon2 computed (once, with full production parameters via `sync.Once`) for unknown/inactive users on both REST and gRPC paths |
| Token revocation | Every token is tracked by JTI; unknown tokens are rejected (fail-closed) rather than silently accepted |
| Token renewal atomicity | `RenewToken` wraps revocation + insertion in a single SQLite transaction |
| TOTP nonce design | Two-step UI login uses a 128-bit single-use server-side nonce to avoid transmitting the password twice |
| CSRF protection | HMAC-SHA256 signed double-submit cookie with `SameSite=Strict` and constant-time validation |
| Credential exclusion | `json:"-"` tags on all credential fields; proto messages omit them too |
| Security headers | All UI responses receive CSP, `X-Content-Type-Options`, `X-Frame-Options`, HSTS, and `Referrer-Policy` |
| Account lockout | 10-attempt, 15-minute rolling lockout checked before Argon2 to prevent timing oracle |
| Argon2id parameters | Config validator enforces OWASP 2023 minimums and rejects weakening |
| SQL injection | All queries use parameterized statements; no string concatenation anywhere |
| Audit log | Append-only with actor/target/IP; no delete path provided |
| Master key handling | Env var cleared after reading; signing key zeroed on shutdown |
| JWT alg confusion | `ValidateToken` enforces `alg=EdDSA` in the key function before signature verification |
| Constant-time operations | `crypto/subtle.ConstantTimeCompare` for password hashes, CSRF tokens; all three TOTP windows evaluated without early exit |
| Timing uniformity | Dummy Argon2 via `sync.Once` for unknown/inactive users on all login paths |
| Token revocation | Fail-closed: untracked tokens are rejected, not silently accepted |
| Token renewal atomicity | `RenewToken` wraps revoke+track in a single SQLite transaction |
| TOTP replay prevention | Counter-based replay detection with atomic SQL UPDATE/WHERE |
| TOTP nonce design | 128-bit single-use server-side nonce; password never retransmitted in step 2 |
| CSRF protection | HMAC-SHA256 double-submit cookie, domain-separated key derivation, SameSite=Strict, constant-time validation |
| Credential exclusion | `json:"-"` on all credential fields; password hash never in API responses |
| Security headers (UI) | CSP (no unsafe-inline), X-Content-Type-Options, X-Frame-Options DENY, HSTS 2yr, Referrer-Policy no-referrer |
| Cookie hardening | HttpOnly + Secure + SameSite=Strict on session cookie |
| Account lockout | 10-attempt rolling window, checked before Argon2, with timing-safe dummy hash |
| Argon2id parameters | Config validator enforces OWASP 2023 minimums; rejects weakening |
| SQL injection | Zero string concatenation — all queries parameterized |
| Input validation | Username regex + length, password min length, account type enum, role allowlist, JSON strict decoder |
| Audit logging | Append-only, no delete path, credentials never logged, actor/target/IP captured |
| Master key hygiene | Env var cleared after read, key zeroed on shutdown, AES-256-GCM at rest |
| TLS | MinVersion TLS 1.2, X25519 preferred, no plaintext listener, read/write/idle timeouts set |
---
## Remediation Priority
## Penetration Test — Attacks That Failed (2026-03-14)
| Fixed | Priority | ID | Severity | Action |
|-------|----------|----|----------|--------|
| Yes | 1 | CRIT-02 | Medium | Change `grpcserver/auth.go:202` to call `StorePendingTOTP` instead of `SetTOTP` |
| Yes | 2 | CRIT-01 | Medium | Add `last_totp_counter` tracking to prevent TOTP replay within the validity window |
| Yes | 3 | DEF-01 | Medium | Apply IP rate limiting to the UI `POST /login` endpoint |
| Yes | 4 | DEF-02 | Low | Add background cleanup goroutine for the `pendingLogins` map |
| Yes | 5 | DEF-03 | Low | Support trusted-proxy IP extraction for accurate per-client rate limiting |
| Yes | 6 | DEF-04 | Low | Add `nbf` claim to issued tokens and validate it on receipt |
| Yes | 7 | DEF-05 | Low | Add upper-bound caps on token expiry durations in config validation |
| Yes | 8 | DEF-07 | Low | Change SQLite to `PRAGMA synchronous=FULL` |
| Yes | 9 | DEF-08 | Low | Do not count gRPC TOTP-missing as a login failure |
| Yes | 10 | DEF-10 | Low | Validate role strings against an allowlist before writing to the DB |
| Yes | 11 | DEF-09 | Info | Apply security headers to `/docs` endpoints |
| Yes | 12 | DEF-06 | Info | Correct the misleading "case-insensitive" comment in `GetAccountByUsername` |
The following attacks were attempted against the live instance and failed, confirming the effectiveness of existing defenses:
| Attack | Result |
|--------|--------|
| JWT `alg:none` bypass | Rejected — `ValidateToken` enforces `alg=EdDSA` |
| JWT `alg:HS256` key-confusion | Rejected — only EdDSA accepted |
| Forged JWT with random Ed25519 key | Rejected — signature verification failed |
| Username enumeration via timing | Not possible — ~355ms for both existing and non-existing users (dummy Argon2 working) |
| Username enumeration via error messages | Not possible — identical `"invalid credentials"` for all failure modes |
| Account lockout enumeration | Not possible — locked accounts return same response as wrong password (SEC-02 fix confirmed) |
| SQL injection via login fields | Not possible — parameterized queries throughout |
| JSON body bomb (oversized payload) | Rejected — `http.MaxBytesReader` returns 413 (SEC-05 fix confirmed) |
| Unknown JSON fields | Rejected — `DisallowUnknownFields` active on decoder |
| Rate limit bypass | Working correctly — 429 after burst exhaustion, `Retry-After` header present |
| Admin endpoint access without auth | Properly returns 401 |
| Directory traversal on static files | Not possible — `noDirListing` wrapper returns 404 (SEC-07 fix confirmed) |
| Public key endpoint | Returns Ed25519 PKIX key (expected; public by design) |
---
## Schema Observations
## Remediation Status
The migration chain (migrations 001006) is sound. Foreign key cascades are appropriate. Indexes are present on all commonly-queried columns. The `failed_logins` table uses a rolling window query approach which is correct.
**CRIT/DEF/SEC series:** All 24 findings remediated. No open items.
One note: the `accounts` table has no unique index enforcing `COLLATE NOCASE` on `username`. This is consistent with treating usernames as case-sensitive but should be documented explicitly to avoid future ambiguity.
**PEN series (2026-03-14):** All 7 findings resolved — 4 fixed, 3 accepted by design. Unauthorized access was not achieved. No open items remain.
Next audit should focus on:
- Any new features added since 2026-03-15
- Dependency updates and CVE review
- Re-evaluate PEN-03 if Swagger UI self-hosting becomes desirable

View File

@@ -1,12 +1,16 @@
# Dockerfile — MCIAS multi-stage container image
#
# Stage 1 (builder): Compiles all four MCIAS binaries.
# Stage 2 (runtime): Minimal Debian image containing only the binaries.
# Stage 2 (runtime): Minimal Alpine image containing only the binaries.
#
# modernc.org/sqlite is a pure-Go, CGo-free SQLite port. CGO_ENABLED=0
# produces fully static binaries with no C library dependencies, which
# deploy cleanly onto a minimal Alpine runtime image.
#
# The final image:
# - Runs as non-root uid 10001 (mcias)
# - Exposes port 8443 (REST/TLS) and 9443 (gRPC/TLS)
# - Declares VOLUME /data for the SQLite database
# - Declares VOLUME /srv/mcias for config, TLS, and database
# - Does NOT contain the Go toolchain, source code, or build cache
#
# Build:
@@ -15,8 +19,7 @@
# Run:
# docker run -d \
# --name mcias \
# -v /path/to/config:/etc/mcias:ro \
# -v mcias-data:/data \
# -v /srv/mcias:/srv/mcias \
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
# -p 8443:8443 \
# -p 9443:9443 \
@@ -25,7 +28,7 @@
# ---------------------------------------------------------------------------
# Stage 1 — builder
# ---------------------------------------------------------------------------
FROM golang:1.26-bookworm AS builder
FROM golang:1.26-alpine AS builder
WORKDIR /build
@@ -36,35 +39,29 @@ RUN go mod download
# Copy source.
COPY . .
# CGO_ENABLED=1 is required by modernc.org/sqlite (pure-Go CGo-free SQLite).
# CGO_ENABLED=0: modernc.org/sqlite is pure Go; no C toolchain required.
# -trimpath removes local file system paths from the binary.
# -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce
# image size.
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
# ---------------------------------------------------------------------------
# Stage 2 — runtime
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim
FROM alpine:3.21
# Install runtime dependencies.
# ca-certificates: required to validate external TLS certificates.
# libc6: required by CGo-compiled binaries (sqlite).
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libc6 && \
rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache ca-certificates
# Create a non-root user for the service.
# uid/gid 10001 is chosen to be well above the range typically assigned to
# system users (1999) and human users (1000+), reducing the chance of
# collision with existing uids on the host when using host networking.
RUN groupadd --gid 10001 mcias && \
useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcias
RUN addgroup -g 10001 mcias && \
adduser -u 10001 -G mcias -H -s /sbin/nologin -D mcias
# Copy compiled binaries from the builder stage.
COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv
@@ -72,17 +69,15 @@ COPY --from=builder /out/mciasctl /usr/local/bin/mciasctl
COPY --from=builder /out/mciasdb /usr/local/bin/mciasdb
COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl
# Create the config and data directories.
# /etc/mcias is mounted read-only by the operator with the config file,
# TLS cert, and TLS key.
# /data is the SQLite database mount point.
RUN mkdir -p /etc/mcias /data && \
chown mcias:mcias /data && \
chmod 0750 /data
# Create the data directory.
# /srv/mcias is mounted from the host with config, TLS certs, and database.
RUN mkdir -p /srv/mcias/certs /srv/mcias/backups && \
chown -R mcias:mcias /srv/mcias && \
chmod 0750 /srv/mcias
# Declare /data as a volume so the operator must explicitly mount it.
# The SQLite database must persist across container restarts.
VOLUME /data
# Declare /srv/mcias as a volume so the operator must explicitly mount it.
# Contains the config file, TLS cert/key, and SQLite database.
VOLUME /srv/mcias
# REST/TLS port and gRPC/TLS port. These are documentation only; the actual
# ports are set in the config file. Override by mounting a different config.
@@ -93,7 +88,8 @@ EXPOSE 9443
USER mcias
# Default entry point and config path.
# The operator mounts /etc/mcias/mcias.conf from the host or a volume.
# See dist/mcias.conf.docker.example for a suitable template.
# The operator mounts /srv/mcias from the host containing mcias.toml,
# TLS cert/key, and the SQLite database.
# See deploy/examples/mcias.conf.docker.example for a suitable template.
ENTRYPOINT ["mciassrv"]
CMD ["-config", "/etc/mcias/mcias.conf"]
CMD ["-config", "/srv/mcias/mcias.toml"]

View File

@@ -3,13 +3,18 @@
# Usage:
# make build — compile all binaries to bin/
# make test — run tests with race detector
# make vet — run go vet
# make lint — run golangci-lint
# make all — vet → lint → test → build (CI pipeline)
# make generate — regenerate protobuf stubs (requires protoc)
# make proto-lint — lint proto files with buf
# make man — build compressed man pages
# make install — run dist/install.sh (requires root)
# make install — run deploy/scripts/install.sh (requires root)
# make devserver — build and run mciassrv against run/ config
# make clean — remove bin/ and generated artifacts
# make dist — build release tarballs for linux/amd64 and linux/arm64
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
# make docker-clean — remove local mcias Docker images
# ---------------------------------------------------------------------------
# Variables
@@ -26,20 +31,25 @@ MAN_PAGES := $(MAN_DIR)/mciassrv.1 $(MAN_DIR)/mciasctl.1 \
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
# Build flags: trim paths from binaries and strip DWARF/symbol table.
# CGO_ENABLED=1 is required for modernc.org/sqlite.
# modernc.org/sqlite is pure-Go and does not require CGo; CGO_ENABLED=0
# produces statically linked binaries that deploy cleanly to Alpine containers.
GO := go
GOFLAGS := -trimpath
LDFLAGS := -s -w -X main.version=$(VERSION)
CGO := CGO_ENABLED=1
CGO := CGO_ENABLED=0
# The race detector requires CGo on some platforms, so tests continue to use
# CGO_ENABLED=1 while production builds are CGO_ENABLED=0.
CGO_TEST := CGO_ENABLED=1
# Platforms for cross-compiled dist tarballs.
DIST_PLATFORMS := linux/amd64 linux/arm64
# ---------------------------------------------------------------------------
# Default target
# Default target — CI pipeline: vet → lint → test → build
# ---------------------------------------------------------------------------
.PHONY: all
all: build
all: vet lint test build
# ---------------------------------------------------------------------------
# build — compile all binaries to bin/
@@ -58,7 +68,14 @@ build:
# ---------------------------------------------------------------------------
.PHONY: test
test:
$(CGO) $(GO) test -race ./...
$(CGO_TEST) $(GO) test -race ./...
# ---------------------------------------------------------------------------
# vet — static analysis via go vet
# ---------------------------------------------------------------------------
.PHONY: vet
vet:
$(GO) vet ./...
# ---------------------------------------------------------------------------
# lint — run golangci-lint
@@ -67,6 +84,15 @@ test:
lint:
golangci-lint run ./...
# ---------------------------------------------------------------------------
# proto-lint — lint and check for breaking changes in proto definitions
# Requires: buf (https://buf.build/docs/installation)
# ---------------------------------------------------------------------------
.PHONY: proto-lint
proto-lint:
buf lint
buf breaking --against '.git#branch=master,subdir=proto'
# ---------------------------------------------------------------------------
# generate — regenerate protobuf stubs from proto/ definitions
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc
@@ -75,6 +101,13 @@ lint:
generate:
$(GO) generate ./...
# ---------------------------------------------------------------------------
# devserver — build and run mciassrv against the local run/ config
# ---------------------------------------------------------------------------
.PHONY: devserver
devserver: build
$(BIN_DIR)/mciassrv -config run/mcias.conf
# ---------------------------------------------------------------------------
# man — build compressed man pages
# ---------------------------------------------------------------------------
@@ -89,7 +122,7 @@ man: $(patsubst %.1,%.1.gz,$(MAN_PAGES))
# ---------------------------------------------------------------------------
.PHONY: install
install: build
sh dist/install.sh
sh deploy/scripts/install.sh
# ---------------------------------------------------------------------------
# clean — remove build artifacts
@@ -97,14 +130,16 @@ install: build
.PHONY: clean
clean:
rm -rf $(BIN_DIR)
rm -rf dist/
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
#
# Output files: dist/mcias_<version>_<os>_<arch>.tar.gz
# Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl,
# man pages, and dist/ files.
# man pages, and deploy/ files.
# ---------------------------------------------------------------------------
.PHONY: dist
dist: man
@@ -115,14 +150,12 @@ dist: man
echo " DIST $$platform -> $$outdir.tar.gz"; \
mkdir -p $$outdir/bin; \
for bin in $(BINARIES); do \
CGO_ENABLED=1 GOOS=$$os GOARCH=$$arch $(GO) build \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build \
$(GOFLAGS) -ldflags "$(LDFLAGS)" \
-o $$outdir/bin/$$bin ./cmd/$$bin; \
done; \
cp -r man $$outdir/; \
cp dist/mcias.conf.example dist/mcias-dev.conf.example \
dist/mcias.env.example dist/mcias.service \
dist/install.sh $$outdir/; \
cp -r deploy $$outdir/; \
tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \
rm -rf $$outdir; \
done
@@ -132,7 +165,15 @@ dist: man
# ---------------------------------------------------------------------------
.PHONY: docker
docker:
docker build -t mcias:$(VERSION) -t mcias:latest .
docker build --force-rm -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
install-local: build
@@ -144,12 +185,17 @@ install-local: build
.PHONY: help
help:
@echo "Available targets:"
@echo " build Compile all binaries to bin/"
@echo " test Run tests with race detector"
@echo " lint Run golangci-lint"
@echo " generate Regenerate protobuf stubs"
@echo " man Build compressed man pages"
@echo " install Install to /usr/local/bin (requires root)"
@echo " clean Remove build artifacts"
@echo " dist Build release tarballs for Linux amd64/arm64"
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
@echo " all vet → lint → test → build (CI pipeline)"
@echo " build Compile all binaries to bin/"
@echo " test Run tests with race detector"
@echo " vet Run go vet"
@echo " lint Run golangci-lint"
@echo " proto-lint Lint proto files with buf"
@echo " generate Regenerate protobuf stubs"
@echo " devserver Build and run mciassrv against run/ config"
@echo " man Build compressed man pages"
@echo " install Install to /usr/local/bin (requires root)"
@echo " clean Remove build artifacts"
@echo " dist Build release tarballs for Linux amd64/arm64"
@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,277 @@
Source of truth for current development state.
---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
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
**Problem:** Only admins could issue tokens for service accounts, and the only way to retrieve the token was a flash message (copy-paste). There was no delegation mechanism for non-admin users.
**Solution:** Added token-issue delegation and a one-time secure file download flow.
**DB (`internal/db/`):**
- Migration `000008`: new `service_account_delegates` table — tracks which human accounts may issue tokens for a given system account
- `GrantTokenIssueAccess`, `RevokeTokenIssueAccess`, `ListTokenIssueDelegates`, `HasTokenIssueAccess`, `ListDelegatedServiceAccounts` functions
**Model (`internal/model/`):**
- New `ServiceAccountDelegate` type
- New audit event constants: `EventTokenDelegateGranted`, `EventTokenDelegateRevoked`
**UI (`internal/ui/`):**
- `handleIssueSystemToken`: now allows admins and delegates (not just admins); after issuance stores token in a short-lived (5 min) single-use download nonce; returns download link in the HTMX fragment
- `handleDownloadToken`: serves the token as `Content-Disposition: attachment` via the one-time nonce; nonce deleted on first use to prevent replay
- `handleGrantTokenDelegate` / `handleRevokeTokenDelegate`: admin-only endpoints to manage delegate access for a system account
- `handleServiceAccountsPage`: new `/service-accounts` page for non-admin delegates to see their assigned service accounts and issue tokens
- New `tokenDownloads sync.Map` in `UIServer` with background cleanup goroutine
**Routes:**
- `POST /accounts/{id}/token` — changed from admin-only to authed+CSRF, authorization checked in handler
- `GET /token/download/{nonce}` — new, authed
- `POST /accounts/{id}/token/delegates` — new, admin-only
- `DELETE /accounts/{id}/token/delegates/{grantee}` — new, admin-only
- `GET /service-accounts` — new, authed (delegates' token management page)
**Templates:**
- `token_list.html`: shows download link after issuance
- `token_delegates.html`: new fragment for admin delegate management
- `account_detail.html`: added "Token Issue Access" section for system accounts
- `service_accounts.html`: new page listing delegated service accounts with issue button
- `base.html`: non-admin nav now shows "Service Accounts" link
### 2026-03-14 — Vault seal/unseal lifecycle
**Problem:** `mciassrv` required the master passphrase at startup and refused to start without it. Operators needed a way to start the server in a degraded state and provide the passphrase at runtime, plus the ability to re-seal at runtime.
**Solution:** Implemented a `Vault` abstraction that manages key material lifecycle with seal/unseal state transitions.
**New package: `internal/vault/`**
- `vault.go`: Thread-safe `Vault` struct with `sync.RWMutex`-protected state. Methods: `IsSealed()`, `Unseal()`, `Seal()`, `MasterKey()`, `PrivKey()`, `PubKey()`. `Seal()` zeroes all key material before nilling.
- `derive.go`: Extracted `DeriveFromPassphrase()` and `DecryptSigningKey()` from `cmd/mciassrv/main.go` for reuse by unseal handlers.
- `vault_test.go`: Tests for state transitions, key zeroing, concurrent access.
**REST API (`internal/server/`):**
- `POST /v1/vault/unseal`: Accept passphrase, derive key, unseal (rate-limited 3/s burst 5)
- `POST /v1/vault/seal`: Admin-only, seals vault and zeroes key material
- `GET /v1/vault/status`: Returns `{"sealed": bool}`
- `GET /v1/health`: Now returns `{"status":"sealed"}` when sealed
- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed
**Web UI (`internal/ui/`):**
- New unseal page at `/unseal` with passphrase form (same styling as login)
- All UI routes redirect to `/unseal` when sealed (except `/static/`)
- CSRF manager now derives key lazily from vault
**gRPC (`internal/grpcserver/`):**
- New `sealedInterceptor` first in interceptor chain — returns `codes.Unavailable` for all RPCs except Health
- Health RPC returns `status: "sealed"` when sealed
**Startup (`cmd/mciassrv/main.go`):**
- When passphrase env var is empty/unset (and not first run): starts in sealed state
- When passphrase is available: backward-compatible unsealed startup
- First run still requires passphrase to generate signing key
**Refactoring:**
- All three servers (REST, UI, gRPC) share a single `*vault.Vault` by pointer
- Replaced static `privKey`, `pubKey`, `masterKey` fields with vault accessor calls
- `middleware.RequireAuth` now reads pubkey from vault at request time
- New `middleware.RequireUnsealed` middleware wired before request logger
**Audit events:** Added `vault_sealed` and `vault_unsealed` event types.
**OpenAPI:** Updated `openapi.yaml` with vault endpoints and sealed health response.
**Files changed:** 19 files (3 new packages, 3 new handlers, 1 new template, extensive refactoring across all server packages and tests).
### 2026-03-13 — Make pgcreds discoverable via CLI and UI
**Problem:** Users had no way to discover which pgcreds were available to them or what their credential IDs were, making it functionally impossible to use the system without manual database inspection.
**Solution:** Added two complementary discovery paths:
**REST API:**
- New `GET /v1/pgcreds` endpoint (requires authentication) returns all accessible credentials (owned + explicitly granted) with their IDs, host, port, database, username, and timestamps
- Response includes `id` field so users can then fetch full credentials via `GET /v1/accounts/{id}/pgcreds`
**CLI (`cmd/mciasctl/main.go`):**
- New `pgcreds list` subcommand calls `GET /v1/pgcreds` and displays accessible credentials with IDs
- Updated usage documentation to include `pgcreds list`
**Web UI (`web/templates/pgcreds.html`):**
- Credential ID now displayed in a `<code>` element at the top of each credential's metadata block
- Styled with monospace font for easy copying and reference
**Files modified:**
- `internal/server/server.go`: Added route `GET /v1/pgcreds` (requires auth, not admin) + handler `handleListAccessiblePGCreds`
- `cmd/mciasctl/main.go`: Added `pgCredsList` function and switch case
- `web/templates/pgcreds.html`: Display credential ID in the credentials list
- Struct field alignment fixed in `pgCredResponse` to pass `go vet`
All tests pass; `go vet ./...` clean.
### 2026-03-12 — Update web UI and model for all compile-time roles
- `internal/model/model.go`: added `RoleGuest`, `RoleViewer`, `RoleEditor`, and
`RoleCommenter` constants; updated `allowedRoles` map and `ValidateRole` error
message to include the full set of recognised roles.
- `internal/ui/`: updated `knownRoles` to include guest, viewer, editor, and
commenter; replaced hardcoded role strings with model constants; removed
obsolete "service" role from UI dropdowns.
- All tests pass; build verified.
### 2026-03-12 — Fix UI privilege escalation vulnerability
**internal/ui/ui.go**
- Added `requireAdminRole` middleware that checks `claims.HasRole("admin")`
and returns 403 if absent
- Updated `admin` and `adminGet` middleware wrappers to include
`requireAdminRole` in the chain — previously only `requireCookieAuth`
was applied, allowing any authenticated user to access admin endpoints
- Profile routes correctly use only `requireCookieAuth` (not admin-gated)
**internal/ui/handlers_accounts.go**
- Removed redundant inline admin check from `handleAdminResetPassword`
(now handled by route-level middleware)
**Full audit performed across all three API surfaces:**
- REST (`internal/server/server.go`): all admin routes use
`requireAuth → RequireRole("admin")` — correct
- gRPC (all service files): every admin RPC calls `requireAdmin(ctx)` as
first statement — correct
- UI: was vulnerable, now fixed with `requireAdminRole` middleware
All tests pass; `go vet ./...` clean.
### 2026-03-12 — Checkpoint: password change UI enforcement + migration recovery

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
**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
**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
**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
**Acceptance criteria:**
@@ -143,6 +155,7 @@ See ARCHITECTURE.md for design rationale.
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
- `DELETE /v1/auth/totp` — admin; removes TOTP from account
- `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
**never** included in any API response
- Tests: each endpoint happy path; auth middleware applied correctly; invalid
@@ -160,29 +173,39 @@ See ARCHITECTURE.md for design rationale.
---
## Phase 4 — Admin CLI
## Phase 4 — Admin CLI **[COMPLETE]**
### Step 4.1: `cmd/mciasctl` — admin CLI
**Acceptance criteria:**
- Subcommands:
- `mciasctl account create --username NAME --type human|system`
- `mciasctl account create -username NAME -type human|system`
- `mciasctl account list`
- `mciasctl account suspend --id UUID`
- `mciasctl account delete --id UUID`
- `mciasctl role grant --account UUID --role ROLE`
- `mciasctl role revoke --account UUID --role ROLE`
- `mciasctl token issue --account UUID` (system accounts)
- `mciasctl token revoke --jti JTI`
- `mciasctl pgcreds set --account UUID --host H --port P --db D --user U --password P`
- `mciasctl pgcreds get --account UUID`
- CLI reads admin JWT from `MCIAS_ADMIN_TOKEN` env var or `--token` flag
- All commands make HTTPS requests to mciassrv (base URL from `--server` flag
- `mciasctl account update -id UUID -status active|inactive`
- `mciasctl account delete -id UUID`
- `mciasctl account get -id UUID`
- `mciasctl account set-password -id UUID`
- `mciasctl role list -id UUID`
- `mciasctl role set -id UUID -roles role1,role2`
- `mciasctl role grant -id UUID -role ROLE`
- `mciasctl role revoke -id UUID -role ROLE`
- `mciasctl token issue -id UUID` (system accounts)
- `mciasctl token revoke -jti JTI`
- `mciasctl pgcreds list`
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
- `mciasctl pgcreds get -id UUID`
- `mciasctl auth login`
- `mciasctl auth change-password`
- `mciasctl tag list -id UUID`
- `mciasctl tag set -id UUID -tags tag1,tag2`
- `mciasctl policy list|create|get|update|delete`
- CLI reads admin JWT from `MCIAS_TOKEN` env var or `-token` flag
- All commands make HTTPS requests to mciassrv (base URL from `-server` flag
or `MCIAS_SERVER` env var)
- Tests: flag parsing; missing required flags → error; help text complete
---
## 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
**Acceptance criteria:**
@@ -219,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
surface.
@@ -305,9 +328,7 @@ surface.
---
---
## Phase 7 — gRPC Interface
## Phase 7 — gRPC Interface **[COMPLETE]**
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
transport security requirements.
@@ -315,7 +336,8 @@ transport security requirements.
### Step 7.1: Protobuf definitions and generated code
**Acceptance criteria:**
- `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)
- `proto/generate.go` contains a `//go:generate protoc ...` directive that
produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and
@@ -348,10 +370,11 @@ transport security requirements.
- gRPC server uses the same TLS certificate and key as the REST server (loaded
from config); minimum TLS 1.2 enforced via `tls.Config`
- Unary server interceptor chain:
1. Request logger (method name, peer IP, status, duration)
2. Auth interceptor (extracts Bearer token, validates, injects claims into
1. Sealed interceptor (blocks all RPCs when vault sealed, except Health)
2. Request logger (method name, peer IP, status, duration)
3. Auth interceptor (extracts Bearer token, validates, injects claims into
`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
- Tests: interceptor chain applied correctly; rate-limit triggers after burst
@@ -387,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.
@@ -452,7 +475,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
- `generate``go generate ./...` (proto stubs from Phase 7)
- `man` — build compressed man pages
- `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
`GOOS`/`GOARCH` cross-compilation)
- `make build` works from a clean checkout after `go mod download`
@@ -474,13 +500,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
- `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"`,
`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:
- `docker build .` completes without error (run in CI if Docker available;
skip gracefully if not)
- `docker run --rm mcias:latest mciassrv --help` exits 0
- Image size documented in PROGRESS.md (target: under 50 MB)
### Step 8.7: Documentation
**Acceptance criteria:**
@@ -492,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
implementation notes.
@@ -597,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
```
@@ -609,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 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 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.
Phases 013 complete as of v1.0.0 (2026-03-15).
Phase 14 complete as of 2026-03-16.

View File

@@ -64,10 +64,10 @@ EOF
Generate the certificate:
```sh
cert genkey -a ec -s 521 > /etc/mcias/server.key
cert selfsign -p /etc/mcias/server.key -f /tmp/request.yaml > /etc/mcias/server.crt
chmod 0640 /etc/mcias/server.key
chown root:mcias /etc/mcias/server.key
cert genkey -a ec -s 521 > /srv/mcias/server.key
cert selfsign -p /srv/mcias/server.key -f /tmp/request.yaml > /srv/mcias/server.crt
chmod 0640 /srv/mcias/server.key
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
rm /tmp/request.yaml
```
@@ -75,21 +75,21 @@ rm /tmp/request.yaml
```sh
openssl req -x509 -newkey ed25519 -days 3650 \
-keyout /etc/mcias/server.key \
-out /etc/mcias/server.crt \
-keyout /srv/mcias/server.key \
-out /srv/mcias/server.crt \
-subj "/CN=auth.example.com" \
-nodes
chmod 0640 /etc/mcias/server.key
chown root:mcias /etc/mcias/server.key
chmod 0640 /srv/mcias/server.key
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
```
### 2. Configure the server
```sh
cp dist/mcias.conf.example /etc/mcias/mcias.conf
$EDITOR /etc/mcias/mcias.conf
chmod 0640 /etc/mcias/mcias.conf
chown root:mcias /etc/mcias/mcias.conf
cp dist/mcias.conf.example /srv/mcias/mcias.toml
$EDITOR /srv/mcias/mcias.toml
chmod 0640 /srv/mcias/mcias.toml
chown mcias:mcias /srv/mcias/mcias.toml
```
Minimum required fields:
@@ -97,11 +97,11 @@ Minimum required fields:
```toml
[server]
listen_addr = "0.0.0.0:8443"
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
tls_cert = "/srv/mcias/server.crt"
tls_key = "/srv/mcias/server.key"
[database]
path = "/var/lib/mcias/mcias.db"
path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
@@ -116,10 +116,10 @@ For local development, use `dist/mcias-dev.conf.example`.
### 3. Set the master key passphrase
```sh
cp dist/mcias.env.example /etc/mcias/env
$EDITOR /etc/mcias/env # replace the placeholder passphrase
chmod 0640 /etc/mcias/env
chown root:mcias /etc/mcias/env
cp dist/mcias.env.example /srv/mcias/env
$EDITOR /srv/mcias/env # replace the placeholder passphrase
chmod 0640 /srv/mcias/env
chown mcias:mcias /srv/mcias/env
```
> **Important:** Back up the passphrase to a secure offline location.
@@ -130,10 +130,10 @@ chown root:mcias /etc/mcias/env
```sh
export MCIAS_MASTER_PASSPHRASE=your-passphrase
mciasdb --config /etc/mcias/mcias.conf account create \
mciasdb --config /srv/mcias/mcias.toml account create \
--username admin --type human
mciasdb --config /etc/mcias/mcias.conf account set-password --id <UUID>
mciasdb --config /etc/mcias/mcias.conf role grant --id <UUID> --role admin
mciasdb --config /srv/mcias/mcias.toml account set-password --id <UUID>
mciasdb --config /srv/mcias/mcias.toml role grant --id <UUID> --role admin
```
### 5. Start the server
@@ -143,7 +143,7 @@ mciasdb --config /etc/mcias/mcias.conf role grant --id <UUID> --role admin
systemctl enable --now mcias
# manual
MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /etc/mcias/mcias.conf
MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /srv/mcias/mcias.toml
```
### 6. Verify
@@ -193,7 +193,7 @@ See `man mciasctl` for the full reference.
```sh
export MCIAS_MASTER_PASSPHRASE=your-passphrase
CONF="--config /etc/mcias/mcias.conf"
CONF="--config /srv/mcias/mcias.toml"
mciasdb $CONF schema verify
mciasdb $CONF account list
@@ -217,22 +217,22 @@ Enable the gRPC listener in config:
[server]
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443"
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
tls_cert = "/srv/mcias/server.crt"
tls_key = "/srv/mcias/server.key"
```
Using mciasgrpcctl:
```sh
export MCIAS_TOKEN=$ADMIN_JWT
mciasgrpcctl -server auth.example.com:9443 -cacert /etc/mcias/server.crt health
mciasgrpcctl -server auth.example.com:9443 -cacert /srv/mcias/server.crt health
mciasgrpcctl account list
```
Using grpcurl:
```sh
grpcurl -cacert /etc/mcias/server.crt \
grpcurl -cacert /srv/mcias/server.crt \
-H "authorization: Bearer $ADMIN_JWT" \
auth.example.com:9443 \
mcias.v1.AdminService/Health
@@ -265,14 +265,13 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) §8 (Web Management UI) for design detail
```sh
make docker
mkdir -p /srv/mcias/config
cp dist/mcias.conf.docker.example /srv/mcias/config/mcias.conf
$EDITOR /srv/mcias/config/mcias.conf
mkdir -p /srv/mcias
cp dist/mcias.conf.docker.example /srv/mcias/mcias.toml
$EDITOR /srv/mcias/mcias.toml
docker run -d \
--name mcias \
-v /srv/mcias/config:/etc/mcias:ro \
-v mcias-data:/data \
-v /srv/mcias:/srv/mcias \
-e MCIAS_MASTER_PASSPHRASE=your-passphrase \
-p 8443:8443 \
-p 9443:9443 \

541
RUNBOOK.md Normal file
View File

@@ -0,0 +1,541 @@
# MCIAS Runbook
Operational procedures for running and maintaining the MCIAS authentication
server. All required files live under `/srv/mcias`.
---
## Directory Layout
```
/srv/mcias/
mcias.toml — server configuration (TOML)
server.crt — TLS certificate (PEM)
server.key — TLS private key (PEM, mode 0640)
mcias.db — SQLite database (WAL mode creates .db-wal and .db-shm)
env — environment file: MCIAS_MASTER_PASSPHRASE (mode 0640)
master.key — optional raw AES-256 key file (mode 0640, alternative to env)
```
All files are owned by the `mcias` system user and group (`mcias:mcias`).
The directory itself is mode `0750`.
---
## Installation
Run as root from the repository root after `make build`:
```sh
sh dist/install.sh
```
This script is idempotent. It:
1. Creates the `mcias` system user and group if they do not exist.
2. Installs binaries to `/usr/local/bin/`.
3. Creates `/srv/mcias/` with correct ownership and permissions.
4. Installs the systemd service unit to `/etc/systemd/system/mcias.service`.
5. Installs example config files to `/srv/mcias/` (will not overwrite existing files).
After installation, complete the steps below before starting the service.
---
## First-Run Setup
### 1. Generate a TLS certificate
**Self-signed (personal/development use):**
```sh
openssl req -x509 -newkey ed25519 -days 3650 \
-keyout /srv/mcias/server.key \
-out /srv/mcias/server.crt \
-subj "/CN=auth.example.com" \
-nodes
chmod 0640 /srv/mcias/server.key
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
```
**Using the `cert` tool:**
```sh
go install github.com/kisom/cert@latest
cat > /tmp/request.yaml <<EOF
subject:
common_name: auth.example.com
hosts:
- auth.example.com
key:
algo: ecdsa
size: 521
ca:
expiry: 87600h
EOF
cert genkey -a ec -s 521 > /srv/mcias/server.key
cert selfsign -p /srv/mcias/server.key -f /tmp/request.yaml > /srv/mcias/server.crt
chmod 0640 /srv/mcias/server.key
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
rm /tmp/request.yaml
```
### 2. Write the configuration file
```sh
cp /srv/mcias/mcias.conf.example /srv/mcias/mcias.toml
$EDITOR /srv/mcias/mcias.toml
chmod 0640 /srv/mcias/mcias.toml
chown mcias:mcias /srv/mcias/mcias.toml
```
Minimum required settings:
```toml
[server]
listen_addr = "0.0.0.0:8443"
tls_cert = "/srv/mcias/server.crt"
tls_key = "/srv/mcias/server.key"
[database]
path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
[master_key]
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
```
See `dist/mcias.conf.example` for the full annotated reference.
### 3. Set the master key passphrase
```sh
cp /srv/mcias/mcias.env.example /srv/mcias/env
$EDITOR /srv/mcias/env # set MCIAS_MASTER_PASSPHRASE to a long random value
chmod 0640 /srv/mcias/env
chown mcias:mcias /srv/mcias/env
```
Generate a strong passphrase:
```sh
openssl rand -base64 32
```
> **IMPORTANT:** Back up the passphrase to a secure offline location.
> Losing it permanently destroys access to all encrypted data in the database.
### 4. Create the first admin account
```sh
export MCIAS_MASTER_PASSPHRASE=your-passphrase
mciasdb --config /srv/mcias/mcias.toml account create \
--username admin --type human
# note the UUID printed
mciasdb --config /srv/mcias/mcias.toml account set-password --id <UUID>
mciasdb --config /srv/mcias/mcias.toml role grant --id <UUID> --role admin
```
### 5. Enable and start the service
```sh
systemctl enable mcias
systemctl start mcias
systemctl status mcias
```
### 6. Verify
```sh
curl -k https://auth.example.com:8443/v1/health
# {"status":"ok"}
```
---
## Routine Operations
### Start / stop / restart
```sh
systemctl start mcias
systemctl stop mcias
systemctl restart mcias
```
### View logs
```sh
journalctl -u mcias -f
journalctl -u mcias --since "1 hour ago"
```
### Check service status
```sh
systemctl status mcias
```
### Reload configuration
The server reads its configuration at startup only. To apply config changes:
```sh
systemctl restart mcias
```
---
## Account Management
All account management can be done via `mciasctl` (REST API) when the server
is running, or `mciasdb` for offline/break-glass operations.
```sh
# Set env for offline tool
export MCIAS_MASTER_PASSPHRASE=your-passphrase
CONF="--config /srv/mcias/mcias.toml"
# List accounts
mciasdb $CONF account list
# Create account
mciasdb $CONF account create --username alice --type human
# Set password (prompts interactively)
mciasdb $CONF account set-password --id <UUID>
# Grant or revoke a role
mciasdb $CONF role grant --id <UUID> --role admin
mciasdb $CONF role revoke --id <UUID> --role admin
# Disable account
mciasdb $CONF account set-status --id <UUID> --status inactive
# Delete account
mciasdb $CONF account set-status --id <UUID> --status deleted
```
---
## Token Management
```sh
CONF="--config /srv/mcias/mcias.toml"
# List active tokens for an account
mciasdb $CONF token list --id <UUID>
# Revoke a specific token by JTI
mciasdb $CONF token revoke --jti <JTI>
# Revoke all tokens for an account (e.g., suspected compromise)
mciasdb $CONF token revoke-all --id <UUID>
# Prune expired tokens from the database
mciasdb $CONF prune tokens
```
---
## Database Maintenance
### Verify schema
```sh
mciasdb --config /srv/mcias/mcias.toml schema verify
```
### Run pending migrations
```sh
mciasdb --config /srv/mcias/mcias.toml schema migrate
```
### Force schema version (break-glass)
```sh
mciasdb --config /srv/mcias/mcias.toml schema force --version N
```
Use only when `schema migrate` reports a dirty version after a failed migration.
### Backup the database
SQLite WAL mode creates three files. Back up all three atomically using the
SQLite backup API or by stopping the server first:
```sh
# Online backup (preferred — no downtime):
sqlite3 /srv/mcias/mcias.db ".backup /path/to/backup/mcias-$(date +%F).db"
# Offline backup:
systemctl stop mcias
cp /srv/mcias/mcias.db /path/to/backup/mcias-$(date +%F).db
systemctl start mcias
```
Store backups alongside a copy of the master key passphrase in a secure
offline location. A database backup without the passphrase is unrecoverable.
---
## Audit Log
```sh
CONF="--config /srv/mcias/mcias.toml"
# Show last 50 audit events
mciasdb $CONF audit tail --n 50
# Query by account
mciasdb $CONF audit query --account <UUID>
# Query by event type since a given time
mciasdb $CONF audit query --type login_failure --since 2026-01-01T00:00:00Z
# Output as JSON (for log shipping)
mciasdb $CONF audit query --json
```
---
## Upgrading
1. Build the new binaries: `make build`
2. Stop the service: `systemctl stop mcias`
3. Install new binaries: `sh dist/install.sh`
- The script will not overwrite existing config files.
- New example files are placed with a `.new` suffix for review.
4. Review any `.new` config files in `/srv/mcias/` and merge changes manually.
5. Run schema migrations if required:
```sh
mciasdb --config /srv/mcias/mcias.toml schema migrate
```
6. Start the service: `systemctl start mcias`
7. Verify: `curl -k https://auth.example.com:8443/v1/health`
---
## WebAuthn / Passkey Configuration
WebAuthn enables passwordless passkey login and hardware security key 2FA.
It is **disabled by default** — to enable it, add a `[webauthn]` section to
`mcias.toml` with the relying party ID and origin.
### Enable WebAuthn
Add to `/srv/mcias/mcias.toml`:
```toml
[webauthn]
rp_id = "auth.example.com"
rp_origin = "https://auth.example.com"
display_name = "MCIAS"
```
- **`rp_id`** — The domain name (no scheme or port). Must match the domain
users see in their browser address bar.
- **`rp_origin`** — The full HTTPS origin. Include the port if non-standard
(e.g., `https://localhost:8443` for development).
- **`display_name`** — Shown to users during browser passkey prompts. Defaults
to "MCIAS" if omitted.
Restart the server after changing the config:
```sh
systemctl restart mcias
```
Once enabled, the **Passkeys** section appears on the user's Profile page
(self-service enrollment) and on the admin Account Detail page (credential
management).
### Passkey enrollment
Passkey enrollment is self-service only. Users add passkeys from their
**Profile → Passkeys** section. Admins can view and remove passkeys from
the Account Detail page but cannot enroll on behalf of users (passkey
registration requires the authenticator device to be present).
### Disable WebAuthn
Remove or comment out the `[webauthn]` section and restart. Existing
credentials remain in the database but are unused. Passkey UI sections
will be hidden.
### Remove all passkeys for an account (break-glass)
```sh
mciasdb --config /srv/mcias/mcias.toml account reset-webauthn --id <UUID>
```
---
## TOTP Two-Factor Authentication
TOTP enrollment is self-service via the **Profile → Two-Factor Authentication**
section. Users enter their current password to begin enrollment, scan the QR
code with an authenticator app, and confirm with a 6-digit code.
### Admin: Remove TOTP for an account
From the web UI: navigate to the account's detail page and click **Remove**
next to the TOTP status.
From the CLI:
```sh
mciasdb --config /srv/mcias/mcias.toml account reset-totp --id <UUID>
```
This clears the TOTP secret and disables the 2FA requirement. The user can
re-enroll from their Profile page.
---
## Master Key Rotation
> This operation is not yet automated. Until a rotation command is
> implemented, rotation requires a full re-encryption of the database.
> Contact the project maintainer for the current procedure.
---
## TLS Certificate Renewal
Replace the certificate and key files, then restart the server:
```sh
# Generate or obtain new cert/key, then:
cp new-server.crt /srv/mcias/server.crt
cp new-server.key /srv/mcias/server.key
chmod 0640 /srv/mcias/server.key
chown mcias:mcias /srv/mcias/server.crt /srv/mcias/server.key
systemctl restart mcias
```
For Let's Encrypt with Certbot, add a deploy hook:
```sh
# /etc/letsencrypt/renewal-hooks/deploy/mcias.sh
#!/bin/sh
cp /etc/letsencrypt/live/auth.example.com/fullchain.pem /srv/mcias/server.crt
cp /etc/letsencrypt/live/auth.example.com/privkey.pem /srv/mcias/server.key
chmod 0640 /srv/mcias/server.key
chown mcias:mcias /srv/mcias/server.crt /srv/mcias/server.key
systemctl restart mcias
```
---
## Docker Deployment
```sh
make docker
mkdir -p /srv/mcias
cp dist/mcias.conf.docker.example /srv/mcias/mcias.toml
$EDITOR /srv/mcias/mcias.toml
# Place TLS cert and key under /srv/mcias/
# Set ownership so uid 10001 (container mcias user) can read them.
chown -R 10001:10001 /srv/mcias
docker run -d \
--name mcias \
-v /srv/mcias:/srv/mcias \
-e MCIAS_MASTER_PASSPHRASE=your-passphrase \
-p 8443:8443 \
-p 9443:9443 \
--restart unless-stopped \
mcias:latest
```
See `dist/mcias.conf.docker.example` for the full annotated Docker config.
---
## Troubleshooting
### Server fails to start: "open database"
Check that `/srv/mcias/` is writable by the `mcias` user:
```sh
ls -la /srv/mcias/
stat /srv/mcias/mcias.db # if it already exists
```
Fix: `chown mcias:mcias /srv/mcias`
### Server fails to start: "environment variable ... is not set"
The `MCIAS_MASTER_PASSPHRASE` env var is missing. Ensure `/srv/mcias/env`
exists, is readable by the mcias user, and contains the correct variable:
```sh
grep MCIAS_MASTER_PASSPHRASE /srv/mcias/env
```
Also confirm the systemd unit loads it:
```sh
systemctl cat mcias | grep EnvironmentFile
```
### Server fails to start: "decrypt signing key"
The master key passphrase has changed or is wrong. The passphrase must match
the one used when the database was first initialized (the KDF salt is stored
in the database). Restore the correct passphrase from your offline backup.
### TLS errors in client connections
Verify the certificate is valid and covers the correct hostname:
```sh
openssl x509 -in /srv/mcias/server.crt -noout -text | grep -E "Subject|DNS"
openssl x509 -in /srv/mcias/server.crt -noout -dates
```
### Database locked / WAL not cleaning up
Check for lingering `mcias.db-wal` and `mcias.db-shm` files after an unclean
shutdown. These are safe to leave in place — SQLite will recover on next open.
Do not delete them while the server is running.
### Schema dirty after failed migration
```sh
mciasdb --config /srv/mcias/mcias.toml schema verify
mciasdb --config /srv/mcias/mcias.toml schema force --version N
mciasdb --config /srv/mcias/mcias.toml schema migrate
```
Replace `N` with the last successfully applied version number.
---
## File Permissions Reference
| Path | Mode | Owner |
|------|------|-------|
| `/srv/mcias/` | `0750` | `mcias:mcias` |
| `/srv/mcias/mcias.toml` | `0640` | `mcias:mcias` |
| `/srv/mcias/server.crt` | `0644` | `mcias:mcias` |
| `/srv/mcias/server.key` | `0640` | `mcias:mcias` |
| `/srv/mcias/mcias.db` | `0640` | `mcias:mcias` |
| `/srv/mcias/env` | `0640` | `mcias:mcias` |
| `/srv/mcias/master.key` | `0640` | `mcias:mcias` |
Verify permissions:
```sh
ls -la /srv/mcias/
```

14
buf.yaml Normal file
View File

@@ -0,0 +1,14 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
# PACKAGE_VERSION_SUFFIX requires package names to end in a version (e.g.
# mcias.v1). The current protos use mcias.v1 already so this is fine, but
# keeping the exception documents the intent explicitly.
- PACKAGE_VERSION_SUFFIX
breaking:
use:
- FILE

View File

@@ -15,10 +15,10 @@ go get git.wntrmute.dev/kyle/mcias/clients/go
## Quick Start
```go
import mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
import "git.wntrmute.dev/kyle/mcias/clients/go/mcias"
// Connect to the MCIAS server.
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{})
client, err := mcias.New("https://auth.example.com", mcias.Options{})
if err != nil {
log.Fatal(err)
}
@@ -43,7 +43,7 @@ if err := client.Logout(); err != nil {
## Custom CA Certificate
```go
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{
client, err := mcias.New("https://auth.example.com", mcias.Options{
CACertPath: "/etc/mcias/ca.pem",
})
```
@@ -55,17 +55,17 @@ All methods return typed errors:
```go
_, _, err := client.Login("alice", "wrongpass", "")
switch {
case errors.Is(err, new(mciasgoclient.MciasAuthError)):
case errors.Is(err, new(mcias.MciasAuthError)):
// 401 — wrong credentials or token invalid
case errors.Is(err, new(mciasgoclient.MciasForbiddenError)):
case errors.Is(err, new(mcias.MciasForbiddenError)):
// 403 — insufficient role
case errors.Is(err, new(mciasgoclient.MciasNotFoundError)):
case errors.Is(err, new(mcias.MciasNotFoundError)):
// 404 — resource not found
case errors.Is(err, new(mciasgoclient.MciasInputError)):
case errors.Is(err, new(mcias.MciasInputError)):
// 400 — malformed request
case errors.Is(err, new(mciasgoclient.MciasConflictError)):
case errors.Is(err, new(mcias.MciasConflictError)):
// 409 — conflict (e.g. duplicate username)
case errors.Is(err, new(mciasgoclient.MciasServerError)):
case errors.Is(err, new(mcias.MciasServerError)):
// 5xx — unexpected server error
}
```

View File

@@ -1,8 +1,8 @@
// Package mciasgoclient provides a thread-safe Go client for the MCIAS REST API.
// Package mcias provides a thread-safe Go client for the MCIAS REST API.
//
// Security: bearer tokens are stored under a sync.RWMutex and are never written
// to logs or included in error messages anywhere in this package.
package mciasgoclient
package mcias
import (
"bytes"
@@ -28,7 +28,7 @@ type MciasError struct {
}
func (e *MciasError) Error() string {
return fmt.Sprintf("mciasgoclient: HTTP %d: %s", e.StatusCode, e.Message)
return fmt.Sprintf("mcias: HTTP %d: %s", e.StatusCode, e.Message)
}
// MciasAuthError is returned for 401 Unauthorized responses.
@@ -77,6 +77,7 @@ type PublicKey struct {
type TokenClaims struct {
Valid bool `json:"valid"`
Sub string `json:"sub,omitempty"`
Username string `json:"username,omitempty"`
Roles []string `json:"roles,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
@@ -184,16 +185,28 @@ type Options struct {
CACertPath string
// Token is an optional pre-existing bearer token.
Token string
// ServiceName is the name of this service as registered in MCIAS. It is
// sent with every Login call so MCIAS can evaluate service-context policy
// rules (e.g. deny guest users from logging into this service).
// Populate from [mcias] service_name in the service's config file.
ServiceName string
// Tags are the service-level tags sent with every Login call. MCIAS
// evaluates auth:login policy against these tags, enabling rules such as
// "deny guest accounts from services tagged env:restricted".
// Populate from [mcias] tags in the service's config file.
Tags []string
}
// Client is a thread-safe MCIAS REST API client.
// Security: the bearer token is guarded by a sync.RWMutex; it is never
// written to logs or included in error messages in this library.
type Client struct {
baseURL string
http *http.Client
mu sync.RWMutex
token string
baseURL string
http *http.Client
serviceName string
tags []string
mu sync.RWMutex
token string
}
// ---------------------------------------------------------------------------
@@ -223,9 +236,11 @@ func New(serverURL string, opts Options) (*Client, error) {
}
transport := &http.Transport{TLSClientConfig: tlsCfg}
c := &Client{
baseURL: serverURL,
http: &http.Client{Transport: transport},
token: opts.Token,
baseURL: serverURL,
http: &http.Client{Transport: transport},
token: opts.Token,
serviceName: opts.ServiceName,
tags: opts.Tags,
}
return c, nil
}
@@ -342,16 +357,28 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
// Login authenticates with username and password. On success the token is
// stored in the Client and returned along with the expiry timestamp.
// totpCode may be empty for accounts without TOTP.
//
// The client's ServiceName and Tags (from Options) are included in the
// request so MCIAS can evaluate service-context policy rules.
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
req := map[string]string{"username": username, "password": password}
body := map[string]interface{}{
"username": username,
"password": password,
}
if totpCode != "" {
req["totp_code"] = totpCode
body["totp_code"] = totpCode
}
if c.serviceName != "" {
body["service_name"] = c.serviceName
}
if len(c.tags) > 0 {
body["tags"] = c.tags
}
var resp struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := c.do(http.MethodPost, "/v1/auth/login", req, &resp); err != nil {
if err := c.do(http.MethodPost, "/v1/auth/login", body, &resp); err != nil {
return "", "", err
}
c.setToken(resp.Token)
@@ -401,9 +428,15 @@ func (c *Client) RenewToken() (token, expiresAt string, err error) {
// Returns a base32 secret and an otpauth:// URI for QR-code generation.
// The secret is shown once; it is not retrievable after this call.
// TOTP is not enforced until confirmed via ConfirmTOTP.
func (c *Client) EnrollTOTP() (*TOTPEnrollResponse, error) {
//
// Security (SEC-01): the current password is required to prevent a stolen
// session token from being used to enroll attacker-controlled TOTP.
func (c *Client) EnrollTOTP(password string) (*TOTPEnrollResponse, error) {
var resp TOTPEnrollResponse
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", nil, &resp); err != nil {
body := struct {
Password string `json:"password"`
}{Password: password}
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", body, &resp); err != nil {
return nil, err
}
return &resp, nil

View File

@@ -1,7 +1,7 @@
// Package mciasgoclient_test provides tests for the MCIAS Go client.
// Package mcias_test provides tests for the MCIAS Go client.
// All tests use inline httptest.NewServer mocks to keep this module
// self-contained (no cross-module imports).
package mciasgoclient_test
package mcias_test
import (
"encoding/json"
@@ -11,16 +11,16 @@ import (
"strings"
"testing"
mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client {
func newTestClient(t *testing.T, serverURL string) *mcias.Client {
t.Helper()
c, err := mciasgoclient.New(serverURL, mciasgoclient.Options{})
c, err := mcias.New(serverURL, mcias.Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -42,7 +42,7 @@ func writeError(w http.ResponseWriter, status int, msg string) {
// ---------------------------------------------------------------------------
func TestNew(t *testing.T) {
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{})
c, err := mcias.New("https://example.com", mcias.Options{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -52,7 +52,7 @@ func TestNew(t *testing.T) {
}
func TestNewWithPresetToken(t *testing.T) {
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{Token: "preset-tok"})
c, err := mcias.New("https://example.com", mcias.Options{Token: "preset-tok"})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -62,7 +62,7 @@ func TestNewWithPresetToken(t *testing.T) {
}
func TestNewBadCACert(t *testing.T) {
_, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{CACertPath: "/nonexistent/ca.pem"})
_, err := mcias.New("https://example.com", mcias.Options{CACertPath: "/nonexistent/ca.pem"})
if err == nil {
t.Fatal("expected error for missing CA cert file")
}
@@ -97,7 +97,7 @@ func TestHealthError(t *testing.T) {
if err == nil {
t.Fatal("expected error for 503")
}
var srvErr *mciasgoclient.MciasServerError
var srvErr *mcias.MciasServerError
if !errors.As(err, &srvErr) {
t.Errorf("expected MciasServerError, got %T: %v", err, err)
}
@@ -183,7 +183,7 @@ func TestLoginUnauthorized(t *testing.T) {
if err == nil {
t.Fatal("expected error for 401")
}
var authErr *mciasgoclient.MciasAuthError
var authErr *mcias.MciasAuthError
if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T: %v", err, err)
}
@@ -275,7 +275,7 @@ func TestEnrollTOTP(t *testing.T) {
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
resp, err := c.EnrollTOTP()
resp, err := c.EnrollTOTP("testpass123")
if err != nil {
t.Fatalf("EnrollTOTP: %v", err)
}
@@ -312,7 +312,7 @@ func TestConfirmTOTPBadCode(t *testing.T) {
if err == nil {
t.Fatal("expected error for bad TOTP code")
}
var inputErr *mciasgoclient.MciasInputError
var inputErr *mcias.MciasInputError
if !errors.As(err, &inputErr) {
t.Errorf("expected MciasInputError, got %T: %v", err, err)
}
@@ -347,7 +347,7 @@ func TestChangePasswordWrongCurrent(t *testing.T) {
if err == nil {
t.Fatal("expected error for wrong current password")
}
var authErr *mciasgoclient.MciasAuthError
var authErr *mcias.MciasAuthError
if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T: %v", err, err)
}
@@ -456,7 +456,7 @@ func TestCreateAccountConflict(t *testing.T) {
if err == nil {
t.Fatal("expected error for 409")
}
var conflictErr *mciasgoclient.MciasConflictError
var conflictErr *mcias.MciasConflictError
if !errors.As(err, &conflictErr) {
t.Errorf("expected MciasConflictError, got %T: %v", err, err)
}
@@ -801,7 +801,7 @@ func TestListAudit(t *testing.T) {
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
resp, err := c.ListAudit(mciasgoclient.AuditFilter{})
resp, err := c.ListAudit(mcias.AuditFilter{})
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
@@ -827,7 +827,7 @@ func TestListAuditWithFilter(t *testing.T) {
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
_, err := c.ListAudit(mciasgoclient.AuditFilter{
_, err := c.ListAudit(mcias.AuditFilter{
Limit: 10, Offset: 5, EventType: "login_fail", ActorID: "acct-uuid-1",
})
if err != nil {
@@ -896,10 +896,10 @@ func TestCreatePolicyRule(t *testing.T) {
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
rule, err := c.CreatePolicyRule(mciasgoclient.CreatePolicyRuleRequest{
rule, err := c.CreatePolicyRule(mcias.CreatePolicyRuleRequest{
Description: "Test rule",
Priority: 50,
Rule: mciasgoclient.PolicyRuleBody{Effect: "deny"},
Rule: mcias.PolicyRuleBody{Effect: "deny"},
})
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
@@ -950,7 +950,7 @@ func TestGetPolicyRuleNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for 404")
}
var notFoundErr *mciasgoclient.MciasNotFoundError
var notFoundErr *mcias.MciasNotFoundError
if !errors.As(err, &notFoundErr) {
t.Errorf("expected MciasNotFoundError, got %T: %v", err, err)
}
@@ -976,7 +976,7 @@ func TestUpdatePolicyRule(t *testing.T) {
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
rule, err := c.UpdatePolicyRule(7, mciasgoclient.UpdatePolicyRuleRequest{Enabled: &enabled})
rule, err := c.UpdatePolicyRule(7, mcias.UpdatePolicyRuleRequest{Enabled: &enabled})
if err != nil {
t.Fatalf("UpdatePolicyRule: %v", err)
}
@@ -1073,7 +1073,7 @@ func TestIntegration(t *testing.T) {
if err == nil {
t.Fatal("expected error for wrong credentials")
}
var authErr *mciasgoclient.MciasAuthError
var authErr *mcias.MciasAuthError
if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T", err)
}

View File

@@ -20,9 +20,13 @@ class Client:
ca_cert_path: str | None = None,
token: str | None = None,
timeout: float = 30.0,
service_name: str | None = None,
tags: list[str] | None = None,
) -> None:
self._base_url = server_url.rstrip("/")
self.token = token
self._service_name = service_name
self._tags = tags or []
ssl_context: ssl.SSLContext | bool
if ca_cert_path is not None:
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
@@ -115,6 +119,9 @@ class Client:
) -> tuple[str, str]:
"""POST /v1/auth/login — authenticate and obtain a JWT.
Returns (token, expires_at). Stores the token on self.token.
The client's service_name and tags are included so MCIAS can evaluate
service-context policy rules (e.g. deny guests from restricted services).
"""
payload: dict[str, Any] = {
"username": username,
@@ -122,6 +129,10 @@ class Client:
}
if totp_code is not None:
payload["totp_code"] = totp_code
if self._service_name is not None:
payload["service_name"] = self._service_name
if self._tags:
payload["tags"] = self._tags
data = self._request("POST", "/v1/auth/login", json=payload)
assert data is not None
token = str(data["token"])
@@ -148,11 +159,15 @@ class Client:
expires_at = str(data["expires_at"])
self.token = token
return token, expires_at
def enroll_totp(self) -> tuple[str, str]:
def enroll_totp(self, password: str) -> tuple[str, str]:
"""POST /v1/auth/totp/enroll — begin TOTP enrollment.
Security (SEC-01): current password is required to prevent session-theft
escalation to persistent account takeover.
Returns (secret, otpauth_uri). The secret is shown only once.
"""
data = self._request("POST", "/v1/auth/totp/enroll")
data = self._request("POST", "/v1/auth/totp/enroll", json={"password": password})
assert data is not None
return str(data["secret"]), str(data["otpauth_uri"])
def confirm_totp(self, code: str) -> None:

View File

@@ -191,7 +191,7 @@ def test_enroll_totp(admin_client: Client) -> None:
json={"secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"},
)
)
secret, uri = admin_client.enroll_totp()
secret, uri = admin_client.enroll_totp("testpass123")
assert secret == "JBSWY3DPEHPK3PXP"
assert "otpauth://totp/" in uri
@respx.mock

View File

@@ -227,6 +227,10 @@ struct LoginRequest<'a> {
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
totp_code: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
service_name: Option<&'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
}
#[derive(Deserialize)]
@@ -268,6 +272,16 @@ pub struct ClientOptions {
/// Optional pre-existing bearer token.
pub token: Option<String>,
/// This service's name as registered in MCIAS. Sent with every login
/// request so MCIAS can evaluate service-context policy rules.
/// Populate from `[mcias] service_name` in the service config.
pub service_name: Option<String>,
/// Service-level tags sent with every login request. MCIAS evaluates
/// `auth:login` policy against these tags.
/// Populate from `[mcias] tags` in the service config.
pub tags: Vec<String>,
}
// ---- Client ----
@@ -280,6 +294,8 @@ pub struct ClientOptions {
pub struct Client {
base_url: String,
http: reqwest::Client,
service_name: Option<String>,
tags: Vec<String>,
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
/// Security: the token is never logged or included in error messages.
token: Arc<RwLock<Option<String>>>,
@@ -306,6 +322,8 @@ impl Client {
Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(),
http,
service_name: opts.service_name,
tags: opts.tags,
token: Arc::new(RwLock::new(opts.token)),
})
}
@@ -336,6 +354,8 @@ impl Client {
username,
password,
totp_code,
service_name: self.service_name.as_deref(),
tags: self.tags.clone(),
};
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
*self.token.write().await = Some(resp.token.clone());
@@ -484,9 +504,12 @@ impl Client {
/// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`.
/// The secret is shown once; store it in an authenticator app immediately.
pub async fn enroll_totp(&self) -> Result<(String, String), MciasError> {
///
/// Security (SEC-01): current password is required to prevent session-theft
/// escalation to persistent account takeover.
pub async fn enroll_totp(&self, password: &str) -> Result<(String, String), MciasError> {
let resp: TotpEnrollResponse =
self.post("/v1/auth/totp/enroll", &serde_json::json!({})).await?;
self.post("/v1/auth/totp/enroll", &serde_json::json!({"password": password})).await?;
Ok((resp.secret, resp.otpauth_uri))
}

View File

@@ -449,7 +449,7 @@ async fn test_enroll_totp() {
.await;
let c = admin_client(&server).await;
let (secret, uri) = c.enroll_totp().await.unwrap();
let (secret, uri) = c.enroll_totp("testpass123").await.unwrap();
assert_eq!(secret, "JBSWY3DPEHPK3PXP");
assert!(uri.starts_with("otpauth://totp/"));
}

View File

@@ -34,6 +34,7 @@
// token issue -id UUID
// token revoke -jti JTI
//
// pgcreds list
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
// pgcreds get -id UUID
//
@@ -526,9 +527,11 @@ func (c *controller) tokenRevoke(args []string) {
func (c *controller) runPGCreds(args []string) {
if len(args) == 0 {
fatalf("pgcreds requires a subcommand: get, set")
fatalf("pgcreds requires a subcommand: list, get, set")
}
switch args[0] {
case "list":
c.pgCredsList(args[1:])
case "get":
c.pgCredsGet(args[1:])
case "set":
@@ -538,6 +541,15 @@ func (c *controller) runPGCreds(args []string) {
}
}
func (c *controller) pgCredsList(args []string) {
fs := flag.NewFlagSet("pgcreds list", flag.ExitOnError)
_ = fs.Parse(args)
var result json.RawMessage
c.doRequest("GET", "/v1/pgcreds", nil, &result)
printJSON(result)
}
func (c *controller) pgCredsGet(args []string) {
fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
@@ -943,6 +955,7 @@ Commands:
token issue -id UUID
token revoke -jti JTI
pgcreds list
pgcreds get -id UUID
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]

View File

@@ -13,7 +13,7 @@ import (
func (t *tool) runAccount(args []string) {
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] {
case "list":
@@ -28,6 +28,8 @@ func (t *tool) runAccount(args []string) {
t.accountSetStatus(args[1:])
case "reset-totp":
t.accountResetTOTP(args[1:])
case "reset-webauthn":
t.webauthnReset(args[1:])
default:
fatalf("unknown account subcommand %q", args[0])
}

View File

@@ -9,7 +9,7 @@
//
// Usage:
//
// mciasdb --config /etc/mcias/mcias.toml <command> [subcommand] [flags]
// mciasdb --config /srv/mcias/mcias.toml <command> [subcommand] [flags]
//
// Commands:
//
@@ -39,6 +39,8 @@
//
// pgcreds get --id UUID
// pgcreds set --id UUID --host H --port P --db D --user U
//
// snapshot [--retain-days N]
package main
import (
@@ -53,7 +55,7 @@ import (
)
func main() {
configPath := flag.String("config", "mcias.toml", "path to TOML configuration file")
configPath := flag.String("config", "/srv/mcias/mcias.toml", "path to TOML configuration file")
flag.Usage = usage
flag.Parse()
@@ -66,6 +68,14 @@ func main() {
command := args[0]
subArgs := args[1:]
// snapshot loads only the config (no master key needed — VACUUM INTO does
// not access encrypted columns) and must be handled before openDB, which
// requires the master key passphrase env var.
if command == "snapshot" {
runSnapshot(*configPath, subArgs)
return
}
// schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force").
@@ -107,6 +117,10 @@ func main() {
tool.runAudit(subArgs)
case "pgcreds":
tool.runPGCreds(subArgs)
case "webauthn":
tool.runWebAuthn(subArgs)
case "rekey":
tool.runRekey(subArgs)
default:
fatalf("unknown command %q; run with no args for usage", command)
}
@@ -241,6 +255,11 @@ Commands:
account set-password --id UUID (prompts interactively)
account set-status --id UUID --status active|inactive|deleted
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 grant --id UUID --role ROLE
@@ -259,6 +278,14 @@ Commands:
pgcreds set --id UUID --host H [--port P] --db D --user U
(password is prompted interactively)
rekey Re-encrypt all secrets under a new master passphrase
(prompts interactively; requires server to be stopped)
snapshot Write a timestamped VACUUM INTO backup to
<db-dir>/backups/; prune backups older than
--retain-days days (default 30, 0 = keep all).
Does not require the master key passphrase.
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
file. Use it only when the server is unavailable or for break-glass recovery.
All write operations are recorded in the audit log.

View File

@@ -438,3 +438,141 @@ func TestPGCredsGetNotFound(t *testing.T) {
t.Fatal("expected ErrNotFound, got nil")
}
}
// ---- rekey command tests ----
// TestRekeyCommandRoundTrip exercises runRekey end-to-end with real AES-256-GCM
// encryption and actual Argon2id key derivation. It verifies that all secrets
// (signing key, TOTP, pg password) remain accessible after rekey and that the
// old master key no longer decrypts the re-encrypted values.
//
// Note: Argon2id derivation (time=3, memory=128 MiB) makes this test slow (~2 s).
func TestRekeyCommandRoundTrip(t *testing.T) {
tool := newTestTool(t)
// ── Setup: signing key encrypted under old master key ──
_, privKey, err := crypto.GenerateEd25519KeyPair()
if err != nil {
t.Fatalf("generate key pair: %v", err)
}
sigKeyPEM, err := crypto.MarshalPrivateKeyPEM(privKey)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
sigEnc, sigNonce, err := crypto.SealAESGCM(tool.masterKey, sigKeyPEM)
if err != nil {
t.Fatalf("seal signing key: %v", err)
}
if err := tool.db.WriteServerConfig(sigEnc, sigNonce); err != nil {
t.Fatalf("write server config: %v", err)
}
// WriteMasterKeySalt so ReadServerConfig has a valid salt row.
oldSalt, err := crypto.NewSalt()
if err != nil {
t.Fatalf("gen salt: %v", err)
}
if err := tool.db.WriteMasterKeySalt(oldSalt); err != nil {
t.Fatalf("write salt: %v", err)
}
// ── Setup: account with TOTP ──
a, err := tool.db.CreateAccount("rekeyuser", "human", "")
if err != nil {
t.Fatalf("create account: %v", err)
}
totpSecret := []byte("JBSWY3DPEHPK3PXP")
totpEnc, totpNonce, err := crypto.SealAESGCM(tool.masterKey, totpSecret)
if err != nil {
t.Fatalf("seal totp: %v", err)
}
if err := tool.db.SetTOTP(a.ID, totpEnc, totpNonce); err != nil {
t.Fatalf("set totp: %v", err)
}
// ── Setup: pg credentials ──
pgPass := []byte("pgpassword123")
pgEnc, pgNonce, err := crypto.SealAESGCM(tool.masterKey, pgPass)
if err != nil {
t.Fatalf("seal pg pass: %v", err)
}
if err := tool.db.WritePGCredentials(a.ID, "localhost", 5432, "mydb", "myuser", pgEnc, pgNonce); err != nil {
t.Fatalf("write pg creds: %v", err)
}
// ── Pipe new passphrase twice into stdin ──
const newPassphrase = "new-master-passphrase-for-test"
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("create stdin pipe: %v", err)
}
origStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = origStdin })
if _, err := fmt.Fprintf(w, "%s\n%s\n", newPassphrase, newPassphrase); err != nil {
t.Fatalf("write stdin: %v", err)
}
_ = w.Close()
// ── Execute rekey ──
tool.runRekey(nil)
// ── Derive new key from stored salt + new passphrase ──
newSalt, err := tool.db.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read new salt: %v", err)
}
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
if err != nil {
t.Fatalf("derive new key: %v", err)
}
defer func() {
for i := range newKey {
newKey[i] = 0
}
}()
// Signing key must decrypt with new key.
newSigEnc, newSigNonce, err := tool.db.ReadServerConfig()
if err != nil {
t.Fatalf("read server config after rekey: %v", err)
}
decPEM, err := crypto.OpenAESGCM(newKey, newSigNonce, newSigEnc)
if err != nil {
t.Fatalf("decrypt signing key with new key: %v", err)
}
if string(decPEM) != string(sigKeyPEM) {
t.Error("signing key PEM mismatch after rekey")
}
// Old key must NOT decrypt the re-encrypted signing key.
// Security: adversarial check that old key is invalidated.
if _, err := crypto.OpenAESGCM(tool.masterKey, newSigNonce, newSigEnc); err == nil {
t.Error("old key still decrypts signing key after rekey — ciphertext was not replaced")
}
// TOTP must decrypt with new key.
updatedAcct, err := tool.db.GetAccountByUUID(a.UUID)
if err != nil {
t.Fatalf("get account after rekey: %v", err)
}
decTOTP, err := crypto.OpenAESGCM(newKey, updatedAcct.TOTPSecretNonce, updatedAcct.TOTPSecretEnc)
if err != nil {
t.Fatalf("decrypt TOTP with new key: %v", err)
}
if string(decTOTP) != string(totpSecret) {
t.Errorf("TOTP mismatch: got %q, want %q", decTOTP, totpSecret)
}
// pg password must decrypt with new key.
updatedCred, err := tool.db.ReadPGCredentials(a.ID)
if err != nil {
t.Fatalf("read pg creds after rekey: %v", err)
}
decPG, err := crypto.OpenAESGCM(newKey, updatedCred.PGPasswordNonce, updatedCred.PGPasswordEnc)
if err != nil {
t.Fatalf("decrypt pg password with new key: %v", err)
}
if string(decPG) != string(pgPass) {
t.Errorf("pg password mismatch: got %q, want %q", decPG, pgPass)
}
}

154
cmd/mciasdb/rekey.go Normal file
View File

@@ -0,0 +1,154 @@
package main
import (
"fmt"
"os"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
//
// The current master key (already loaded in tool.masterKey by openDB) is used
// to decrypt every encrypted secret: the Ed25519 signing key, all TOTP secrets,
// and all Postgres credential passwords. The operator is then prompted for a
// new passphrase (confirmed), a fresh Argon2id salt is generated, a new 256-bit
// master key is derived, and all secrets are re-encrypted and written back in a
// single atomic SQLite transaction.
//
// Security: The entire re-encryption happens in memory first; the database is
// only updated once all ciphertext has been produced successfully. The new
// salt replaces the old salt atomically within the same transaction so the
// database is never left in a mixed state. Both the old and new master keys
// are zeroed in deferred cleanup. No secret material is logged or printed.
func (t *tool) runRekey(_ []string) {
// ── 1. Decrypt signing key under old master key ──────────────────────
sigKeyEnc, sigKeyNonce, err := t.db.ReadServerConfig()
if err != nil {
fatalf("read server config: %v", err)
}
sigKeyPEM, err := crypto.OpenAESGCM(t.masterKey, sigKeyNonce, sigKeyEnc)
if err != nil {
fatalf("decrypt signing key: %v", err)
}
// ── 2. Decrypt all TOTP secrets under old master key ─────────────────
totpAccounts, err := t.db.ListAccountsWithTOTP()
if err != nil {
fatalf("list accounts with TOTP: %v", err)
}
type totpPlain struct {
secret []byte
accountID int64
}
totpPlaintexts := make([]totpPlain, 0, len(totpAccounts))
for _, a := range totpAccounts {
pt, err := crypto.OpenAESGCM(t.masterKey, a.TOTPSecretNonce, a.TOTPSecretEnc)
if err != nil {
fatalf("decrypt TOTP secret for account %s: %v", a.Username, err)
}
totpPlaintexts = append(totpPlaintexts, totpPlain{accountID: a.ID, secret: pt})
}
// ── 3. Decrypt all pg_credentials passwords under old master key ──────
pgCreds, err := t.db.ListAllPGCredentials()
if err != nil {
fatalf("list pg credentials: %v", err)
}
type pgPlain struct {
password []byte
credID int64
}
pgPlaintexts := make([]pgPlain, 0, len(pgCreds))
for _, c := range pgCreds {
pt, err := crypto.OpenAESGCM(t.masterKey, c.PGPasswordNonce, c.PGPasswordEnc)
if err != nil {
fatalf("decrypt pg password for credential %d: %v", c.ID, err)
}
pgPlaintexts = append(pgPlaintexts, pgPlain{credID: c.ID, password: pt})
}
// ── 4. Prompt for new passphrase (confirmed) ──────────────────────────
fmt.Fprintln(os.Stderr, "Enter new master passphrase (will not echo):")
newPassphrase, err := readPassword("New passphrase: ")
if err != nil {
fatalf("read passphrase: %v", err)
}
if newPassphrase == "" {
fatalf("passphrase must not be empty")
}
confirm, err := readPassword("Confirm passphrase: ")
if err != nil {
fatalf("read passphrase confirmation: %v", err)
}
if newPassphrase != confirm {
fatalf("passphrases do not match")
}
// ── 5. Derive new master key ──────────────────────────────────────────
// Security: a fresh random salt is generated for every rekey so that the
// new key is independent of the old key even if the same passphrase is
// reused. The new salt is stored atomically with the re-encrypted secrets.
newSalt, err := crypto.NewSalt()
if err != nil {
fatalf("generate new salt: %v", err)
}
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
if err != nil {
fatalf("derive new master key: %v", err)
}
// Zero both keys when done, regardless of outcome.
defer func() {
for i := range newKey {
newKey[i] = 0
}
}()
// ── 6. Re-encrypt signing key ─────────────────────────────────────────
newSigKeyEnc, newSigKeyNonce, err := crypto.SealAESGCM(newKey, sigKeyPEM)
if err != nil {
fatalf("re-encrypt signing key: %v", err)
}
// ── 7. Re-encrypt TOTP secrets ────────────────────────────────────────
totpRows := make([]db.TOTPRekeyRow, 0, len(totpPlaintexts))
for _, tp := range totpPlaintexts {
enc, nonce, err := crypto.SealAESGCM(newKey, tp.secret)
if err != nil {
fatalf("re-encrypt TOTP secret for account %d: %v", tp.accountID, err)
}
totpRows = append(totpRows, db.TOTPRekeyRow{
AccountID: tp.accountID,
Enc: enc,
Nonce: nonce,
})
}
// ── 8. Re-encrypt pg_credentials passwords ────────────────────────────
pgRows := make([]db.PGRekeyRow, 0, len(pgPlaintexts))
for _, pp := range pgPlaintexts {
enc, nonce, err := crypto.SealAESGCM(newKey, pp.password)
if err != nil {
fatalf("re-encrypt pg password for credential %d: %v", pp.credID, err)
}
pgRows = append(pgRows, db.PGRekeyRow{
CredentialID: pp.credID,
Enc: enc,
Nonce: nonce,
})
}
// ── 9. Atomic commit ──────────────────────────────────────────────────
if err := t.db.Rekey(newSalt, newSigKeyEnc, newSigKeyNonce, totpRows, pgRows); err != nil {
fatalf("rekey database: %v", err)
}
if err := t.db.WriteAuditEvent("master_key_rekeyed", nil, nil, "", `{"actor":"mciasdb"}`); err != nil {
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
}
fmt.Printf("Rekey complete: %d TOTP secrets and %d pg credentials re-encrypted.\n",
len(totpRows), len(pgRows))
fmt.Fprintln(os.Stderr, "Update your mcias.toml or passphrase environment variable to use the new passphrase.")
}

44
cmd/mciasdb/snapshot.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"flag"
"fmt"
"path/filepath"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
// runSnapshot handles the "snapshot" command.
//
// It opens the database read-only (no master key derivation needed — VACUUM
// INTO does not access encrypted columns) and writes a timestamped backup to
// /srv/mcias/backups/ (or the directory adjacent to the configured DB path).
// Backups older than --retain-days are pruned.
func runSnapshot(configPath string, args []string) {
fs := flag.NewFlagSet("snapshot", flag.ExitOnError)
retainDays := fs.Int("retain-days", 30, "prune backups older than this many days (0 = keep all)")
if err := fs.Parse(args); err != nil {
fatalf("snapshot: %v", err)
}
cfg, err := config.Load(configPath)
if err != nil {
fatalf("snapshot: load config: %v", err)
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
fatalf("snapshot: open database: %v", err)
}
defer func() { _ = database.Close() }()
// Place backups in a "backups" directory adjacent to the database file.
backupDir := filepath.Join(filepath.Dir(cfg.Database.Path), "backups")
dest, err := database.SnapshotDir(backupDir, *retainDays)
if err != nil {
fatalf("snapshot: %v", err)
}
fmt.Printf("snapshot written: %s\n", dest)
}

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

@@ -9,7 +9,7 @@
//
// Usage:
//
// mciassrv -config /etc/mcias/mcias.toml
// mciassrv -config /srv/mcias/mcias.toml
package main
import (
@@ -36,10 +36,11 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
"git.wntrmute.dev/kyle/mcias/internal/server"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
func main() {
configPath := flag.String("config", "mcias.toml", "path to TOML configuration file")
configPath := flag.String("config", "/srv/mcias/mcias.toml", "path to TOML configuration file")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
@@ -72,29 +73,45 @@ func run(configPath string, logger *slog.Logger) error {
}
logger.Info("database ready", "path", cfg.Database.Path)
// Derive or load the master encryption key.
// Derive or load the master encryption key and build the vault.
// Security: The master key encrypts TOTP secrets, Postgres passwords, and
// the signing key at rest. It is derived from a passphrase via Argon2id
// (or loaded directly from a key file). The KDF salt is stored in the DB
// for stability across restarts. The passphrase env var is cleared after use.
masterKey, err := loadMasterKey(cfg, database)
if err != nil {
return fmt.Errorf("load master key: %w", err)
}
defer func() {
// Zero the master key when done — reduces the window of exposure.
for i := range masterKey {
masterKey[i] = 0
//
// When the passphrase is not available (empty env var in passphrase mode
// with no key file), the server starts in sealed state. The operator must
// provide the passphrase via the /v1/vault/unseal API or the /unseal UI page.
// First run (no signing key in DB) still requires the passphrase at startup.
var v *vault.Vault
masterKey, mkErr := loadMasterKey(cfg, database)
if mkErr != nil {
// Check if we can start sealed (passphrase mode, empty env var).
if cfg.MasterKey.KeyFile != "" || os.Getenv(cfg.MasterKey.PassphraseEnv) != "" {
return fmt.Errorf("load master key: %w", mkErr)
}
}()
// Load or generate the Ed25519 signing key.
// Security: The private signing key is stored AES-256-GCM encrypted in the
// database. On first run it is generated and stored. The key is decrypted
// with the master key each startup.
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
if err != nil {
return fmt.Errorf("signing key: %w", err)
// 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 {
// Load or generate the Ed25519 signing key.
// Security: The private signing key is stored AES-256-GCM encrypted in the
// database. On first run it is generated and stored. The key is decrypted
// with the master key each startup.
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
if err != nil {
// Zero master key on failure.
for i := range masterKey {
masterKey[i] = 0
}
return fmt.Errorf("signing key: %w", err)
}
v = vault.NewUnsealed(masterKey, privKey, pubKey)
logger.Info("vault unsealed at startup")
}
// Configure TLS. We require TLS 1.2+ and prefer TLS 1.3.
@@ -108,8 +125,8 @@ func run(configPath string, logger *slog.Logger) error {
},
}
// Build the REST handler.
restSrv := server.New(database, cfg, privKey, pubKey, masterKey, logger)
// Build the REST handler. All servers share the same vault by pointer.
restSrv := server.New(database, cfg, v, logger)
httpServer := &http.Server{
Addr: cfg.Server.ListenAddr,
Handler: restSrv.Handler(),
@@ -131,7 +148,7 @@ func run(configPath string, logger *slog.Logger) error {
return fmt.Errorf("load gRPC TLS credentials: %w", err)
}
grpcSrvImpl := grpcserver.New(database, cfg, privKey, pubKey, masterKey, logger)
grpcSrvImpl := grpcserver.New(database, cfg, v, logger)
// Build server directly with TLS credentials. GRPCServerWithCreds builds
// the server with transport credentials at construction time per gRPC idiom.
grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds)

View File

@@ -15,7 +15,7 @@
# export MCIAS_MASTER_PASSPHRASE=devpassphrase
#
# Start the server:
# mciassrv -config /path/to/mcias-dev.conf
# mciassrv -config /path/to/mcias-dev.toml
[server]
listen_addr = "127.0.0.1:8443"
@@ -41,3 +41,10 @@ threads = 4
[master_key]
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# WebAuthn — passkey authentication for local development.
# rp_origin includes the non-standard port since we're not behind a proxy.
[webauthn]
rp_id = "localhost"
rp_origin = "https://localhost:8443"
display_name = "MCIAS (dev)"

View File

@@ -1,42 +1,40 @@
# mcias.conf.docker.example — Config template for container deployment
#
# Mount this file into the container at /etc/mcias/mcias.conf:
# Mount this file into the container at /srv/mcias/mcias.toml:
#
# docker run -d \
# --name mcias \
# -v /path/to/mcias.conf:/etc/mcias/mcias.conf:ro \
# -v /path/to/certs:/etc/mcias:ro \
# -v mcias-data:/data \
# -v /srv/mcias:/srv/mcias \
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
# -p 8443:8443 \
# -p 9443:9443 \
# mcias:latest
#
# The container runs as uid 10001 (mcias). Ensure that:
# - /data volume is writable by uid 10001
# - /srv/mcias is writable by uid 10001
# - TLS cert and key are readable by uid 10001
#
# TLS: The server performs TLS termination inside the container; there is no
# plain-text mode. Mount your certificate and key under /etc/mcias/.
# plain-text mode. Place your certificate and key under /srv/mcias/.
# For Let's Encrypt certificates, mount the live/ directory read-only.
[server]
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443"
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
tls_cert = "/srv/mcias/server.crt"
tls_key = "/srv/mcias/server.key"
# If a reverse proxy (nginx, Caddy, Traefik) sits in front of this container,
# set trusted_proxy to its container IP so real client IPs are used for rate
# limiting and audit logging. Leave commented out for direct exposure.
# trusted_proxy = "172.17.0.1"
[database]
# VOLUME /data is declared in the Dockerfile; map a named volume here.
path = "/data/mcias.db"
# All data lives under /srv/mcias for a single-volume deployment.
path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
default_expiry = "720h"
default_expiry = "168h"
admin_expiry = "8h"
service_expiry = "8760h"
@@ -50,3 +48,14 @@ threads = 4
# Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ...
# or with a Docker secret / Kubernetes secret.
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# ---------------------------------------------------------------------------
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
# ---------------------------------------------------------------------------
# Uncomment to enable passwordless passkey login. Set rp_id to your domain
# and rp_origin to the full HTTPS origin users access in their browser.
#
# [webauthn]
# rp_id = "auth.example.com"
# rp_origin = "https://auth.example.com"
# display_name = "MCIAS"

View File

@@ -1,12 +1,12 @@
# mcias.conf — Reference configuration for mciassrv
#
# Copy this file to /etc/mcias/mcias.conf and adjust the values for your
# Copy this file to /srv/mcias/mcias.toml and adjust the values for your
# deployment. All fields marked REQUIRED must be set before the server will
# start. Fields marked OPTIONAL can be omitted to use defaults.
#
# File permissions: mode 0640, owner root:mcias.
# chmod 0640 /etc/mcias/mcias.conf
# chown root:mcias /etc/mcias/mcias.conf
# chmod 0640 /srv/mcias/mcias.toml
# chown root:mcias /srv/mcias/mcias.toml
# ---------------------------------------------------------------------------
# [server] — Network listener configuration
@@ -26,11 +26,11 @@ listen_addr = "0.0.0.0:8443"
# REQUIRED. Path to the TLS certificate (PEM format).
# Self-signed certificates work fine for personal deployments; for
# public-facing deployments consider a certificate from Let's Encrypt.
tls_cert = "/etc/mcias/server.crt"
tls_cert = "/srv/mcias/server.crt"
# REQUIRED. Path to the TLS private key (PEM format).
# Permissions: mode 0640, owner root:mcias.
tls_key = "/etc/mcias/server.key"
tls_key = "/srv/mcias/server.key"
# OPTIONAL. IP address of a trusted reverse proxy (e.g. nginx, Caddy, HAProxy).
# When set, the rate limiter and audit log extract the real client IP from the
@@ -55,7 +55,7 @@ tls_key = "/etc/mcias/server.key"
# REQUIRED. Path to the SQLite database file.
# The directory must be writable by the mcias user. WAL mode is enabled
# automatically; expect three files: mcias.db, mcias.db-wal, mcias.db-shm.
path = "/var/lib/mcias/mcias.db"
path = "/srv/mcias/mcias.db"
# ---------------------------------------------------------------------------
# [tokens] — JWT issuance policy
@@ -69,8 +69,8 @@ issuer = "https://auth.example.com"
# OPTIONAL. Default token expiry for interactive (human) logins.
# Go duration string: "h" hours, "m" minutes, "s" seconds.
# Default: 720h (30 days). Reduce for higher-security deployments.
default_expiry = "720h"
# Default: 168h (7 days). The maximum allowed value is 720h (30 days).
default_expiry = "168h"
# OPTIONAL. Expiry for admin tokens (tokens with the "admin" role).
# Should be shorter than default_expiry to limit the blast radius of
@@ -113,13 +113,34 @@ threads = 4
# database on first run and reused on subsequent runs so the same passphrase
# always produces the same master key.
#
# Set the passphrase in /etc/mcias/env (loaded by the systemd EnvironmentFile
# Set the passphrase in /srv/mcias/env (loaded by the systemd EnvironmentFile
# directive). See dist/mcias.env.example for the template.
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# Option B: Key file mode. The file must contain exactly 32 bytes of raw key
# material (AES-256). Generate with: openssl rand -out /etc/mcias/master.key 32
# material (AES-256). Generate with: openssl rand -out /srv/mcias/master.key 32
# Permissions: mode 0640, owner root:mcias.
#
# Uncomment and comment out passphrase_env to switch modes.
# keyfile = "/etc/mcias/master.key"
# keyfile = "/srv/mcias/master.key"
# ---------------------------------------------------------------------------
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
# ---------------------------------------------------------------------------
# Enables passwordless passkey login and hardware security key 2FA.
# If this section is omitted or rp_id/rp_origin are empty, WebAuthn is
# disabled and passkey options will not appear in the UI.
#
# [webauthn]
#
# REQUIRED (if enabling). The Relying Party ID — typically the domain name
# (without port or scheme). Must match the domain users see in their browser.
# rp_id = "auth.example.com"
#
# REQUIRED (if enabling). The Relying Party Origin — the full origin URL
# including scheme. Must be HTTPS. Include the port if non-standard (not 443).
# rp_origin = "https://auth.example.com"
#
# OPTIONAL. Display name shown to users during passkey registration prompts.
# Default: "MCIAS".
# display_name = "MCIAS"

View File

@@ -1,10 +1,10 @@
# /etc/mcias/env — Environment file for mciassrv (systemd EnvironmentFile).
# /srv/mcias/env — Environment file for mciassrv (systemd EnvironmentFile).
#
# This file is loaded by the mcias.service unit before the server starts.
# It must be readable only by root and the mcias service account:
#
# chmod 0640 /etc/mcias/env
# chown root:mcias /etc/mcias/env
# chmod 0640 /srv/mcias/env
# chown root:mcias /srv/mcias/env
#
# SECURITY: This file contains the master key passphrase. Treat it with
# the same care as a private key. Do not commit it to version control.

View File

@@ -1,13 +1,13 @@
#!/bin/sh
# install.sh — MCIAS first-time and upgrade installer
#
# Usage: sh dist/install.sh
# Usage: sh deploy/scripts/install.sh
#
# This script must be run as root. It:
# 1. Creates the mcias system user and group (idempotent).
# 2. Copies binaries to /usr/local/bin/.
# 3. Creates /etc/mcias/ and /var/lib/mcias/ with correct permissions.
# 4. Installs the systemd service unit.
# 3. Creates /srv/mcias/ with correct permissions.
# 4. Installs the systemd service and backup units.
# 5. Prints post-install instructions.
#
# The script does NOT start or enable the service automatically. Review the
@@ -25,14 +25,14 @@ set -eu
# Configuration
# ---------------------------------------------------------------------------
BIN_DIR="/usr/local/bin"
CONF_DIR="/etc/mcias"
DATA_DIR="/var/lib/mcias"
SRV_DIR="/srv/mcias"
MAN_DIR="/usr/share/man/man1"
SYSTEMD_DIR="/etc/systemd/system"
SERVICE_USER="mcias"
SERVICE_GROUP="mcias"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
REPO_ROOT="$(dirname "$DEPLOY_DIR")"
# ---------------------------------------------------------------------------
# Helpers
@@ -101,11 +101,7 @@ fi
# Step 2: Install binaries.
info "Installing binaries to $BIN_DIR"
for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
src="$REPO_ROOT/$bin"
if [ ! -f "$src" ]; then
# Try bin/ subdirectory (Makefile build output).
src="$REPO_ROOT/bin/$bin"
fi
src="$REPO_ROOT/bin/$bin"
if [ ! -f "$src" ]; then
warn "Binary not found: $bin — skipping. Run 'make build' first."
continue
@@ -114,34 +110,40 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
done
# Step 3: Create configuration directory.
info "Creating $CONF_DIR"
install -d -m 0750 -o root -g "$SERVICE_GROUP" "$CONF_DIR"
# Step 3: Create service directory structure.
info "Creating $SRV_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/certs"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/backups"
# Install example config files; never overwrite existing configs.
for f in mcias.conf.example mcias.env.example; do
src="$SCRIPT_DIR/$f"
dst="$CONF_DIR/$f"
src="$DEPLOY_DIR/examples/$f"
dst="$SRV_DIR/$f"
if [ -f "$src" ]; then
install -m 0640 -o root -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
fi
done
# Step 4: Create data directory.
info "Creating $DATA_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$DATA_DIR"
# Step 5: Install systemd service unit.
# Step 4: Install systemd units.
if [ -d "$SYSTEMD_DIR" ]; then
info "Installing systemd service unit to $SYSTEMD_DIR"
install -m 0644 -o root -g root "$SCRIPT_DIR/mcias.service" "$SYSTEMD_DIR/mcias.service"
info "Installing systemd units to $SYSTEMD_DIR"
for unit in mcias.service mcias-backup.service mcias-backup.timer; do
src="$DEPLOY_DIR/systemd/$unit"
if [ -f "$src" ]; then
install -m 0644 -o root -g root "$src" "$SYSTEMD_DIR/$unit"
info " Installed $unit"
fi
done
info "Reloading systemd daemon"
systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually."
info "Enabling backup timer"
systemctl enable mcias-backup.timer 2>/dev/null || warn "Could not enable timer; enable manually with: systemctl enable mcias-backup.timer"
else
warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation."
fi
# Step 6: Install man pages.
# Step 5: Install man pages.
if [ -d "$REPO_ROOT/man/man1" ]; then
install -d -m 0755 -o root -g root "$MAN_DIR"
info "Installing man pages to $MAN_DIR"
@@ -175,26 +177,26 @@ Next steps:
# Self-signed (development / personal use):
openssl req -x509 -newkey ed25519 -days 3650 \\
-keyout /etc/mcias/server.key \\
-out /etc/mcias/server.crt \\
-keyout /srv/mcias/certs/server.key \\
-out /srv/mcias/certs/server.crt \\
-subj "/CN=auth.example.com" \\
-nodes
chmod 0640 /etc/mcias/server.key
chown root:mcias /etc/mcias/server.key
chmod 0640 /srv/mcias/certs/server.key
chown mcias:mcias /srv/mcias/certs/server.key /srv/mcias/certs/server.crt
2. Copy and edit the configuration file:
cp /etc/mcias/mcias.conf.example /etc/mcias/mcias.conf
\$EDITOR /etc/mcias/mcias.conf
chmod 0640 /etc/mcias/mcias.conf
chown root:mcias /etc/mcias/mcias.conf
cp /srv/mcias/mcias.conf.example /srv/mcias/mcias.toml
\$EDITOR /srv/mcias/mcias.toml
chmod 0640 /srv/mcias/mcias.toml
chown mcias:mcias /srv/mcias/mcias.toml
3. Set the master key passphrase:
cp /etc/mcias/mcias.env.example /etc/mcias/env
\$EDITOR /etc/mcias/env # replace the placeholder passphrase
chmod 0640 /etc/mcias/env
chown root:mcias /etc/mcias/env
cp /srv/mcias/mcias.env.example /srv/mcias/env
\$EDITOR /srv/mcias/env # replace the placeholder passphrase
chmod 0640 /srv/mcias/env
chown mcias:mcias /srv/mcias/env
IMPORTANT: Back up the passphrase to a secure offline location.
Losing it means losing access to all encrypted data in the database.
@@ -205,19 +207,22 @@ Next steps:
systemctl start mcias
systemctl status mcias
The backup timer was enabled automatically. Verify with:
systemctl status mcias-backup.timer
5. Create the first admin account using mciasdb (while the server is
running, or before first start):
MCIAS_MASTER_PASSPHRASE=\$(grep MCIAS_MASTER_PASSPHRASE /etc/mcias/env | cut -d= -f2) \\
mciasdb --config /etc/mcias/mcias.conf account create \\
MCIAS_MASTER_PASSPHRASE=\$(grep MCIAS_MASTER_PASSPHRASE /srv/mcias/env | cut -d= -f2) \\
mciasdb --config /srv/mcias/mcias.toml account create \\
--username admin --type human
Then set a password:
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /etc/mcias/mcias.conf \\
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /srv/mcias/mcias.toml \\
account set-password --id <UUID>
And grant the admin role:
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /etc/mcias/mcias.conf \\
MCIAS_MASTER_PASSPHRASE=... mciasdb --config /srv/mcias/mcias.toml \\
role grant --id <UUID> --role admin
For full documentation, see: man mciassrv

View File

@@ -0,0 +1,32 @@
[Unit]
Description=MCIAS Database Backup
Documentation=man:mciasdb(1)
After=mcias.service
# Backup runs against the live database using VACUUM INTO, which is safe
# while mciassrv is running (WAL mode allows concurrent readers).
[Service]
Type=oneshot
User=mcias
Group=mcias
EnvironmentFile=/srv/mcias/env
ExecStart=/usr/local/bin/mciasdb -config /srv/mcias/mcias.toml snapshot
# Filesystem restrictions (read-write to /srv/mcias for the backup output).
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/mcias
NoNewPrivileges=true
PrivateDevices=true
CapabilityBoundingSet=
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Daily MCIAS Database Backup
Documentation=man:mciasdb(1)
[Timer]
# Run daily at 02:00 UTC with up to 5-minute random jitter to avoid
# thundering-herd on systems with many services.
OnCalendar=*-*-* 02:00:00 UTC
RandomizedDelaySec=5min
# Run immediately on boot if the last scheduled run was missed
# (e.g. host was offline at 02:00).
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -11,11 +11,11 @@ User=mcias
Group=mcias
# Configuration and secrets.
# /etc/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
# See dist/mcias.env.example for the template.
EnvironmentFile=/etc/mcias/env
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
# See deploy/examples/mcias.env.example for the template.
EnvironmentFile=/srv/mcias/env
ExecStart=/usr/local/bin/mciassrv -config /etc/mcias/mcias.conf
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
Restart=on-failure
RestartSec=5
@@ -30,11 +30,11 @@ LimitNOFILE=65536
CapabilityBoundingSet=
# Filesystem restrictions.
# mciassrv reads /etc/mcias (config, TLS cert/key) and writes /var/lib/mcias (DB).
# mciassrv reads and writes /srv/mcias (config, TLS cert/key, database).
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/mcias
ReadWritePaths=/srv/mcias
# Additional hardening.
NoNewPrivileges=true
@@ -42,6 +42,7 @@ PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true

View File

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

View File

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

View File

@@ -304,9 +304,12 @@ func (x *RenewTokenResponse) GetExpiresAt() *timestamppb.Timestamp {
return nil
}
// EnrollTOTPRequest carries no body; the acting account is from the JWT.
// EnrollTOTPRequest carries the current password for re-authentication.
// Security (SEC-01): password is required to prevent a stolen session token
// from being used to enroll attacker-controlled TOTP on the victim's account.
type EnrollTOTPRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` // security: current password required; never logged
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -341,6 +344,13 @@ func (*EnrollTOTPRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{6}
}
func (x *EnrollTOTPRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
// EnrollTOTPResponse returns the TOTP secret and otpauth URI for display.
// Security: the secret is shown once; it is stored only in encrypted form.
type EnrollTOTPResponse struct {
@@ -559,6 +569,288 @@ func (*RemoveTOTPResponse) Descriptor() ([]byte, []int) {
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
const file_mcias_v1_auth_proto_rawDesc = "" +
@@ -578,8 +870,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\x12RenewTokenResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x129\n" +
"\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x13\n" +
"\x11EnrollTOTPRequest\"M\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"/\n" +
"\x11EnrollTOTPRequest\x12\x1a\n" +
"\bpassword\x18\x01 \x01(\tR\bpassword\"M\n" +
"\x12EnrollTOTPResponse\x12\x16\n" +
"\x06secret\x18\x01 \x01(\tR\x06secret\x12\x1f\n" +
"\votpauth_uri\x18\x02 \x01(\tR\n" +
@@ -590,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\x11RemoveTOTPRequest\x12\x1d\n" +
"\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" +
"\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" +
@@ -600,7 +917,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"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" +
"\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 (
file_mcias_v1_auth_proto_rawDescOnce sync.Once
@@ -614,42 +933,54 @@ func file_mcias_v1_auth_proto_rawDescGZIP() []byte {
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{
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
(*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{
12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
8, // [8:14] is the sub-list for method output_type
2, // [2:8] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp
17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp
13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo
0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest
15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest
1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
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() }
@@ -663,7 +994,7 @@ func file_mcias_v1_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
NumEnums: 0,
NumMessages: 12,
NumMessages: 17,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -21,12 +21,14 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
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.
@@ -53,6 +55,12 @@ type AuthServiceClient interface {
// RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata.
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 {
@@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques
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.
// All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility.
@@ -147,6 +175,12 @@ type AuthServiceServer interface {
// RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata.
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()
}
@@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
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) testEmbeddedByValue() {}
@@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f
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.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RemoveTOTP",
Handler: _AuthService_RemoveTOTP_Handler,
},
{
MethodName: "ListWebAuthnCredentials",
Handler: _AuthService_ListWebAuthnCredentials_Handler,
},
{
MethodName: "RemoveWebAuthnCredential",
Handler: _AuthService_RemoveWebAuthnCredential_Handler,
},
},
Streams: []grpc.StreamDesc{},
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/google/uuid v1.6.0
github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.7
modernc.org/sqlite v1.46.1
@@ -16,13 +16,21 @@ require (
require (
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/ncruces/go-strftime v1.0.0 // 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/net v0.47.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
modernc.org/libc v1.67.6 // 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/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/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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
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-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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/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-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/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
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/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
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/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
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.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
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/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
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/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=

33
internal/audit/detail.go Normal file
View File

@@ -0,0 +1,33 @@
// Package audit provides helpers for constructing audit log detail strings.
package audit
import "encoding/json"
// JSON builds a JSON details string from key-value pairs for audit logging.
// Uses json.Marshal for safe encoding rather than fmt.Sprintf with %q,
// which is fragile for edge-case Unicode.
func JSON(pairs ...string) string {
if len(pairs)%2 != 0 {
return "{}"
}
m := make(map[string]string, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
m[pairs[i]] = pairs[i+1]
}
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}
// JSONWithRoles builds a JSON details string that includes a "roles" key
// mapped to a string slice. This produces a proper JSON array for the value.
func JSONWithRoles(roles []string) string {
m := map[string][]string{"roles": roles}
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}

View File

@@ -0,0 +1,163 @@
package audit
import (
"encoding/json"
"testing"
)
func TestJSON(t *testing.T) {
tests := []struct {
verify func(t *testing.T, result string)
name string
pairs []string
}{
{
name: "single pair",
pairs: []string{"username", "alice"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if m["username"] != "alice" {
t.Fatalf("expected alice, got %s", m["username"])
}
},
},
{
name: "multiple pairs",
pairs: []string{"jti", "abc-123", "reason", "logout"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if m["jti"] != "abc-123" {
t.Fatalf("expected abc-123, got %s", m["jti"])
}
if m["reason"] != "logout" {
t.Fatalf("expected logout, got %s", m["reason"])
}
},
},
{
name: "special characters in values",
pairs: []string{"username", "user\"with\\quotes"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON for special chars: %v", err)
}
if m["username"] != "user\"with\\quotes" {
t.Fatalf("unexpected value: %s", m["username"])
}
},
},
{
name: "unicode edge cases",
pairs: []string{"username", "user\u2028line\u2029sep"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON for unicode: %v", err)
}
if m["username"] != "user\u2028line\u2029sep" {
t.Fatalf("unexpected value: %s", m["username"])
}
},
},
{
name: "null bytes in value",
pairs: []string{"data", "before\x00after"},
verify: func(t *testing.T, result string) {
var m map[string]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON for null bytes: %v", err)
}
if m["data"] != "before\x00after" {
t.Fatalf("unexpected value: %q", m["data"])
}
},
},
{
name: "odd number of args returns empty object",
pairs: []string{"key"},
verify: func(t *testing.T, result string) {
if result != "{}" {
t.Fatalf("expected {}, got %s", result)
}
},
},
{
name: "no args returns empty object",
pairs: nil,
verify: func(t *testing.T, result string) {
if result != "{}" {
t.Fatalf("expected {}, got %s", result)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := JSON(tc.pairs...)
tc.verify(t, result)
})
}
}
func TestJSONWithRoles(t *testing.T) {
tests := []struct {
verify func(t *testing.T, result string)
name string
roles []string
}{
{
name: "multiple roles",
roles: []string{"admin", "editor"},
verify: func(t *testing.T, result string) {
var m map[string][]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(m["roles"]) != 2 || m["roles"][0] != "admin" || m["roles"][1] != "editor" {
t.Fatalf("unexpected roles: %v", m["roles"])
}
},
},
{
name: "empty roles",
roles: []string{},
verify: func(t *testing.T, result string) {
var m map[string][]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(m["roles"]) != 0 {
t.Fatalf("expected empty roles, got %v", m["roles"])
}
},
},
{
name: "roles with special characters",
roles: []string{"role\"special"},
verify: func(t *testing.T, result string) {
var m map[string][]string
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if m["roles"][0] != "role\"special" {
t.Fatalf("unexpected role: %s", m["roles"][0])
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := JSONWithRoles(tc.roles)
tc.verify(t, result)
})
}
}

View File

@@ -8,18 +8,29 @@ import (
"fmt"
"net"
"os"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
// Config is the top-level configuration structure parsed from the TOML file.
type Config struct {
type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable
Server ServerConfig `toml:"server"`
MasterKey MasterKeyConfig `toml:"master_key"`
Database DatabaseConfig `toml:"database"`
Tokens TokensConfig `toml:"tokens"`
Argon2 Argon2Config `toml:"argon2"`
WebAuthn WebAuthnConfig `toml:"webauthn"`
}
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
// section disables WebAuthn support. If any field is set, RPID and RPOrigin are
// required and RPOrigin must use the HTTPS scheme.
type WebAuthnConfig struct {
RPID string `toml:"rp_id"`
RPOrigin string `toml:"rp_origin"`
DisplayName string `toml:"display_name"`
}
// ServerConfig holds HTTP listener and TLS settings.
@@ -75,7 +86,7 @@ type MasterKeyConfig struct {
}
// duration is a wrapper around time.Duration that supports TOML string parsing
// (e.g. "720h", "8h").
// (e.g. "168h", "8h").
type duration struct {
time.Duration
}
@@ -174,8 +185,8 @@ func (c *Config) validate() error {
// generous to accommodate a range of legitimate deployments while
// catching obvious typos (e.g. "876000h" instead of "8760h").
const (
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
maxAdminExpiry = 24 * time.Hour // 24 hours
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
maxAdminExpiry = 24 * time.Hour // 24 hours
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
)
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"))
}
// WebAuthn — if any field is set, RPID and RPOrigin are required.
hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != ""
if hasWebAuthn {
if c.WebAuthn.RPID == "" {
errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured"))
}
if c.WebAuthn.RPOrigin == "" {
errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured"))
} else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") {
errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin))
}
}
return errors.Join(errs...)
}
@@ -233,3 +257,8 @@ func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Durat
// ServiceExpiry returns the configured service token expiry duration.
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
func (c *Config) WebAuthnEnabled() bool {
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
}

View File

@@ -12,11 +12,11 @@ func validConfig() string {
return `
[server]
listen_addr = "0.0.0.0:8443"
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
tls_cert = "/srv/mcias/server.crt"
tls_key = "/srv/mcias/server.key"
[database]
path = "/var/lib/mcias/mcias.db"
path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
@@ -154,11 +154,11 @@ func TestValidateMasterKeyBothSet(t *testing.T) {
path := writeTempConfig(t, `
[server]
listen_addr = "0.0.0.0:8443"
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
tls_cert = "/srv/mcias/server.crt"
tls_key = "/srv/mcias/server.key"
[database]
path = "/var/lib/mcias/mcias.db"
path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
@@ -173,7 +173,7 @@ threads = 4
[master_key]
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
keyfile = "/etc/mcias/master.key"
keyfile = "/srv/mcias/master.key"
`)
_, err := Load(path)
if err == nil {
@@ -185,11 +185,11 @@ func TestValidateMasterKeyNoneSet(t *testing.T) {
path := writeTempConfig(t, `
[server]
listen_addr = "0.0.0.0:8443"
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
tls_cert = "/srv/mcias/server.crt"
tls_key = "/srv/mcias/server.key"
[database]
path = "/var/lib/mcias/mcias.db"
path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
@@ -213,9 +213,9 @@ threads = 4
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
func TestTrustedProxyValidation(t *testing.T) {
tests := []struct {
name string
proxy string
wantErr bool
name string
proxy string
wantErr bool
}{
{"empty is valid (disabled)", "", false},
{"valid IPv4", "127.0.0.1", false},

View File

@@ -692,6 +692,70 @@ func (db *DB) RenewToken(oldJTI, reason, newJTI string, accountID int64, issuedA
return nil
}
// IssueSystemToken atomically revokes an existing system token (if oldJTI is
// non-empty), tracks the new token in token_revocation, and upserts the
// system_tokens table — all within a single SQLite transaction.
//
// Security: these three operations must be atomic so that a crash between them
// cannot leave the database in an inconsistent state (e.g., old token revoked
// but new token not tracked, or token tracked but system_tokens not updated).
// With MaxOpenConns(1) and SQLite's serialised write path, BEGIN IMMEDIATE
// acquires the write lock immediately and prevents any other writer from
// interleaving.
func (db *DB) IssueSystemToken(oldJTI, newJTI string, accountID int64, issuedAt, expiresAt time.Time) error {
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: issue system token begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
n := now()
// If there is an existing token, revoke it.
if oldJTI != "" {
_, err := tx.Exec(`
UPDATE token_revocation
SET revoked_at = ?, revoke_reason = ?
WHERE jti = ? AND revoked_at IS NULL
`, n, nullString("rotated"), oldJTI)
if err != nil {
return fmt.Errorf("db: issue system token revoke old %q: %w", oldJTI, err)
}
// We do not require rows affected > 0 because the old token may
// already be revoked or expired; the important thing is that we
// proceed to track the new token regardless.
}
// Track the new token in token_revocation.
_, err = tx.Exec(`
INSERT INTO token_revocation (jti, account_id, issued_at, expires_at)
VALUES (?, ?, ?, ?)
`, newJTI, accountID,
issuedAt.UTC().Format(time.RFC3339),
expiresAt.UTC().Format(time.RFC3339))
if err != nil {
return fmt.Errorf("db: issue system token track new %q: %w", newJTI, err)
}
// Upsert the system_tokens table so GetSystemToken returns the new JTI.
_, err = tx.Exec(`
INSERT INTO system_tokens (account_id, jti, expires_at, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
jti = excluded.jti,
expires_at = excluded.expires_at,
created_at = excluded.created_at
`, accountID, newJTI, expiresAt.UTC().Format(time.RFC3339), n)
if err != nil {
return fmt.Errorf("db: issue system token set system token for account %d: %w", accountID, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: issue system token commit: %w", err)
}
return nil
}
// RevokeAllUserTokens revokes all non-expired, non-revoked tokens for an account.
func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
n := now()
@@ -1181,3 +1245,268 @@ func (db *DB) ClearLoginFailures(accountID int64) error {
}
return nil
}
// ListAccountsWithTOTP returns all accounts (including deleted) that have a
// non-null TOTP secret stored, so that rekey can re-encrypt every secret even
// for inactive or deleted accounts.
func (db *DB) ListAccountsWithTOTP() ([]*model.Account, error) {
rows, err := db.sql.Query(`
SELECT id, uuid, username, account_type, COALESCE(password_hash,''),
status, totp_required,
totp_secret_enc, totp_secret_nonce,
created_at, updated_at, deleted_at
FROM accounts
WHERE totp_secret_enc IS NOT NULL
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("db: list accounts with TOTP: %w", err)
}
defer func() { _ = rows.Close() }()
var accounts []*model.Account
for rows.Next() {
a, err := db.scanAccountRow(rows)
if err != nil {
return nil, err
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}
// ListAllPGCredentials returns every row in pg_credentials. Used by rekey
// to re-encrypt all stored passwords under a new master key.
func (db *DB) ListAllPGCredentials() ([]*model.PGCredential, error) {
rows, err := db.sql.Query(`
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id
FROM pg_credentials
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("db: list all pg credentials: %w", err)
}
defer func() { _ = rows.Close() }()
var creds []*model.PGCredential
for rows.Next() {
var cred model.PGCredential
var createdAtStr, updatedAtStr string
var ownerID sql.NullInt64
if err := rows.Scan(
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
&cred.PGDatabase, &cred.PGUsername,
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
&createdAtStr, &updatedAtStr, &ownerID,
); err != nil {
return nil, fmt.Errorf("db: scan pg credential: %w", err)
}
var parseErr error
cred.CreatedAt, parseErr = parseTime(createdAtStr)
if parseErr != nil {
return nil, parseErr
}
cred.UpdatedAt, parseErr = parseTime(updatedAtStr)
if parseErr != nil {
return nil, parseErr
}
if ownerID.Valid {
v := ownerID.Int64
cred.OwnerID = &v
}
creds = append(creds, &cred)
}
return creds, rows.Err()
}
// TOTPRekeyRow carries a re-encrypted TOTP secret for a single account.
type TOTPRekeyRow struct {
Enc []byte
Nonce []byte
AccountID int64
}
// PGRekeyRow carries a re-encrypted Postgres password for a single credential row.
type PGRekeyRow struct {
Enc []byte
Nonce []byte
CredentialID int64
}
// Rekey atomically replaces the master-key salt and all secrets encrypted
// under the old master key with values encrypted under the new master key.
//
// Security: The entire replacement is performed inside a single SQLite
// transaction so that a crash mid-way leaves the database either fully on the
// old key or fully on the new key — never in a mixed state. The caller is
// responsible for zeroing the old and new master keys after this call returns.
func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRows []TOTPRekeyRow, pgRows []PGRekeyRow) error {
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: rekey begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
n := now()
// Replace master key salt and signing key atomically.
_, err = tx.Exec(`
UPDATE server_config
SET master_key_salt = ?,
signing_key_enc = ?,
signing_key_nonce = ?,
updated_at = ?
WHERE id = 1
`, newSalt, newSigningKeyEnc, newSigningKeyNonce, n)
if err != nil {
return fmt.Errorf("db: rekey update server_config: %w", err)
}
// Re-encrypt each TOTP secret.
for _, row := range totpRows {
_, err = tx.Exec(`
UPDATE accounts
SET totp_secret_enc = ?,
totp_secret_nonce = ?,
updated_at = ?
WHERE id = ?
`, row.Enc, row.Nonce, n, row.AccountID)
if err != nil {
return fmt.Errorf("db: rekey update TOTP for account %d: %w", row.AccountID, err)
}
}
// Re-encrypt each pg_credentials password.
for _, row := range pgRows {
_, err = tx.Exec(`
UPDATE pg_credentials
SET pg_password_enc = ?,
pg_password_nonce = ?,
updated_at = ?
WHERE id = ?
`, row.Enc, row.Nonce, n, row.CredentialID)
if err != nil {
return fmt.Errorf("db: rekey update pg credential %d: %w", row.CredentialID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: rekey commit: %w", err)
}
return nil
}
// GrantTokenIssueAccess records that granteeID may issue tokens for the system
// account identified by accountID. Idempotent: a second call for the same
// (account, grantee) pair is silently ignored via INSERT OR IGNORE.
func (db *DB) GrantTokenIssueAccess(accountID, granteeID int64, grantedBy *int64) error {
_, err := db.sql.Exec(`
INSERT OR IGNORE INTO service_account_delegates
(account_id, grantee_id, granted_by, granted_at)
VALUES (?, ?, ?, ?)
`, accountID, granteeID, grantedBy, now())
if err != nil {
return fmt.Errorf("db: grant token issue access: %w", err)
}
return nil
}
// RevokeTokenIssueAccess removes the delegate grant for granteeID on accountID.
// Returns ErrNotFound if no such grant exists.
func (db *DB) RevokeTokenIssueAccess(accountID, granteeID int64) error {
result, err := db.sql.Exec(`
DELETE FROM service_account_delegates
WHERE account_id = ? AND grantee_id = ?
`, accountID, granteeID)
if err != nil {
return fmt.Errorf("db: revoke token issue access: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: revoke token issue access rows: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// ListTokenIssueDelegates returns all delegate grants for the given system account.
func (db *DB) ListTokenIssueDelegates(accountID int64) ([]*model.ServiceAccountDelegate, error) {
rows, err := db.sql.Query(`
SELECT d.id, d.account_id, d.grantee_id, d.granted_by, d.granted_at,
a.uuid, a.username
FROM service_account_delegates d
JOIN accounts a ON a.id = d.grantee_id
WHERE d.account_id = ?
ORDER BY d.granted_at ASC
`, accountID)
if err != nil {
return nil, fmt.Errorf("db: list token issue delegates: %w", err)
}
defer func() { _ = rows.Close() }()
var out []*model.ServiceAccountDelegate
for rows.Next() {
var d model.ServiceAccountDelegate
var grantedAt string
if err := rows.Scan(
&d.ID, &d.AccountID, &d.GranteeID, &d.GrantedBy, &grantedAt,
&d.GranteeUUID, &d.GranteeName,
); err != nil {
return nil, fmt.Errorf("db: scan token issue delegate: %w", err)
}
t, err := parseTime(grantedAt)
if err != nil {
return nil, err
}
d.GrantedAt = t
out = append(out, &d)
}
return out, rows.Err()
}
// HasTokenIssueAccess reports whether actorID has been granted permission to
// issue tokens for the system account identified by accountID.
func (db *DB) HasTokenIssueAccess(accountID, actorID int64) (bool, error) {
var count int
err := db.sql.QueryRow(`
SELECT COUNT(1) FROM service_account_delegates
WHERE account_id = ? AND grantee_id = ?
`, accountID, actorID).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: has token issue access: %w", err)
}
return count > 0, nil
}
// ListDelegatedServiceAccounts returns system accounts for which actorID has
// been granted token-issue access.
func (db *DB) ListDelegatedServiceAccounts(actorID int64) ([]*model.Account, error) {
rows, err := db.sql.Query(`
SELECT a.id, a.uuid, a.username, a.account_type, COALESCE(a.password_hash,''),
a.status, a.totp_required,
a.totp_secret_enc, a.totp_secret_nonce,
a.created_at, a.updated_at, a.deleted_at
FROM service_account_delegates d
JOIN accounts a ON a.id = d.account_id
WHERE d.grantee_id = ? AND a.status != 'deleted'
ORDER BY a.username ASC
`, actorID)
if err != nil {
return nil, fmt.Errorf("db: list delegated service accounts: %w", err)
}
defer func() { _ = rows.Close() }()
var out []*model.Account
for rows.Next() {
a, err := db.scanAccountRow(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}

View File

@@ -445,6 +445,79 @@ func TestSystemTokenRotationRevokesOld(t *testing.T) {
}
}
// TestIssueSystemTokenAtomic verifies that IssueSystemToken atomically
// revokes an old token, tracks the new token, and upserts system_tokens.
func TestIssueSystemTokenAtomic(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("svc-atomic", model.AccountTypeSystem, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
now := time.Now().UTC()
exp := now.Add(time.Hour)
// Issue first system token with no old JTI.
jti1 := "atomic-sys-tok-1"
if err := db.IssueSystemToken("", jti1, acct.ID, now, exp); err != nil {
t.Fatalf("IssueSystemToken first: %v", err)
}
// Verify the first token is tracked and not revoked.
rec1, err := db.GetTokenRecord(jti1)
if err != nil {
t.Fatalf("GetTokenRecord jti1: %v", err)
}
if rec1.IsRevoked() {
t.Error("first token should not be revoked")
}
// Verify system_tokens points to the first token.
st1, err := db.GetSystemToken(acct.ID)
if err != nil {
t.Fatalf("GetSystemToken after first issue: %v", err)
}
if st1.JTI != jti1 {
t.Errorf("system token JTI = %q, want %q", st1.JTI, jti1)
}
// Issue second token, which should atomically revoke the first.
jti2 := "atomic-sys-tok-2"
if err := db.IssueSystemToken(jti1, jti2, acct.ID, now, exp); err != nil {
t.Fatalf("IssueSystemToken second: %v", err)
}
// First token must be revoked.
rec1After, err := db.GetTokenRecord(jti1)
if err != nil {
t.Fatalf("GetTokenRecord jti1 after rotation: %v", err)
}
if !rec1After.IsRevoked() {
t.Error("first token should be revoked after second issue")
}
if rec1After.RevokeReason != "rotated" {
t.Errorf("revoke reason = %q, want %q", rec1After.RevokeReason, "rotated")
}
// Second token must be tracked and not revoked.
rec2, err := db.GetTokenRecord(jti2)
if err != nil {
t.Fatalf("GetTokenRecord jti2: %v", err)
}
if rec2.IsRevoked() {
t.Error("second token should not be revoked")
}
// system_tokens must point to the second token.
st2, err := db.GetSystemToken(acct.ID)
if err != nil {
t.Fatalf("GetSystemToken after second issue: %v", err)
}
if st2.JTI != jti2 {
t.Errorf("system token JTI = %q, want %q", st2.JTI, jti2)
}
}
func TestRevokeAllUserTokens(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("ivan", model.AccountTypeHuman, "hash")

View File

@@ -194,3 +194,210 @@ func TestListAuditEventsCombinedFilters(t *testing.T) {
t.Fatalf("expected 0 events, got %d", len(events))
}
}
// ---- rekey helper tests ----
func TestListAccountsWithTOTP(t *testing.T) {
database := openTestDB(t)
// No accounts with TOTP yet.
accounts, err := database.ListAccountsWithTOTP()
if err != nil {
t.Fatalf("ListAccountsWithTOTP (empty): %v", err)
}
if len(accounts) != 0 {
t.Fatalf("expected 0 accounts, got %d", len(accounts))
}
// Create an account and store a TOTP secret.
a, err := database.CreateAccount("totpuser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
if err := database.SetTOTP(a.ID, []byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("set TOTP: %v", err)
}
// Create another account without TOTP.
if _, err := database.CreateAccount("nototp", model.AccountTypeHuman, "hash"); err != nil {
t.Fatalf("create account: %v", err)
}
accounts, err = database.ListAccountsWithTOTP()
if err != nil {
t.Fatalf("ListAccountsWithTOTP: %v", err)
}
if len(accounts) != 1 {
t.Fatalf("expected 1 account with TOTP, got %d", len(accounts))
}
if accounts[0].ID != a.ID {
t.Errorf("expected account ID %d, got %d", a.ID, accounts[0].ID)
}
}
func TestListAllPGCredentials(t *testing.T) {
database := openTestDB(t)
creds, err := database.ListAllPGCredentials()
if err != nil {
t.Fatalf("ListAllPGCredentials (empty): %v", err)
}
if len(creds) != 0 {
t.Fatalf("expected 0 creds, got %d", len(creds))
}
a, err := database.CreateAccount("pguser", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("create account: %v", err)
}
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("write pg credentials: %v", err)
}
creds, err = database.ListAllPGCredentials()
if err != nil {
t.Fatalf("ListAllPGCredentials: %v", err)
}
if len(creds) != 1 {
t.Fatalf("expected 1 credential, got %d", len(creds))
}
if creds[0].AccountID != a.ID {
t.Errorf("expected account ID %d, got %d", a.ID, creds[0].AccountID)
}
}
func TestRekey(t *testing.T) {
database := openTestDB(t)
// Set up: salt + signing key.
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
t.Fatalf("write salt: %v", err)
}
if err := database.WriteServerConfig([]byte("oldenc"), []byte("oldnonce")); err != nil {
t.Fatalf("write server config: %v", err)
}
// Set up: account with TOTP.
a, err := database.CreateAccount("rekeyuser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
if err := database.SetTOTP(a.ID, []byte("totpenc"), []byte("totpnonce")); err != nil {
t.Fatalf("set TOTP: %v", err)
}
// Set up: pg credential.
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("pgenc"), []byte("pgnonce")); err != nil {
t.Fatalf("write pg creds: %v", err)
}
// Execute Rekey.
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
totpRows := []TOTPRekeyRow{{AccountID: a.ID, Enc: []byte("newtotpenc"), Nonce: []byte("newtotpnonce")}}
pgCred, err := database.ReadPGCredentials(a.ID)
if err != nil {
t.Fatalf("read pg creds: %v", err)
}
pgRows := []PGRekeyRow{{CredentialID: pgCred.ID, Enc: []byte("newpgenc"), Nonce: []byte("newpgnonce")}}
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), totpRows, pgRows); err != nil {
t.Fatalf("Rekey: %v", err)
}
// Verify: salt replaced.
gotSalt, err := database.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read salt after rekey: %v", err)
}
if string(gotSalt) != string(newSalt) {
t.Errorf("salt mismatch: got %q, want %q", gotSalt, newSalt)
}
// Verify: signing key replaced.
gotEnc, gotNonce, err := database.ReadServerConfig()
if err != nil {
t.Fatalf("read server config after rekey: %v", err)
}
if string(gotEnc) != "newenc" || string(gotNonce) != "newnonce" {
t.Errorf("signing key enc/nonce mismatch after rekey")
}
// Verify: TOTP replaced.
updatedAcct, err := database.GetAccountByID(a.ID)
if err != nil {
t.Fatalf("get account after rekey: %v", err)
}
if string(updatedAcct.TOTPSecretEnc) != "newtotpenc" || string(updatedAcct.TOTPSecretNonce) != "newtotpnonce" {
t.Errorf("TOTP enc/nonce mismatch after rekey: enc=%q nonce=%q",
updatedAcct.TOTPSecretEnc, updatedAcct.TOTPSecretNonce)
}
// Verify: pg credential replaced.
updatedCred, err := database.ReadPGCredentials(a.ID)
if err != nil {
t.Fatalf("read pg creds after rekey: %v", err)
}
if string(updatedCred.PGPasswordEnc) != "newpgenc" || string(updatedCred.PGPasswordNonce) != "newpgnonce" {
t.Errorf("pg enc/nonce mismatch after rekey: enc=%q nonce=%q",
updatedCred.PGPasswordEnc, updatedCred.PGPasswordNonce)
}
}
func TestRekeyEmptyDatabase(t *testing.T) {
database := openTestDB(t)
// Minimal setup: salt and signing key only; no TOTP, no pg creds.
salt := []byte("saltsaltsaltsaltsaltsaltsaltsalt") // 32 bytes
if err := database.WriteMasterKeySalt(salt); err != nil {
t.Fatalf("write salt: %v", err)
}
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("write server config: %v", err)
}
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
t.Fatalf("Rekey (empty): %v", err)
}
gotSalt, err := database.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read salt: %v", err)
}
if string(gotSalt) != string(newSalt) {
t.Errorf("salt mismatch")
}
}
// TestRekeyOldSaltUnchangedOnQueryError verifies the salt and encrypted data
// is only present under the new values after a successful Rekey — the old
// values must be gone. Uses the same approach as TestRekey but reads the
// stored salt before and confirms it changes.
func TestRekeyReplacesSalt(t *testing.T) {
database := openTestDB(t)
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
t.Fatalf("write salt: %v", err)
}
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("write server config: %v", err)
}
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
t.Fatalf("Rekey: %v", err)
}
gotSalt, err := database.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read salt: %v", err)
}
if string(gotSalt) == string(oldSalt) {
t.Error("old salt still present after rekey")
}
if string(gotSalt) != string(newSalt) {
t.Errorf("expected new salt %q, got %q", newSalt, gotSalt)
}
}

View File

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

View File

@@ -0,0 +1,15 @@
-- service_account_delegates tracks which human accounts are permitted to 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 IF NOT EXISTS service_account_delegates (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, grantee_id)
);
CREATE INDEX IF NOT EXISTS idx_sa_delegates_account ON service_account_delegates (account_id);
CREATE INDEX IF NOT EXISTS idx_sa_delegates_grantee ON service_account_delegates (grantee_id);

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);

68
internal/db/snapshot.go Normal file
View File

@@ -0,0 +1,68 @@
package db
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Snapshot creates a consistent backup of the database at destPath using
// SQLite's VACUUM INTO statement. VACUUM INTO acquires a read lock for the
// duration of the copy, which is safe while the server is running in WAL mode.
// The destination file is created by SQLite; the caller must ensure the parent
// directory exists.
func (db *DB) Snapshot(destPath string) error {
// VACUUM INTO is not supported on in-memory databases.
if strings.Contains(db.path, "mode=memory") {
return fmt.Errorf("db: snapshot not supported on in-memory databases")
}
if _, err := db.sql.Exec("VACUUM INTO ?", destPath); err != nil {
return fmt.Errorf("db: snapshot VACUUM INTO %q: %w", destPath, err)
}
return nil
}
// SnapshotDir creates a timestamped backup in dir and prunes backups older
// than retainDays days. dir is created with mode 0750 if it does not exist.
// The backup filename format is mcias-20060102-150405.db.
func (db *DB) SnapshotDir(dir string, retainDays int) (string, error) {
if err := os.MkdirAll(dir, 0750); err != nil {
return "", fmt.Errorf("db: create backup dir %q: %w", dir, err)
}
ts := time.Now().UTC().Format("20060102-150405")
dest := filepath.Join(dir, fmt.Sprintf("mcias-%s.db", ts))
if err := db.Snapshot(dest); err != nil {
return "", err
}
// Prune backups older than retainDays.
if retainDays > 0 {
cutoff := time.Now().UTC().AddDate(0, 0, -retainDays)
entries, err := os.ReadDir(dir)
if err != nil {
// Non-fatal: the backup was written; log pruning failure separately.
return dest, fmt.Errorf("db: list backup dir for pruning: %w", err)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
// Skip the file we just wrote.
if e.Name() == filepath.Base(dest) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
return dest, nil
}

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

@@ -17,8 +17,12 @@ type adminServiceServer struct {
s *Server
}
// Health returns {"status":"ok"} to signal the server is operational.
// Health returns {"status":"ok"} to signal the server is operational, or
// {"status":"sealed"} when the vault is sealed.
func (a *adminServiceServer) Health(_ context.Context, _ *mciasv1.HealthRequest) (*mciasv1.HealthResponse, error) {
if a.s.vault.IsSealed() {
return &mciasv1.HealthResponse{Status: "sealed"}, nil
}
return &mciasv1.HealthResponse{Status: "ok"}, nil
}
@@ -26,11 +30,12 @@ func (a *adminServiceServer) Health(_ context.Context, _ *mciasv1.HealthRequest)
// The "x" field is the raw 32-byte public key base64url-encoded without padding,
// matching the REST /v1/keys/public response format.
func (a *adminServiceServer) GetPublicKey(_ context.Context, _ *mciasv1.GetPublicKeyRequest) (*mciasv1.GetPublicKeyResponse, error) {
if len(a.s.pubKey) == 0 {
return nil, status.Error(codes.Internal, "public key not available")
pubKey, err := a.s.vault.PubKey()
if err != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
// Encode as base64url without padding — identical to the REST handler.
x := base64.RawURLEncoding.EncodeToString(a.s.pubKey)
x := base64.RawURLEncoding.EncodeToString(pubKey)
return &mciasv1.GetPublicKeyResponse{
Kty: "OKP",
Crv: "Ed25519",

View File

@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"net"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
@@ -13,6 +14,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"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"
@@ -42,7 +44,7 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
// Security: run dummy Argon2 to equalise timing for unknown users.
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
a.s.db.WriteAuditEvent(model.EventLoginFail, nil, nil, ip, //nolint:errcheck // audit failure is non-fatal
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username))
audit.JSON("username", req.Username, "reason", "unknown_user"))
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
@@ -60,7 +62,9 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
// Security: return the same Unauthenticated / "invalid credentials" as wrong-password
// to prevent user-enumeration via lockout differentiation (SEC-02).
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
@@ -82,7 +86,11 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
}
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
masterKey, mkErr := a.s.vault.MasterKey()
if mkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
if err != nil {
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
return nil, status.Error(codes.Internal, "internal error")
@@ -117,7 +125,11 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
}
}
tokenStr, claims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
privKey, pkErr := a.s.vault.PrivKey()
if pkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
tokenStr, claims, err := token.IssueToken(privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
a.s.logger.Error("issue token", "error", err)
return nil, status.Error(codes.Internal, "internal error")
@@ -129,7 +141,7 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
a.s.db.WriteAuditEvent(model.EventLoginOK, &acct.ID, nil, ip, "") //nolint:errcheck
a.s.db.WriteAuditEvent(model.EventTokenIssued, &acct.ID, nil, ip, //nolint:errcheck
fmt.Sprintf(`{"jti":%q}`, claims.JTI))
audit.JSON("jti", claims.JTI))
return &mciasv1.LoginResponse{
Token: tokenStr,
@@ -145,7 +157,7 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
return nil, status.Error(codes.Internal, "internal error")
}
a.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI))
audit.JSON("jti", claims.JTI, "reason", "logout"))
return &mciasv1.LogoutResponse{}, nil
}
@@ -153,6 +165,14 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
claims := claimsFromContext(ctx)
// Security: only allow renewal when the token has consumed at least 50% of
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
elapsed := time.Since(claims.IssuedAt)
if elapsed < totalLifetime/2 {
return nil, status.Error(codes.InvalidArgument, "token is not yet eligible for renewal")
}
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "account not found")
@@ -174,7 +194,11 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
}
}
newTokenStr, newClaims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
privKey, pkErr := a.s.vault.PrivKey()
if pkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
newTokenStr, newClaims, err := token.IssueToken(privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
@@ -186,7 +210,7 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
}
a.s.db.WriteAuditEvent(model.EventTokenRenewed, &acct.ID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI))
audit.JSON("old_jti", claims.JTI, "new_jti", newClaims.JTI))
return &mciasv1.RenewTokenResponse{
Token: newTokenStr,
@@ -195,19 +219,49 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
}
// EnrollTOTP begins TOTP enrollment for the calling account.
func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
//
// Security (SEC-01): the current password is required to prevent a stolen
// session token from being used to enroll attacker-controlled TOTP on the
// victim's account. Lockout is checked and failures are recorded.
func (a *authServiceServer) EnrollTOTP(ctx context.Context, req *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
claims := claimsFromContext(ctx)
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "account not found")
}
if req.Password == "" {
return nil, status.Error(codes.InvalidArgument, "password is required")
}
// Security: check lockout before verifying (same as login flow).
locked, lockErr := a.s.db.IsLockedOut(acct.ID)
if lockErr != nil {
a.s.logger.Error("lockout check (gRPC TOTP enroll)", "error", lockErr)
}
if locked {
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"locked"}`) //nolint:errcheck
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
}
// Security: verify the current password with Argon2id (constant-time).
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = a.s.db.RecordLoginFailure(acct.ID)
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"wrong_password"}`) //nolint:errcheck
return nil, status.Error(codes.Unauthenticated, "password is incorrect")
}
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
secretEnc, secretNonce, err := crypto.SealAESGCM(a.s.masterKey, rawSecret)
masterKey, mkErr := a.s.vault.MasterKey()
if mkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
@@ -245,7 +299,11 @@ func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.Confir
return nil, status.Error(codes.FailedPrecondition, "TOTP enrollment not started")
}
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
masterKey, mkErr := a.s.vault.MasterKey()
if mkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}

View File

@@ -47,7 +47,11 @@ func (c *credentialServiceServer) GetPGCreds(ctx context.Context, req *mciasv1.G
}
// Decrypt the password for admin retrieval.
password, err := crypto.OpenAESGCM(c.s.masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc)
masterKey, mkErr := c.s.vault.MasterKey()
if mkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
password, err := crypto.OpenAESGCM(masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc)
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
@@ -94,7 +98,11 @@ func (c *credentialServiceServer) SetPGCreds(ctx context.Context, req *mciasv1.S
return nil, status.Error(codes.Internal, "internal error")
}
enc, nonce, err := crypto.SealAESGCM(c.s.masterKey, []byte(cr.Password))
masterKey, mkErr := c.s.vault.MasterKey()
if mkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(cr.Password))
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}

View File

@@ -17,7 +17,6 @@ package grpcserver
import (
"context"
"crypto/ed25519"
"log/slog"
"net"
"strings"
@@ -35,6 +34,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
// contextKey is the unexported context key type for this package.
@@ -57,21 +57,17 @@ type Server struct {
cfg *config.Config
logger *slog.Logger
rateLimiter *grpcRateLimiter
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
masterKey []byte
vault *vault.Vault
}
// New creates a Server with the given dependencies (same as the REST Server).
// A fresh per-IP rate limiter (10 req/s, burst 10) is allocated per Server
// instance so that tests do not share state across test cases.
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server {
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server {
return &Server{
db: database,
cfg: cfg,
privKey: priv,
pubKey: pub,
masterKey: masterKey,
vault: v,
logger: logger,
rateLimiter: newGRPCRateLimiter(10, 10),
}
@@ -106,6 +102,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
[]grpc.ServerOption{
grpc.ChainUnaryInterceptor(
s.loggingInterceptor,
s.sealedInterceptor,
s.authInterceptor,
s.rateLimitInterceptor,
),
@@ -162,14 +159,36 @@ func (s *Server) loggingInterceptor(
return resp, err
}
// sealedInterceptor rejects all RPCs (except Health) when the vault is sealed.
//
// Security: This is the first interceptor in the chain (after logging). It
// prevents any authenticated or data-serving handler from running while the
// vault is sealed and key material is unavailable.
func (s *Server) sealedInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
if !s.vault.IsSealed() {
return handler(ctx, req)
}
// Health is always allowed — returns sealed status.
if info.FullMethod == "/mcias.v1.AdminService/Health" {
return handler(ctx, req)
}
return nil, status.Error(codes.Unavailable, "vault sealed")
}
// authInterceptor validates the Bearer JWT from gRPC metadata and injects
// claims into the context. Public methods bypass this check.
//
// Security: Same validation path as the REST RequireAuth middleware:
// 1. Extract "authorization" metadata value (case-insensitive key lookup).
// 2. Validate JWT (alg-first, then signature, then expiry/issuer).
// 3. Check JTI against revocation table.
// 4. Inject claims into context.
// 2. Read public key from vault (fail closed if sealed).
// 3. Validate JWT (alg-first, then signature, then expiry/issuer).
// 4. Check JTI against revocation table.
// 5. Inject claims into context.
func (s *Server) authInterceptor(
ctx context.Context,
req interface{},
@@ -186,7 +205,13 @@ func (s *Server) authInterceptor(
return nil, status.Error(codes.Unauthenticated, "missing or invalid authorization")
}
claims, err := token.ValidateToken(s.pubKey, tokenStr, s.cfg.Tokens.Issuer)
// Security: read the public key from vault at request time.
pubKey, err := s.vault.PubKey()
if err != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
claims, err := token.ValidateToken(pubKey, tokenStr, s.cfg.Tokens.Issuer)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid or expired token")
}
@@ -289,28 +314,75 @@ func (l *grpcRateLimiter) cleanup() {
// rateLimitInterceptor applies per-IP rate limiting using the same token-bucket
// parameters as the REST rate limiter (10 req/s, burst 10).
//
// Security (SEC-06): uses grpcClientIP to extract the real client IP when
// behind a trusted reverse proxy, matching the REST middleware behaviour.
func (s *Server) rateLimitInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
ip := ""
if p, ok := peer.FromContext(ctx); ok {
host, _, err := net.SplitHostPort(p.Addr.String())
if err == nil {
ip = host
} else {
ip = p.Addr.String()
}
var trustedProxy net.IP
if s.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
}
ip := grpcClientIP(ctx, trustedProxy)
if ip != "" && !s.rateLimiter.allow(ip) {
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
}
return handler(ctx, req)
}
// grpcClientIP extracts the real client IP from gRPC context, optionally
// honouring proxy headers when the peer matches the trusted proxy.
//
// Security (SEC-06): mirrors middleware.ClientIP for the REST server.
// X-Forwarded-For and X-Real-IP metadata are only trusted when the immediate
// peer address matches trustedProxy exactly, preventing IP-spoofing attacks.
// Only the first (leftmost) value in x-forwarded-for is used (original client).
// gRPC lowercases all metadata keys, so we look up "x-forwarded-for" and
// "x-real-ip".
func grpcClientIP(ctx context.Context, trustedProxy net.IP) string {
peerIP := ""
if p, ok := peer.FromContext(ctx); ok {
host, _, err := net.SplitHostPort(p.Addr.String())
if err == nil {
peerIP = host
} else {
peerIP = p.Addr.String()
}
}
if trustedProxy != nil && peerIP != "" {
remoteIP := net.ParseIP(peerIP)
if remoteIP != nil && remoteIP.Equal(trustedProxy) {
// Peer is the trusted proxy — extract real client IP from metadata.
// Prefer x-real-ip (single value) over x-forwarded-for (may be a
// comma-separated list when multiple proxies are chained).
md, ok := metadata.FromIncomingContext(ctx)
if ok {
if vals := md.Get("x-real-ip"); len(vals) > 0 {
if ip := net.ParseIP(strings.TrimSpace(vals[0])); ip != nil {
return ip.String()
}
}
if vals := md.Get("x-forwarded-for"); len(vals) > 0 {
// Take the first (leftmost) address — the original client.
first, _, _ := strings.Cut(vals[0], ",")
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
return ip.String()
}
}
}
}
}
return peerIP
}
// extractBearerFromMD extracts the Bearer token from gRPC metadata.
// The key lookup is case-insensitive per gRPC metadata convention (all keys
// are lowercased by the framework; we match on "authorization").

View File

@@ -12,6 +12,7 @@ import (
"io"
"log/slog"
"net"
"strings"
"testing"
"time"
@@ -19,6 +20,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
@@ -28,6 +30,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
const (
@@ -71,7 +74,8 @@ func newTestEnv(t *testing.T) *testEnv {
cfg := config.NewTestConfig(testIssuer)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := New(database, cfg, priv, pub, masterKey, logger)
v := vault.NewUnsealed(masterKey, priv, pub)
srv := New(database, cfg, v, logger)
grpcSrv := srv.GRPCServer()
lis := bufconn.Listen(bufConnSize)
@@ -143,7 +147,12 @@ func (e *testEnv) issueAdminToken(t *testing.T, username string) (string, *model
// issueUserToken issues a regular (non-admin) token for an account.
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
t.Helper()
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, time.Hour)
return e.issueShortToken(t, acct, time.Hour)
}
func (e *testEnv) issueShortToken(t *testing.T, acct *model.Account, expiry time.Duration) string {
t.Helper()
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, expiry)
if err != nil {
t.Fatalf("issue token: %v", err)
}
@@ -357,11 +366,17 @@ func TestLogout(t *testing.T) {
}
}
// TestRenewToken verifies that a valid token can be renewed.
// TestRenewToken verifies that a valid token can be renewed after 50% of its
// lifetime has elapsed (SEC-03).
func TestRenewToken(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "renewuser")
tok := e.issueUserToken(t, acct)
// Issue a short-lived token (4s) so we can wait past the 50% threshold.
tok := e.issueShortToken(t, acct, 4*time.Second)
// Wait for >50% of lifetime to elapse.
time.Sleep(2100 * time.Millisecond)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
@@ -377,6 +392,28 @@ func TestRenewToken(t *testing.T) {
}
}
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
// of its lifetime has elapsed (SEC-03).
func TestRenewTokenTooEarly(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "renewearlyuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
_, err := cl.RenewToken(ctx, &mciasv1.RenewTokenRequest{})
if err == nil {
t.Fatal("RenewToken: expected error for early renewal, got nil")
}
st, ok := status.FromError(err)
if !ok || st.Code() != codes.InvalidArgument {
t.Fatalf("RenewToken: expected InvalidArgument, got %v", err)
}
if !strings.Contains(st.Message(), "not yet eligible for renewal") {
t.Errorf("RenewToken: expected eligibility message, got: %s", st.Message())
}
}
// ---- TokenService tests ----
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
@@ -650,3 +687,196 @@ func TestCredentialFieldsAbsentFromAccountResponse(t *testing.T) {
}
}
}
// ---- grpcClientIP tests (SEC-06) ----
// fakeAddr implements net.Addr for testing peer contexts.
type fakeAddr struct {
addr string
network string
}
func (a fakeAddr) String() string { return a.addr }
func (a fakeAddr) Network() string { return a.network }
// TestGRPCClientIP_NoProxy verifies that when no trusted proxy is configured
// the function returns the peer IP directly.
func TestGRPCClientIP_NoProxy(t *testing.T) {
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "10.0.0.5:54321", network: "tcp"},
})
got := grpcClientIP(ctx, nil)
if got != "10.0.0.5" {
t.Errorf("grpcClientIP(no proxy) = %q, want %q", got, "10.0.0.5")
}
}
// TestGRPCClientIP_TrustedProxy_XForwardedFor verifies that when the peer
// matches the trusted proxy, the real client IP is extracted from
// x-forwarded-for metadata.
func TestGRPCClientIP_TrustedProxy_XForwardedFor(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs("x-forwarded-for", "203.0.113.50, 10.0.0.1")
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "203.0.113.50" {
t.Errorf("grpcClientIP(xff) = %q, want %q", got, "203.0.113.50")
}
}
// TestGRPCClientIP_TrustedProxy_XRealIP verifies that x-real-ip is preferred
// over x-forwarded-for when both are present.
func TestGRPCClientIP_TrustedProxy_XRealIP(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs(
"x-real-ip", "198.51.100.10",
"x-forwarded-for", "203.0.113.50",
)
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "198.51.100.10" {
t.Errorf("grpcClientIP(x-real-ip preferred) = %q, want %q", got, "198.51.100.10")
}
}
// TestGRPCClientIP_UntrustedPeer_IgnoresHeaders verifies that forwarded
// headers are ignored when the peer does NOT match the trusted proxy.
// Security: This prevents IP-spoofing by untrusted clients.
func TestGRPCClientIP_UntrustedPeer_IgnoresHeaders(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
// Peer is NOT the trusted proxy.
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "10.0.0.99:54321", network: "tcp"},
})
md := metadata.Pairs(
"x-forwarded-for", "203.0.113.50",
"x-real-ip", "198.51.100.10",
)
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "10.0.0.99" {
t.Errorf("grpcClientIP(untrusted peer) = %q, want %q", got, "10.0.0.99")
}
}
// TestGRPCClientIP_TrustedProxy_NoHeaders verifies that when the peer matches
// the proxy but no forwarded headers are set, the peer IP is returned as fallback.
func TestGRPCClientIP_TrustedProxy_NoHeaders(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
got := grpcClientIP(ctx, proxyIP)
if got != "192.168.1.1" {
t.Errorf("grpcClientIP(proxy, no headers) = %q, want %q", got, "192.168.1.1")
}
}
// TestGRPCClientIP_TrustedProxy_InvalidHeader verifies that invalid IPs in
// headers are ignored and the peer IP is returned.
func TestGRPCClientIP_TrustedProxy_InvalidHeader(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs("x-forwarded-for", "not-an-ip")
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "192.168.1.1" {
t.Errorf("grpcClientIP(invalid header) = %q, want %q", got, "192.168.1.1")
}
}
// TestGRPCClientIP_NoPeer verifies that an empty string is returned when
// there is no peer in the context.
func TestGRPCClientIP_NoPeer(t *testing.T) {
got := grpcClientIP(context.Background(), nil)
if got != "" {
t.Errorf("grpcClientIP(no peer) = %q, want %q", got, "")
}
}
// TestLoginLockedAccountReturnsUnauthenticated verifies that a locked-out
// account gets the same gRPC Unauthenticated / "invalid credentials" as a
// wrong-password attempt, preventing user-enumeration via lockout
// differentiation (SEC-02).
func TestLoginLockedAccountReturnsUnauthenticated(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "lockgrpc")
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
for range db.LockoutThreshold {
if err := e.db.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
locked, err := e.db.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
cl := mciasv1.NewAuthServiceClient(e.conn)
// Attempt login on the locked account.
_, lockedErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "lockgrpc",
Password: "testpass123",
})
if lockedErr == nil {
t.Fatal("Login on locked account: expected error, got nil")
}
// Attempt login with wrong password for comparison.
_, wrongErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "lockgrpc",
Password: "wrongpassword",
})
if wrongErr == nil {
t.Fatal("Login with wrong password: expected error, got nil")
}
lockedSt, _ := status.FromError(lockedErr)
wrongSt, _ := status.FromError(wrongErr)
// Both must return Unauthenticated, not ResourceExhausted.
if lockedSt.Code() != codes.Unauthenticated {
t.Errorf("locked: got code %v, want Unauthenticated", lockedSt.Code())
}
if wrongSt.Code() != codes.Unauthenticated {
t.Errorf("wrong password: got code %v, want Unauthenticated", wrongSt.Code())
}
// Messages must be identical.
if lockedSt.Message() != wrongSt.Message() {
t.Errorf("locked message %q differs from wrong-password message %q",
lockedSt.Message(), wrongSt.Message())
}
if lockedSt.Message() != "invalid credentials" {
t.Errorf("locked message = %q, want %q", lockedSt.Message(), "invalid credentials")
}
}

View File

@@ -32,7 +32,11 @@ func (t *tokenServiceServer) ValidateToken(_ context.Context, req *mciasv1.Valid
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
claims, err := token.ValidateToken(t.s.pubKey, tokenStr, t.s.cfg.Tokens.Issuer)
pubKey, pkErr := t.s.vault.PubKey()
if pkErr != nil {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
claims, err := token.ValidateToken(pubKey, tokenStr, t.s.cfg.Tokens.Issuer)
if err != nil {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
@@ -67,21 +71,24 @@ func (ts *tokenServiceServer) IssueServiceToken(ctx context.Context, req *mciasv
return nil, status.Error(codes.InvalidArgument, "token issue is only for system accounts")
}
tokenStr, claims, err := token.IssueToken(ts.s.privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry())
privKey, pkErr := ts.s.vault.PrivKey()
if pkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
tokenStr, claims, err := token.IssueToken(privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry())
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
// Revoke existing system token if any.
// Atomically revoke existing system token (if any), track the new token,
// and update system_tokens — all in a single transaction.
// Security: prevents inconsistent state if a crash occurs mid-operation.
var oldJTI string
existing, err := ts.s.db.GetSystemToken(acct.ID)
if err == nil && existing != nil {
_ = ts.s.db.RevokeToken(existing.JTI, "rotated")
oldJTI = existing.JTI
}
if err := ts.s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
if err := ts.s.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
if err := ts.s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}

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

@@ -13,7 +13,6 @@ package middleware
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
@@ -27,6 +26,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
// contextKey is the unexported type for context keys in this package, preventing
@@ -90,12 +90,18 @@ func (rw *responseWriter) WriteHeader(code int) {
// RequireAuth returns middleware that validates a Bearer JWT and injects the
// claims into the request context. Returns 401 on any auth failure.
//
// The public key is read from the vault at request time so that the middleware
// works correctly across seal/unseal transitions. When the vault is sealed,
// the sealed middleware (RequireUnsealed) prevents reaching this handler, but
// the vault check here provides defense in depth (fail closed).
//
// Security: Token validation order:
// 1. Extract Bearer token from Authorization header.
// 2. Validate the JWT (alg=EdDSA, signature, expiry, issuer).
// 3. Check the JTI against the revocation table in the database.
// 4. Inject validated claims into context for downstream handlers.
func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func(http.Handler) http.Handler {
// 2. Read public key from vault (fail closed if sealed).
// 3. Validate the JWT (alg=EdDSA, signature, expiry, issuer).
// 4. Check the JTI against the revocation table in the database.
// 5. Inject validated claims into context for downstream handlers.
func RequireAuth(v *vault.Vault, database *db.DB, issuer string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr, err := extractBearerToken(r)
@@ -104,6 +110,14 @@ func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func(
return
}
// Security: read the public key from vault at request time.
// If the vault is sealed, fail closed with 503.
pubKey, err := v.PubKey()
if err != nil {
writeError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
claims, err := token.ValidateToken(pubKey, tokenStr, issuer)
if err != nil {
// Security: Map all token errors to a generic 401; do not
@@ -437,3 +451,47 @@ func RequirePolicy(
})
}
}
// RequireUnsealed returns middleware that blocks requests when the vault is sealed.
//
// Exempt paths (served normally even when sealed):
// - GET /v1/health, GET /v1/vault/status, POST /v1/vault/unseal
// - GET /unseal, POST /unseal
// - GET /static/* (CSS/JS needed by the unseal page)
//
// API paths (/v1/*) receive a JSON 503 response. All other paths (UI) receive
// a 302 redirect to /unseal.
//
// Security: This middleware is the first in the chain (after global security
// headers). It ensures no authenticated or data-serving handler runs while the
// vault is sealed and key material is unavailable.
func RequireUnsealed(v *vault.Vault) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !v.IsSealed() {
next.ServeHTTP(w, r)
return
}
path := r.URL.Path
// Exempt paths that must work while sealed.
if path == "/v1/health" || path == "/v1/vault/status" ||
path == "/v1/vault/unseal" ||
path == "/unseal" ||
strings.HasPrefix(path, "/static/") {
next.ServeHTTP(w, r)
return
}
// API paths: JSON 503.
if strings.HasPrefix(path, "/v1/") {
writeError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
// UI paths: redirect to unseal page.
http.Redirect(w, r, "/unseal", http.StatusFound)
})
}
}

View File

@@ -15,6 +15,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
@@ -26,6 +27,15 @@ func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
return pub, priv
}
func testVault(t *testing.T, priv ed25519.PrivateKey, pub ed25519.PublicKey) *vault.Vault {
t.Helper()
mk := make([]byte, 32)
if _, err := rand.Read(mk); err != nil {
t.Fatalf("generate master key: %v", err)
}
return vault.NewUnsealed(mk, priv, pub)
}
func openTestDB(t *testing.T) *db.DB {
t.Helper()
database, err := db.Open(":memory:")
@@ -96,7 +106,7 @@ func TestRequireAuthValid(t *testing.T) {
tokenStr := issueAndTrackToken(t, priv, database, acct.ID, []string{"reader"})
reached := false
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
claims := ClaimsFromContext(r.Context())
if claims == nil {
@@ -123,7 +133,7 @@ func TestRequireAuthMissingHeader(t *testing.T) {
_ = priv
database := openTestDB(t)
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("handler should not be reached without auth")
w.WriteHeader(http.StatusOK)
}))
@@ -138,10 +148,10 @@ func TestRequireAuthMissingHeader(t *testing.T) {
}
func TestRequireAuthInvalidToken(t *testing.T) {
pub, _ := generateTestKey(t)
pub, priv := generateTestKey(t)
database := openTestDB(t)
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("handler should not be reached with invalid token")
w.WriteHeader(http.StatusOK)
}))
@@ -176,7 +186,7 @@ func TestRequireAuthRevokedToken(t *testing.T) {
t.Fatalf("RevokeToken: %v", err)
}
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("handler should not be reached with revoked token")
w.WriteHeader(http.StatusOK)
}))
@@ -201,7 +211,7 @@ func TestRequireAuthExpiredToken(t *testing.T) {
t.Fatalf("IssueToken: %v", err)
}
handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("handler should not be reached with expired token")
w.WriteHeader(http.StatusOK)
}))
@@ -351,8 +361,8 @@ func TestClientIP(t *testing.T) {
remoteAddr string
xForwardedFor string
xRealIP string
trustedProxy net.IP
want string
trustedProxy net.IP
}{
{
name: "no proxy configured: uses RemoteAddr",
@@ -367,11 +377,11 @@ func TestClientIP(t *testing.T) {
want: "198.51.100.9",
},
{
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
remoteAddr: "10.0.0.1:8080",
xRealIP: "203.0.113.42",
trustedProxy: proxy,
want: "203.0.113.42",
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
remoteAddr: "10.0.0.1:8080",
xRealIP: "203.0.113.42",
trustedProxy: proxy,
want: "203.0.113.42",
},
{
name: "request from trusted proxy with X-Forwarded-For: uses first entry",
@@ -397,10 +407,10 @@ func TestClientIP(t *testing.T) {
want: "203.0.113.55",
},
{
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
remoteAddr: "10.0.0.1:8080",
trustedProxy: proxy,
want: "10.0.0.1",
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
remoteAddr: "10.0.0.1:8080",
trustedProxy: proxy,
want: "10.0.0.1",
},
{
// Security: attacker fakes X-Forwarded-For but connects directly.

View File

@@ -178,6 +178,9 @@ const (
EventPGCredAccessed = "pgcred_accessed"
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
EventVaultSealed = "vault_sealed"
EventVaultUnsealed = "vault_unsealed"
EventTagAdded = "tag_added"
EventTagRemoved = "tag_removed"
@@ -207,8 +210,50 @@ const (
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
EventPasswordChanged = "password_changed"
EventTokenDelegateGranted = "token_delegate_granted"
EventTokenDelegateRevoked = "token_delegate_revoked"
EventWebAuthnEnrolled = "webauthn_enrolled"
EventWebAuthnRemoved = "webauthn_removed"
EventWebAuthnLoginOK = "webauthn_login_ok"
EventWebAuthnLoginFail = "webauthn_login_fail"
)
// ServiceAccountDelegate records that a specific account has been granted
// permission to issue tokens for a given system account. Only admins can
// add or remove delegates; delegates can issue/rotate tokens for that specific
// system account and nothing else.
type ServiceAccountDelegate struct {
GrantedAt time.Time `json:"granted_at"`
GrantedBy *int64 `json:"-"`
GranteeUUID string `json:"grantee_id"`
GranteeName string `json:"grantee_username"`
ID int64 `json:"-"`
AccountID 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.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns.

View File

@@ -81,6 +81,16 @@ var defaultRules = []Rule{
OwnerMatchesSubject: true,
Effect: Allow,
},
{
// Self-service WebAuthn enrollment: any authenticated human account may
// register and manage their own passkeys/security keys. The handler
// verifies the subject matches before writing. Mirrors TOTP rule -3.
ID: -8,
Description: "Self-service: any principal may enroll their own WebAuthn credentials",
Priority: 0,
Actions: []Action{ActionEnrollWebAuthn},
Effect: Allow,
},
{
// Public endpoints: token validation and login do not require
// authentication. The middleware exempts them from RequireAuth entirely;

View File

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

View File

@@ -217,6 +217,9 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
// Reload the in-memory engine so the new rule takes effect immediately.
s.reloadPolicyEngine()
rv, err := policyRuleToResponse(rec)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -325,6 +328,9 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
// Reload the in-memory engine so rule changes take effect immediately.
s.reloadPolicyEngine()
updated, err := s.db.GetPolicyRule(rec.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -358,6 +364,9 @@ func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request)
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
// Reload the in-memory engine so the deleted rule is removed immediately.
s.reloadPolicyEngine()
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,766 @@
// 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/policy"
"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)
// Load roles for policy check and expiry decision.
roles, err := s.db.GetRoles(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Policy check: evaluate auth:login rules.
// WebAuthn login has no service context (no service_name or tags in the
// request body), so per-service deny rules won't fire. Account-level deny
// rules (e.g. deny a specific role from all auth:login actions) apply.
// This mirrors the policy gate in handleLogin so both auth paths are consistent.
//
// Security: policy is checked after credential verification so that a
// policy-denied login returns 403 (not 401), distinguishing a policy
// restriction from a bad credential without leaking account existence.
if s.polEng != nil {
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{},
}
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"policy_denied"}`)
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
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

@@ -10,46 +10,186 @@
package server
import (
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/ui"
"git.wntrmute.dev/kyle/mcias/internal/validate"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/kyle/mcias/web"
)
// Server holds the dependencies injected into all handlers.
type Server struct {
db *db.DB
cfg *config.Config
logger *slog.Logger
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
masterKey []byte
db *db.DB
cfg *config.Config
logger *slog.Logger
vault *vault.Vault
polEng *policy.Engine
}
// New creates a Server with the given dependencies.
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server {
// The policy engine is initialised and loaded from the database on construction.
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server {
eng := policy.NewEngine()
if err := loadEngineRules(eng, database); err != nil {
logger.Warn("policy engine initial load failed; built-in defaults will apply", "error", err)
}
return &Server{
db: database,
cfg: cfg,
privKey: priv,
pubKey: pub,
masterKey: masterKey,
logger: logger,
db: database,
cfg: cfg,
vault: v,
logger: logger,
polEng: eng,
}
}
// loadEngineRules reads all policy rules from the database and loads them into eng.
// Enabled/disabled and validity-window filtering is handled by the engine itself.
func loadEngineRules(eng *policy.Engine, database *db.DB) error {
records, err := database.ListPolicyRules(false)
if err != nil {
return fmt.Errorf("list policy rules: %w", err)
}
prs := make([]policy.PolicyRecord, len(records))
for i, r := range records {
prs[i] = policy.PolicyRecord{
ID: r.ID,
Priority: r.Priority,
Description: r.Description,
RuleJSON: r.RuleJSON,
Enabled: r.Enabled,
NotBefore: r.NotBefore,
ExpiresAt: r.ExpiresAt,
}
}
return eng.SetRules(prs)
}
// reloadPolicyEngine reloads operator rules from the database into the engine.
// Called after any create, update, or delete of a policy rule so that the
// in-memory cache stays consistent with the database.
func (s *Server) reloadPolicyEngine() {
if err := loadEngineRules(s.polEng, s.db); err != nil {
s.logger.Error("reload policy engine", "error", err)
}
}
// accountTypeLookup returns an AccountTypeLookup closure that resolves the
// account type ("human" or "system") for the given subject UUID. Used by the
// RequirePolicy middleware to populate PolicyInput.AccountType.
func (s *Server) accountTypeLookup() middleware.AccountTypeLookup {
return func(subjectUUID string) string {
acct, err := s.db.GetAccountByUUID(subjectUUID)
if err != nil {
return ""
}
return string(acct.AccountType)
}
}
// policyDenyLogger returns a PolicyDenyLogger that records policy denials in
// the audit log as EventPolicyDeny events.
func (s *Server) policyDenyLogger() middleware.PolicyDenyLogger {
return func(r *http.Request, claims *token.Claims, action policy.Action, res policy.Resource, matchedRuleID int64) {
s.writeAudit(r, model.EventPolicyDeny, nil, nil,
fmt.Sprintf(`{"subject":%q,"action":%q,"resource_type":%q,"rule_id":%d}`,
claims.Subject, action, res.Type, matchedRuleID))
}
}
// buildAccountResource assembles the policy.Resource for endpoints that
// target a specific account ({id} path parameter). Looks up the account's
// UUID, username (for ServiceName), and tags from the database.
// Returns an empty Resource on lookup failure; deny-by-default in the engine
// means this safely falls through to a denial for owner-scoped rules.
func (s *Server) buildAccountResource(r *http.Request, _ *token.Claims) policy.Resource {
id := r.PathValue("id")
if id == "" {
return policy.Resource{}
}
acct, err := s.db.GetAccountByUUID(id)
if err != nil {
return policy.Resource{}
}
tags, _ := s.db.GetAccountTags(acct.ID)
return policy.Resource{
OwnerUUID: acct.UUID,
ServiceName: acct.Username,
Tags: tags,
}
}
// buildTokenResource assembles the policy.Resource for token-issue requests.
// The request body contains account_id (UUID); the resource owner is that account.
// Because this builder reads the body it must be called before the body is
// consumed by the handler — the middleware calls it before invoking next.
func (s *Server) buildTokenResource(r *http.Request, _ *token.Claims) policy.Resource {
// Peek at the account_id without consuming the body.
// We read the body into a small wrapper struct to get the target UUID.
// The actual handler re-reads the body via decodeJSON, so this is safe
// because http.MaxBytesReader is applied by the handler, not here.
var peek struct {
AccountID string `json:"account_id"`
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBytes))
if err != nil {
return policy.Resource{}
}
// Restore the body for the downstream handler.
r.Body = io.NopCloser(strings.NewReader(string(body)))
if err := json.Unmarshal(body, &peek); err != nil || peek.AccountID == "" {
return policy.Resource{}
}
acct, err := s.db.GetAccountByUUID(peek.AccountID)
if err != nil {
return policy.Resource{}
}
tags, _ := s.db.GetAccountTags(acct.ID)
return policy.Resource{
OwnerUUID: acct.UUID,
ServiceName: acct.Username,
Tags: tags,
}
}
// buildJTIResource assembles the policy.Resource for token-revoke requests.
// Looks up the token record by {jti} to identify the owning account.
func (s *Server) buildJTIResource(r *http.Request, _ *token.Claims) policy.Resource {
jti := r.PathValue("jti")
if jti == "" {
return policy.Resource{}
}
rec, err := s.db.GetTokenRecord(jti)
if err != nil {
return policy.Resource{}
}
acct, err := s.db.GetAccountByID(rec.AccountID)
if err != nil {
return policy.Resource{}
}
tags, _ := s.db.GetAccountTags(acct.ID)
return policy.Resource{
OwnerUUID: acct.UUID,
ServiceName: acct.Username,
Tags: tags,
}
}
@@ -92,6 +232,14 @@ func (s *Server) Handler() http.Handler {
if err != nil {
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.
// The Swagger UI page at /docs loads JavaScript from the same origin
// and renders untrusted content (API descriptions), so it benefits from
@@ -106,70 +254,169 @@ func (s *Server) Handler() http.Handler {
w.WriteHeader(http.StatusOK)
_, _ = 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).
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
mux.Handle("POST /v1/vault/unseal", unsealRateLimit(http.HandlerFunc(s.handleUnseal)))
mux.HandleFunc("GET /v1/vault/status", s.handleVaultStatus)
mux.Handle("POST /v1/vault/seal", middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)(middleware.RequireRole("admin")(http.HandlerFunc(s.handleSeal))))
// Authenticated endpoints.
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
requireAdmin := func(h http.Handler) http.Handler {
return requireAuth(middleware.RequireRole("admin")(h))
requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)
// Policy middleware factory: chains requireAuth → RequirePolicy → next.
// All protected endpoints use this instead of the old requireAdmin wrapper
// so that operator-defined policy rules (not just the admin role) control
// access. The built-in admin wildcard rule (ID -1) preserves existing
// admin behaviour; additional operator rules can grant non-admin accounts
// access to specific actions.
//
// Security: deny-wins + default-deny in the engine mean that any
// misconfiguration results in 403, never silent permit.
acctTypeLookup := s.accountTypeLookup()
denyLogger := s.policyDenyLogger()
requirePolicy := func(
action policy.Action,
resType policy.ResourceType,
builder middleware.ResourceBuilder,
) func(http.Handler) http.Handler {
pol := middleware.RequirePolicy(s.polEng, action, resType, builder, acctTypeLookup, denyLogger)
return func(next http.Handler) http.Handler {
return requireAuth(pol(next))
}
}
// Auth endpoints (require valid token).
// Resource builders for endpoints that target a specific account or token.
buildAcct := middleware.ResourceBuilder(s.buildAccountResource)
buildToken := middleware.ResourceBuilder(s.buildTokenResource)
buildJTI := middleware.ResourceBuilder(s.buildJTIResource)
// Auth endpoints (require valid token; self-service rules in built-in defaults
// allow any authenticated principal to perform these operations).
mux.Handle("POST /v1/auth/logout", requireAuth(http.HandlerFunc(s.handleLogout)))
mux.Handle("POST /v1/auth/renew", requireAuth(http.HandlerFunc(s.handleRenew)))
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
// Admin-only endpoints.
mux.Handle("DELETE /v1/auth/totp", requireAdmin(http.HandlerFunc(s.handleTOTPRemove)))
mux.Handle("POST /v1/token/issue", requireAdmin(http.HandlerFunc(s.handleTokenIssue)))
mux.Handle("DELETE /v1/token/{jti}", requireAdmin(http.HandlerFunc(s.handleTokenRevoke)))
mux.Handle("GET /v1/accounts", requireAdmin(http.HandlerFunc(s.handleListAccounts)))
mux.Handle("POST /v1/accounts", requireAdmin(http.HandlerFunc(s.handleCreateAccount)))
mux.Handle("GET /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleGetAccount)))
mux.Handle("PATCH /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleUpdateAccount)))
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount)))
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole)))
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole)))
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
// WebAuthn registration endpoints (require valid token; self-service).
mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin)))
mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish)))
// WebAuthn login endpoints (public, rate-limited).
mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin)))
mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish)))
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
mux.Handle("DELETE /v1/auth/totp",
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
mux.Handle("POST /v1/token/issue",
requirePolicy(policy.ActionIssueToken, policy.ResourceToken, buildToken)(http.HandlerFunc(s.handleTokenIssue)))
mux.Handle("DELETE /v1/token/{jti}",
requirePolicy(policy.ActionRevokeToken, policy.ResourceToken, buildJTI)(http.HandlerFunc(s.handleTokenRevoke)))
mux.Handle("GET /v1/accounts",
requirePolicy(policy.ActionListAccounts, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleListAccounts)))
mux.Handle("POST /v1/accounts",
requirePolicy(policy.ActionCreateAccount, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleCreateAccount)))
mux.Handle("GET /v1/accounts/{id}",
requirePolicy(policy.ActionReadAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetAccount)))
mux.Handle("PATCH /v1/accounts/{id}",
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleUpdateAccount)))
mux.Handle("DELETE /v1/accounts/{id}",
requirePolicy(policy.ActionDeleteAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleDeleteAccount)))
mux.Handle("GET /v1/accounts/{id}/roles",
requirePolicy(policy.ActionReadRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetRoles)))
mux.Handle("PUT /v1/accounts/{id}/roles",
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetRoles)))
mux.Handle("POST /v1/accounts/{id}/roles",
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGrantRole)))
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}",
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleRevokeRole)))
mux.Handle("GET /v1/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds)))
mux.Handle("GET /v1/accounts/{id}/pgcreds",
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
// WebAuthn credential management (policy-gated).
mux.Handle("GET /v1/accounts/{id}/webauthn",
requirePolicy(policy.ActionReadAccount, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleListWebAuthnCredentials)))
mux.Handle("DELETE /v1/accounts/{id}/webauthn/{credentialId}",
requirePolicy(policy.ActionRemoveWebAuthn, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleDeleteWebAuthnCredential)))
mux.Handle("GET /v1/audit",
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags",
requirePolicy(policy.ActionReadTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetTags)))
mux.Handle("PUT /v1/accounts/{id}/tags",
requirePolicy(policy.ActionWriteTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetTags)))
mux.Handle("PUT /v1/accounts/{id}/password",
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleAdminSetPassword)))
// Self-service password change (requires valid token; actor must match target account).
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
mux.Handle("PATCH /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleUpdatePolicyRule)))
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule)))
mux.Handle("GET /v1/policy/rules",
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleListPolicyRules)))
mux.Handle("POST /v1/policy/rules",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleCreatePolicyRule)))
mux.Handle("GET /v1/policy/rules/{id}",
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleGetPolicyRule)))
mux.Handle("PATCH /v1/policy/rules/{id}",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleUpdatePolicyRule)))
mux.Handle("DELETE /v1/policy/rules/{id}",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule)))
// UI routes (HTMX-based management frontend).
uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger)
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
if err != nil {
panic(fmt.Sprintf("ui: init failed: %v", err))
}
uiSrv.Register(mux)
// Apply global middleware: request logging.
// Apply global middleware: request logging, sealed check, and security headers.
// Rate limiting is applied per-route above (login, token/validate).
var root http.Handler = mux
// Security: RequireUnsealed runs after the mux (so exempt routes can be
// routed) but before the logger (so sealed-blocked requests are still logged).
root = middleware.RequireUnsealed(s.vault)(root)
root = middleware.RequestLogger(s.logger)(root)
// Security (SEC-04): apply baseline security headers to ALL responses
// (both API and UI). These headers are safe for every content type:
// - X-Content-Type-Options prevents MIME-sniffing attacks.
// - Strict-Transport-Security enforces HTTPS for 2 years.
// - Cache-Control prevents caching of authenticated responses.
// The UI sub-mux already sets these plus CSP/X-Frame-Options/Referrer-Policy
// which will override where needed (last Set wins before WriteHeader).
root = globalSecurityHeaders(root)
return root
}
// ---- Public handlers ----
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
if s.vault.IsSealed() {
writeJSON(w, http.StatusOK, map[string]string{"status": "sealed"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handlePublicKey returns the server's Ed25519 public key in JWK format.
// This allows relying parties to independently verify JWTs.
func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) {
pubKey, err := s.vault.PubKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
// Encode the Ed25519 public key as a JWK (RFC 8037).
// The "x" parameter is the base64url-encoded public key bytes.
jwk := map[string]string{
@@ -177,7 +424,7 @@ func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) {
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"x": encodeBase64URL(s.pubKey),
"x": encodeBase64URL(pubKey),
}
writeJSON(w, http.StatusOK, jwk)
}
@@ -189,6 +436,12 @@ type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code,omitempty"`
// ServiceName and Tags identify the calling service. MCIAS evaluates the
// auth:login policy with these as the resource context, enabling operators
// to restrict which roles/account-types may log into specific services.
// Clients populate these from their [mcias] config section.
ServiceName string `json:"service_name,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// loginResponse is the response body for a successful login.
@@ -214,7 +467,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Security: return a generic error whether the user exists or not.
// Always run a dummy Argon2 check to prevent timing-based user enumeration.
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, nil, nil, fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username))
s.writeAudit(r, model.EventLoginFail, nil, nil, audit.JSON("username", req.Username, "reason", "unknown_user"))
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
@@ -238,7 +491,9 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
// Security: return the same 401 "invalid credentials" as wrong-password
// to prevent user-enumeration via lockout differentiation (SEC-02).
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
@@ -255,13 +510,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// TOTP check (if enrolled).
if acct.TOTPRequired {
if req.TOTPCode == "" {
// Security (DEF-08 / PEN-06): do NOT increment the lockout counter
// for a missing TOTP code. A missing code means the client needs to
// re-prompt the user — it is not a credential failure. Incrementing
// here would let an attacker trigger account lockout by omitting the
// code after a correct password guess, and would penalise well-behaved
// clients that call Login in two steps (password first, TOTP second).
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required")
return
}
// Decrypt the TOTP secret.
secret, err := crypto.OpenAESGCM(s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
if err != nil {
s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -287,13 +552,42 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Login succeeded: clear any outstanding failure counter.
_ = s.db.ClearLoginFailures(acct.ID)
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
// Load roles for expiry decision and policy check.
roles, err := s.db.GetRoles(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Policy check: evaluate auth:login with the calling service's context.
// Operator rules can deny login based on role, account type, service name,
// or tags. The built-in default Allow for auth:login is overridden by any
// matching Deny rule (deny-wins semantics).
//
// Security: policy is checked after credential verification so that a
// policy-denied login returns 403 (not 401), distinguishing a service
// access restriction from a wrong password without leaking user existence.
{
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{
ServiceName: req.ServiceName,
Tags: req.Tags,
},
}
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
audit.JSON("reason", "policy_deny", "service_name", req.ServiceName))
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}
}
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
for _, r := range roles {
if r == "admin" {
expiry = s.cfg.AdminExpiry()
@@ -301,7 +595,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
}
tokenStr, claims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
s.logger.Error("issue token", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -315,7 +614,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
s.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, fmt.Sprintf(`{"jti":%q}`, claims.JTI))
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", claims.JTI))
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
@@ -330,13 +629,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI))
s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", claims.JTI, "reason", "logout"))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
// Security: only allow renewal when the token has consumed at least 50% of
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
elapsed := time.Since(claims.IssuedAt)
if elapsed < totalLifetime/2 {
middleware.WriteError(w, http.StatusBadRequest, "token is not yet eligible for renewal", "renewal_too_early")
return
}
// Load account to get current roles (they may have changed since token issuance).
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
@@ -362,7 +670,12 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
}
}
newTokenStr, newClaims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
newTokenStr, newClaims, 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
@@ -376,7 +689,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
return
}
s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI))
s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, audit.JSON("old_jti", claims.JTI, "new_jti", newClaims.JTI))
writeJSON(w, http.StatusOK, loginResponse{
Token: newTokenStr,
@@ -392,6 +705,7 @@ type validateRequest struct {
type validateResponse struct {
Subject string `json:"sub,omitempty"`
Username string `json:"username,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Roles []string `json:"roles,omitempty"`
Valid bool `json:"valid"`
@@ -414,7 +728,12 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
return
}
claims, err := token.ValidateToken(s.pubKey, tokenStr, s.cfg.Tokens.Issuer)
pubKey, err := s.vault.PubKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
claims, err := token.ValidateToken(pubKey, tokenStr, s.cfg.Tokens.Issuer)
if err != nil {
writeJSON(w, http.StatusOK, validateResponse{Valid: false})
return
@@ -426,12 +745,16 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, validateResponse{
resp := validateResponse{
Valid: true,
Subject: claims.Subject,
Roles: claims.Roles,
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}
if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
resp.Username = acct.Username
}
writeJSON(w, http.StatusOK, resp)
}
type issueTokenRequest struct {
@@ -454,23 +777,26 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
return
}
tokenStr, claims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, nil, s.cfg.ServiceExpiry())
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, nil, s.cfg.ServiceExpiry())
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Revoke existing system token if any.
// Atomically revoke existing system token (if any), track the new token,
// and update system_tokens — all in a single transaction.
// Security: prevents inconsistent state if a crash occurs mid-operation.
var oldJTI string
existing, err := s.db.GetSystemToken(acct.ID)
if err == nil && existing != nil {
_ = s.db.RevokeToken(existing.JTI, "rotated")
oldJTI = existing.JTI
}
if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
if err := s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
@@ -482,7 +808,7 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
actorID = &a.ID
}
}
s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, fmt.Sprintf(`{"jti":%q}`, claims.JTI))
s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, audit.JSON("jti", claims.JTI))
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
@@ -502,7 +828,7 @@ func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
return
}
s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q}`, jti))
s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", jti))
w.WriteHeader(http.StatusNoContent)
}
@@ -597,7 +923,7 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
return
}
s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, fmt.Sprintf(`{"username":%q}`, acct.Username))
s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, audit.JSON("username", acct.Username))
writeJSON(w, http.StatusCreated, accountToResponse(acct))
}
@@ -712,7 +1038,7 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
return
}
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"roles":%v}`, req.Roles))
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, audit.JSONWithRoles(req.Roles))
w.WriteHeader(http.StatusNoContent)
}
@@ -745,7 +1071,7 @@ func (s *Server) handleGrantRole(w http.ResponseWriter, r *http.Request) {
return
}
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, req.Role))
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, audit.JSON("role", req.Role))
w.WriteHeader(http.StatusNoContent)
}
@@ -774,12 +1100,16 @@ func (s *Server) handleRevokeRole(w http.ResponseWriter, r *http.Request) {
return
}
s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, role))
s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, audit.JSON("role", role))
w.WriteHeader(http.StatusNoContent)
}
// ---- TOTP endpoints ----
type totpEnrollRequest struct {
Password string `json:"password"` // security: current password required to prevent session-theft escalation
}
type totpEnrollResponse struct {
Secret string `json:"secret"` // base32-encoded
OTPAuthURI string `json:"otpauth_uri"`
@@ -789,6 +1119,12 @@ type totpConfirmRequest struct {
Code string `json:"code"`
}
// handleTOTPEnroll begins TOTP enrollment for the calling account.
//
// Security (SEC-01): the current password is required in the request body to
// prevent a stolen session token from being used to enroll attacker-controlled
// MFA on the victim's account. Lockout is checked and failures are recorded
// to prevent brute-force use of this endpoint as a password oracle.
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
@@ -797,6 +1133,38 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
return
}
var req totpEnrollRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Password == "" {
middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request")
return
}
// Security: check lockout before verifying (same as login and password-change flows)
// so an attacker cannot use this endpoint to brute-force the current password.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (TOTP enroll)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
return
}
// Security: verify the current password with the same constant-time
// Argon2id path used at login to prevent timing oracles.
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = s.db.RecordLoginFailure(acct.ID)
s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized")
return
}
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -805,7 +1173,12 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
// Encrypt the secret before storing it temporarily.
// Note: we store as pending; enrollment is confirmed with /confirm.
secretEnc, secretNonce, err := crypto.SealAESGCM(s.masterKey, rawSecret)
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
@@ -848,7 +1221,12 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
return
}
secret, err := crypto.OpenAESGCM(s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
@@ -1026,7 +1404,9 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
}
if locked {
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
// Security: return the same 401 as wrong-password to prevent
// user-enumeration via lockout differentiation (SEC-02).
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
@@ -1106,7 +1486,12 @@ func (s *Server) handleGetPGCreds(w http.ResponseWriter, r *http.Request) {
}
// Decrypt the password to return it to the admin caller.
password, err := crypto.OpenAESGCM(s.masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc)
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
password, err := crypto.OpenAESGCM(masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
@@ -1143,7 +1528,12 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
req.Port = 5432
}
enc, nonce, err := crypto.SealAESGCM(s.masterKey, []byte(req.Password))
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(req.Password))
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
@@ -1158,6 +1548,58 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleListAccessiblePGCreds returns all pg_credentials accessible to the
// authenticated user: those owned + those explicitly granted. The credential ID
// is included so callers can fetch a specific credential via /v1/accounts/{id}/pgcreds.
func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
middleware.WriteError(w, http.StatusUnauthorized, "not authenticated", "unauthorized")
return
}
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
creds, err := s.db.ListAccessiblePGCreds(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Convert credentials to response format with credential ID.
type pgCredResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Host string `json:"host"`
Database string `json:"database"`
Username string `json:"username"`
ServiceAccountID string `json:"service_account_id"`
ServiceAccountName string `json:"service_account_name,omitempty"`
ID int64 `json:"id"`
Port int `json:"port"`
}
response := make([]pgCredResponse, len(creds))
for i, cred := range creds {
response[i] = pgCredResponse{
ID: cred.ID,
ServiceAccountID: cred.ServiceAccountUUID,
Host: cred.PGHost,
Port: cred.PGPort,
Database: cred.PGDatabase,
Username: cred.PGUsername,
CreatedAt: cred.CreatedAt,
UpdatedAt: cred.UpdatedAt,
}
}
writeJSON(w, http.StatusOK, response)
}
// ---- Audit endpoints ----
// handleListAudit returns paginated audit log entries with resolved usernames.
@@ -1269,9 +1711,21 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
}
}
// maxJSONBytes limits the size of JSON request bodies (1 MiB).
//
// Security (SEC-05): without a size limit an attacker could send a
// multi-gigabyte body and exhaust server memory. The UI layer already
// applies http.MaxBytesReader; this constant gives the REST API the
// same protection.
const maxJSONBytes = 1 << 20
// decodeJSON decodes a JSON request body into v.
// Returns false and writes a 400 response if decoding fails.
//
// Security (SEC-05): the body is wrapped with http.MaxBytesReader so
// that oversized payloads are rejected before they are fully read.
func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
@@ -1282,21 +1736,42 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
}
// extractBearerFromRequest extracts a Bearer token from the Authorization header.
// Security (PEN-01): validates the "Bearer" prefix using case-insensitive
// comparison before extracting the token. The previous implementation sliced
// at a fixed offset without checking the prefix, accepting any 8+ character
// Authorization value.
func extractBearerFromRequest(r *http.Request) (string, error) {
auth := r.Header.Get("Authorization")
if auth == "" {
return "", fmt.Errorf("no Authorization header")
}
const prefix = "Bearer "
if len(auth) <= len(prefix) {
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", fmt.Errorf("malformed Authorization header")
}
return auth[len(prefix):], nil
if parts[1] == "" {
return "", fmt.Errorf("empty Bearer token")
}
return parts[1], nil
}
// docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
// to the /docs and /docs/openapi.yaml endpoints.
//
// globalSecurityHeaders sets baseline security headers on every response.
// Security (SEC-04): API responses previously lacked X-Content-Type-Options,
// HSTS, and Cache-Control. These three headers are safe for all content types
// and do not interfere with JSON API clients or the HTMX UI.
func globalSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
// Security (DEF-09): without these headers the Swagger UI HTML page is
// served without CSP, X-Frame-Options, or HSTS, leaving it susceptible
// to clickjacking and MIME-type confusion in browsers.

View File

@@ -3,10 +3,15 @@ package server
import (
"bytes"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha1" //nolint:gosec // G505: SHA1 required by RFC 6238 TOTP (HMAC-SHA1)
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/http/httptest"
"strings"
@@ -18,9 +23,31 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
// generateTOTPCode computes a valid RFC 6238 TOTP code for the current time
// using the given raw secret bytes. Used in tests to confirm TOTP enrollment.
func generateTOTPCode(t *testing.T, secret []byte) string {
t.Helper()
counter := uint64(time.Now().Unix() / 30) //nolint:gosec // G115: always non-negative
counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, counter)
mac := hmac.New(sha1.New, secret)
if _, err := mac.Write(counterBytes); err != nil {
t.Fatalf("generateTOTPCode: HMAC write: %v", err)
}
h := mac.Sum(nil)
offset := h[len(h)-1] & 0x0F
binCode := (int(h[offset]&0x7F)<<24 |
int(h[offset+1])<<16 |
int(h[offset+2])<<8 |
int(h[offset+3])) % int(math.Pow10(6))
return fmt.Sprintf("%06d", binCode)
}
const testIssuer = "https://auth.example.com"
func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey, *db.DB) {
@@ -47,8 +74,9 @@ func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey
cfg := config.NewTestConfig(testIssuer)
v := vault.NewUnsealed(masterKey, priv, pub)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := New(database, cfg, priv, pub, masterKey, logger)
srv := New(database, cfg, v, logger)
return srv, pub, priv, database
}
@@ -519,8 +547,10 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
t.Fatalf("TrackToken: %v", err)
}
// Start enrollment.
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", nil, tokenStr)
// Start enrollment (password required since SEC-01 fix).
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "testpass123",
}, tokenStr)
if rr.Code != http.StatusOK {
t.Fatalf("enroll status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
@@ -558,12 +588,69 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
}
}
// TestTOTPEnrollRequiresPassword verifies that TOTP enrollment (SEC-01)
// requires the current password. A stolen session token alone must not be
// sufficient to add attacker-controlled MFA to the victim's account.
func TestTOTPEnrollRequiresPassword(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "totp-pw-check")
handler := srv.Handler()
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
t.Run("no password", func(t *testing.T) {
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{}, tokenStr)
if rr.Code != http.StatusBadRequest {
t.Errorf("enroll without password: status = %d, want %d; body: %s",
rr.Code, http.StatusBadRequest, rr.Body.String())
}
})
t.Run("wrong password", func(t *testing.T) {
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "wrong-password",
}, tokenStr)
if rr.Code != http.StatusUnauthorized {
t.Errorf("enroll with wrong password: status = %d, want %d; body: %s",
rr.Code, http.StatusUnauthorized, rr.Body.String())
}
})
t.Run("correct password", func(t *testing.T) {
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "testpass123",
}, tokenStr)
if rr.Code != http.StatusOK {
t.Fatalf("enroll with correct password: status = %d, want 200; body: %s",
rr.Code, rr.Body.String())
}
var resp totpEnrollResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Secret == "" {
t.Error("expected non-empty TOTP secret")
}
if resp.OTPAuthURI == "" {
t.Error("expected non-empty otpauth URI")
}
})
}
func TestRenewToken(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "renew-user")
handler := srv.Handler()
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
// Issue a short-lived token (4s) so we can wait past the 50% threshold
// while leaving enough headroom before expiry to avoid flakiness.
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, 4*time.Second)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
@@ -572,6 +659,9 @@ func TestRenewToken(t *testing.T) {
t.Fatalf("TrackToken: %v", err)
}
// Wait for >50% of the 4s lifetime to elapse.
time.Sleep(2100 * time.Millisecond)
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
if rr.Code != http.StatusOK {
t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String())
@@ -594,3 +684,438 @@ func TestRenewToken(t *testing.T) {
t.Error("old token should be revoked after renewal")
}
}
func TestOversizedJSONBodyRejected(t *testing.T) {
srv, _, _, _ := newTestServer(t)
handler := srv.Handler()
// Build a JSON body larger than 1 MiB.
oversized := bytes.Repeat([]byte("A"), (1<<20)+1)
body := []byte(`{"username":"admin","password":"` + string(oversized) + `"}`)
req := httptest.NewRequest("POST", "/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for oversized body, got %d", rr.Code)
}
}
// TestSecurityHeadersOnAPIResponses verifies that the global security-headers
// middleware (SEC-04) sets X-Content-Type-Options, Strict-Transport-Security,
// and Cache-Control on all API responses, not just the UI.
func TestSecurityHeadersOnAPIResponses(t *testing.T) {
srv, _, _, _ := newTestServer(t)
handler := srv.Handler()
wantHeaders := map[string]string{
"X-Content-Type-Options": "nosniff",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"Cache-Control": "no-store",
}
t.Run("GET /v1/health", func(t *testing.T) {
rr := doRequest(t, handler, "GET", "/v1/health", nil, "")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
for header, want := range wantHeaders {
got := rr.Header().Get(header)
if got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
})
t.Run("POST /v1/auth/login", func(t *testing.T) {
createTestHumanAccount(t, srv, "sec04-user")
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
"username": "sec04-user",
"password": "testpass123",
}, "")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
for header, want := range wantHeaders {
got := rr.Header().Get(header)
if got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
})
}
// TestLoginLockedAccountReturns401 verifies that a locked-out account gets the
// same HTTP 401 / "invalid credentials" response as a wrong-password attempt,
// preventing user-enumeration via lockout differentiation (SEC-02).
func TestLoginLockedAccountReturns401(t *testing.T) {
srv, _, _, database := newTestServer(t)
acct := createTestHumanAccount(t, srv, "lockuser")
handler := srv.Handler()
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
// Record enough failures to trigger lockout.
for range db.LockoutThreshold {
if err := database.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
// Confirm the account is locked.
locked, err := database.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
// Attempt login on the locked account.
lockedRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
"username": "lockuser",
"password": "testpass123",
}, "")
// Also attempt login with a wrong password (not locked) for comparison.
wrongRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
"username": "lockuser",
"password": "wrongpassword",
}, "")
// Both must return 401, not 429.
if lockedRR.Code != http.StatusUnauthorized {
t.Errorf("locked account: status = %d, want %d", lockedRR.Code, http.StatusUnauthorized)
}
if wrongRR.Code != http.StatusUnauthorized {
t.Errorf("wrong password: status = %d, want %d", wrongRR.Code, http.StatusUnauthorized)
}
// Parse the JSON bodies and compare — they must be identical.
type errResp struct {
Error string `json:"error"`
Code string `json:"code"`
}
var lockedBody, wrongBody errResp
if err := json.Unmarshal(lockedRR.Body.Bytes(), &lockedBody); err != nil {
t.Fatalf("unmarshal locked body: %v", err)
}
if err := json.Unmarshal(wrongRR.Body.Bytes(), &wrongBody); err != nil {
t.Fatalf("unmarshal wrong body: %v", err)
}
if lockedBody != wrongBody {
t.Errorf("locked response %+v differs from wrong-password response %+v", lockedBody, wrongBody)
}
if lockedBody.Code != "unauthorized" {
t.Errorf("locked response code = %q, want %q", lockedBody.Code, "unauthorized")
}
if lockedBody.Error != "invalid credentials" {
t.Errorf("locked response error = %q, want %q", lockedBody.Error, "invalid credentials")
}
}
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
// of its lifetime has elapsed (SEC-03).
// TestExtractBearerFromRequest verifies that extractBearerFromRequest correctly
// validates the "Bearer" prefix before extracting the token string.
// Security (PEN-01): the previous implementation sliced at a fixed offset
// without checking the prefix, accepting any 8+ character Authorization value.
func TestExtractBearerFromRequest(t *testing.T) {
tests := []struct {
name string
header string
want string
wantErr bool
}{
{"valid", "Bearer mytoken123", "mytoken123", false},
{"missing header", "", "", true},
{"no bearer prefix", "Token mytoken123", "", true},
{"basic auth scheme", "Basic dXNlcjpwYXNz", "", true},
{"empty token", "Bearer ", "", true},
{"bearer only no space", "Bearer", "", true},
{"case insensitive", "bearer mytoken123", "mytoken123", false},
{"mixed case", "BEARER mytoken123", "mytoken123", false},
{"garbage 8 chars", "XXXXXXXX", "", true},
{"token with spaces", "Bearer token with spaces", "token with spaces", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
got, err := extractBearerFromRequest(req)
if (err != nil) != tc.wantErr {
t.Errorf("wantErr=%v, got err=%v", tc.wantErr, err)
}
if !tc.wantErr && got != tc.want {
t.Errorf("token = %q, want %q", got, tc.want)
}
})
}
}
func TestRenewTokenTooEarly(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "renew-early-user")
handler := srv.Handler()
// Issue a long-lived token so 50% is far in the future.
tokStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
// Immediately try to renew — should be rejected.
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, tokStr)
if rr.Code != http.StatusBadRequest {
t.Fatalf("renew status = %d, want 400; body: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "not yet eligible for renewal") {
t.Errorf("expected eligibility message, got: %s", rr.Body.String())
}
}
// TestTOTPMissingDoesNotIncrementLockout verifies that a login attempt with
// a correct password but missing TOTP code does NOT increment the account
// lockout counter (PEN-06 / DEF-08).
//
// Security: incrementing the lockout counter for a missing TOTP code would
// allow an attacker to lock out a TOTP-enrolled account by repeatedly sending
// the correct password with no TOTP code — without needing to guess TOTP.
// It would also penalise well-behaved two-step clients.
func TestTOTPMissingDoesNotIncrementLockout(t *testing.T) {
srv, _, priv, database := newTestServer(t)
acct := createTestHumanAccount(t, srv, "totp-lockout-user")
handler := srv.Handler()
// Issue a token so we can call the TOTP enroll and confirm endpoints.
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
// Enroll TOTP — get back the base32 secret.
enrollRR := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "testpass123",
}, tokenStr)
if enrollRR.Code != http.StatusOK {
t.Fatalf("enroll status = %d, want 200; body: %s", enrollRR.Code, enrollRR.Body.String())
}
var enrollResp totpEnrollResponse
if err := json.Unmarshal(enrollRR.Body.Bytes(), &enrollResp); err != nil {
t.Fatalf("unmarshal enroll: %v", err)
}
// Decode the secret and generate a valid TOTP code to confirm enrollment.
// We compute the TOTP code inline using the same RFC 6238 algorithm used
// by auth.ValidateTOTP, since auth.hotp is not exported.
secretBytes, err := auth.DecodeTOTPSecret(enrollResp.Secret)
if err != nil {
t.Fatalf("DecodeTOTPSecret: %v", err)
}
currentCode := generateTOTPCode(t, secretBytes)
// Confirm enrollment.
confirmRR := doRequest(t, handler, "POST", "/v1/auth/totp/confirm", map[string]string{
"code": currentCode,
}, tokenStr)
if confirmRR.Code != http.StatusNoContent {
t.Fatalf("confirm status = %d, want 204; body: %s", confirmRR.Code, confirmRR.Body.String())
}
// Account should now require TOTP. Lower the lockout threshold to 1 so
// that a single RecordLoginFailure call would immediately lock the account.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 1
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
// Attempt login with the correct password but no TOTP code.
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
"username": "totp-lockout-user",
"password": "testpass123",
}, "")
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for missing TOTP, got %d; body: %s", rr.Code, rr.Body.String())
}
// The error code must be totp_required, not unauthorized.
var errResp struct {
Code string `json:"code"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &errResp); err != nil {
t.Fatalf("unmarshal error response: %v", err)
}
if errResp.Code != "totp_required" {
t.Errorf("error code = %q, want %q", errResp.Code, "totp_required")
}
// Security (PEN-06): the lockout counter must NOT have been incremented.
// With threshold=1, if it had been incremented the account would now be
// locked and a subsequent login with correct credentials would fail.
locked, err := database.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if locked {
t.Error("account was locked after TOTP-missing login — lockout counter was incorrectly incremented (PEN-06)")
}
}
// issueSystemToken creates a system account, issues a JWT with the given roles,
// tracks it in the database, and returns the token string and account.
func issueSystemToken(t *testing.T, srv *Server, priv ed25519.PrivateKey, username string, roles []string) (string, *model.Account) {
t.Helper()
acct, err := srv.db.CreateAccount(username, model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("create system account: %v", err)
}
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, roles, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return tokenStr, acct
}
// TestPolicyEnforcement verifies that the policy engine gates access:
// - Admin role is always allowed (built-in wildcard rule).
// - Unauthenticated requests are rejected.
// - Non-admin accounts are denied by default.
// - A non-admin account gains access once an operator policy rule is created.
// - Deleting the rule reverts to denial.
func TestPolicyEnforcement(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
handler := srv.Handler()
adminToken, _ := issueAdminToken(t, srv, priv, "admin-pol")
// 1. Admin can list accounts (built-in wildcard rule -1).
rr := doRequest(t, handler, "GET", "/v1/accounts", nil, adminToken)
if rr.Code != http.StatusOK {
t.Errorf("admin list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
// 2. Unauthenticated request is rejected.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("unauth list accounts status = %d, want 401", rr.Code)
}
// 3. System account with no operator rules is denied by default.
svcToken, svcAcct := issueSystemToken(t, srv, priv, "metacrypt", []string{"user"})
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (no policy) list accounts status = %d, want 403; body: %s", rr.Code, rr.Body.String())
}
// 4. Create an operator policy rule granting the system account accounts:list.
rule := createPolicyRuleRequest{
Description: "allow metacrypt to list accounts",
Priority: 50,
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
AccountTypes: []string{"system"},
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Allow,
},
}
rr = doRequest(t, handler, "POST", "/v1/policy/rules", rule, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create policy rule status = %d, want 201; body: %s", rr.Code, rr.Body.String())
}
var created policyRuleResponse
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("unmarshal created rule: %v", err)
}
// 5. The same system account can now list accounts.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusOK {
t.Errorf("system account (with policy) list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
// 6. The system account is still denied other actions (accounts:read).
rr = doRequest(t, handler, "POST", "/v1/accounts", map[string]string{
"username": "newuser", "password": "newpassword123", "account_type": "human",
}, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (list-only policy) create account status = %d, want 403", rr.Code)
}
// 7. Delete the rule and verify the account is denied again.
rr = doRequest(t, handler, "DELETE", fmt.Sprintf("/v1/policy/rules/%d", created.ID), nil, adminToken)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete policy rule status = %d, want 204; body: %s", rr.Code, rr.Body.String())
}
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (rule deleted) list accounts status = %d, want 403", rr.Code)
}
}
// TestPolicyDenyRule verifies that an explicit Deny rule blocks access even
// when an Allow rule would otherwise permit it.
func TestPolicyDenyRule(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
handler := srv.Handler()
adminToken, _ := issueAdminToken(t, srv, priv, "admin-deny")
// Create an Allow rule for the system account.
svcToken, svcAcct := issueSystemToken(t, srv, priv, "svc-deny", []string{"user"})
allow := createPolicyRuleRequest{
Description: "allow svc-deny to list accounts",
Priority: 50,
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Allow,
},
}
rr := doRequest(t, handler, "POST", "/v1/policy/rules", allow, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create allow rule status = %d; body: %s", rr.Code, rr.Body.String())
}
// Verify access is granted.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusOK {
t.Fatalf("with allow rule, list accounts status = %d, want 200", rr.Code)
}
// Add a higher-priority Deny rule for the same account.
deny := createPolicyRuleRequest{
Description: "deny svc-deny accounts:list",
Priority: 10, // lower number = higher precedence
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Deny,
},
}
rr = doRequest(t, handler, "POST", "/v1/policy/rules", deny, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create deny rule status = %d; body: %s", rr.Code, rr.Body.String())
}
// Deny-wins: access must now be blocked despite the Allow rule.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("deny-wins: list accounts status = %d, want 403", rr.Code)
}
}

102
internal/server/vault.go Normal file
View File

@@ -0,0 +1,102 @@
// Vault seal/unseal REST handlers for MCIAS.
package server
import (
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
// unsealRequest is the request body for POST /v1/vault/unseal.
type unsealRequest struct {
Passphrase string `json:"passphrase"`
}
// handleUnseal accepts a passphrase, derives the master key, decrypts the
// signing key, and unseals the vault. Rate-limited to 3/s burst 5.
//
// Security: The passphrase is never logged. A generic error is returned on
// any failure to prevent information leakage about the vault state.
func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
if !s.vault.IsSealed() {
writeJSON(w, http.StatusOK, map[string]string{"status": "already unsealed"})
return
}
var req unsealRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Passphrase == "" {
middleware.WriteError(w, http.StatusBadRequest, "passphrase is required", "bad_request")
return
}
// Derive master key from passphrase.
masterKey, err := vault.DeriveFromPassphrase(req.Passphrase, s.db)
if err != nil {
s.logger.Error("vault unseal: derive key", "error", err)
middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized")
return
}
// Decrypt the signing key.
privKey, pubKey, err := vault.DecryptSigningKey(s.db, masterKey)
if err != nil {
// Zero derived master key on failure.
for i := range masterKey {
masterKey[i] = 0
}
s.logger.Error("vault unseal: decrypt signing key", "error", err)
middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized")
return
}
if err := s.vault.Unseal(masterKey, privKey, pubKey); err != nil {
s.logger.Error("vault unseal: state transition", "error", err)
middleware.WriteError(w, http.StatusConflict, "vault is already unsealed", "conflict")
return
}
ip := middleware.ClientIP(r, nil)
s.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "api", "ip", ip))
s.logger.Info("vault unsealed via API", "ip", ip)
writeJSON(w, http.StatusOK, map[string]string{"status": "unsealed"})
}
// handleSeal seals the vault, zeroing all key material. Admin-only.
//
// Security: The caller's token becomes invalid after sealing because the
// public key needed to validate it is no longer available.
func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
if s.vault.IsSealed() {
writeJSON(w, http.StatusOK, map[string]string{"status": "already sealed"})
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &acct.ID
}
}
s.vault.Seal()
ip := middleware.ClientIP(r, nil)
s.writeAudit(r, model.EventVaultSealed, actorID, nil, audit.JSON("source", "api", "ip", ip))
s.logger.Info("vault sealed via API", "ip", ip)
writeJSON(w, http.StatusOK, map[string]string{"status": "sealed"})
}
// handleVaultStatus returns the current seal state of the vault.
func (s *Server) handleVaultStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"sealed": s.vault.IsSealed()})
}

View File

@@ -0,0 +1,171 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
func TestHandleHealthSealed(t *testing.T) {
srv, _, _, _ := newTestServer(t)
srv.vault.Seal()
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("health status = %d, want 200", rr.Code)
}
var resp map[string]string
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode health: %v", err)
}
if resp["status"] != "sealed" {
t.Fatalf("health status = %q, want sealed", resp["status"])
}
}
func TestHandleHealthUnsealed(t *testing.T) {
srv, _, _, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("health status = %d, want 200", rr.Code)
}
var resp map[string]string
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode health: %v", err)
}
if resp["status"] != "ok" {
t.Fatalf("health status = %q, want ok", resp["status"])
}
}
func TestVaultStatusEndpoint(t *testing.T) {
srv, _, _, _ := newTestServer(t)
// Unsealed
req := httptest.NewRequest(http.MethodGet, "/v1/vault/status", nil)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status code = %d, want 200", rr.Code)
}
var resp map[string]bool
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["sealed"] {
t.Fatal("vault should be unsealed")
}
// Seal and check again
srv.vault.Seal()
req = httptest.NewRequest(http.MethodGet, "/v1/vault/status", nil)
rr = httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status code = %d, want 200", rr.Code)
}
resp = nil
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp["sealed"] {
t.Fatal("vault should be sealed")
}
}
func TestSealedMiddlewareAPIReturns503(t *testing.T) {
srv, _, _, _ := newTestServer(t)
srv.vault.Seal()
req := httptest.NewRequest(http.MethodGet, "/v1/accounts", nil)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("sealed API status = %d, want 503", rr.Code)
}
var resp map[string]string
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["code"] != "vault_sealed" {
t.Fatalf("error code = %q, want vault_sealed", resp["code"])
}
}
func TestSealedMiddlewareUIRedirects(t *testing.T) {
srv, _, _, _ := newTestServer(t)
srv.vault.Seal()
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("sealed UI status = %d, want 302", rr.Code)
}
loc := rr.Header().Get("Location")
if loc != "/unseal" {
t.Fatalf("redirect location = %q, want /unseal", loc)
}
}
func TestUnsealBadPassphrase(t *testing.T) {
srv, _, _, _ := newTestServer(t)
// Start sealed.
v := vault.NewSealed()
srv.vault = v
body := `{"passphrase":"wrong-passphrase"}`
req := httptest.NewRequest(http.MethodPost, "/v1/vault/unseal", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("unseal with bad passphrase status = %d, want 401", rr.Code)
}
}
func TestSealAlreadySealedNoop(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
// Seal via API (needs admin token)
adminToken, _ := issueAdminToken(t, srv, priv, "admin")
req := httptest.NewRequest(http.MethodPost, "/v1/vault/seal", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("seal status = %d, want 200", rr.Code)
}
var resp map[string]string
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["status"] != "sealed" {
t.Fatalf("seal response status = %q, want sealed", resp["status"])
}
// Vault should be sealed now
if !srv.vault.IsSealed() {
t.Fatal("vault should be sealed after seal API call")
}
}

View File

@@ -8,6 +8,9 @@ import (
"crypto/subtle"
"encoding/hex"
"fmt"
"sync"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
@@ -21,17 +24,61 @@ import (
// - The form/header value is HMAC-SHA256(key, cookieVal); this is what the
// server verifies. An attacker cannot forge the HMAC without the key.
// - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks.
// - When backed by a vault, the key is derived lazily on first use after
// unseal. When the vault is re-sealed, the key is invalidated and re-derived
// on the next unseal. This is safe because sealed middleware prevents
// reaching CSRF-protected routes.
type CSRFManager struct {
key []byte
vault *vault.Vault
key []byte
mu sync.Mutex
}
// newCSRFManager creates a CSRFManager whose key is derived from masterKey.
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
func newCSRFManager(masterKey []byte) *CSRFManager {
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
// from the vault's master key. When the vault is sealed, operations fail
// gracefully (the sealed middleware prevents reaching CSRF-protected routes).
func newCSRFManagerFromVault(v *vault.Vault) *CSRFManager {
c := &CSRFManager{vault: v}
// If already unsealed, derive immediately.
mk, err := v.MasterKey()
if err == nil {
c.key = deriveCSRFKey(mk)
}
return c
}
// deriveCSRFKey computes the HMAC key from a master key.
func deriveCSRFKey(masterKey []byte) []byte {
h := sha256.New()
h.Write([]byte("mcias-ui-csrf-v1"))
h.Write(masterKey)
return &CSRFManager{key: h.Sum(nil)}
return h.Sum(nil)
}
// csrfKey returns the current CSRF key, deriving it from vault if needed.
func (c *CSRFManager) csrfKey() ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
// If we have a vault, re-derive key when sealed state changes.
if c.vault != nil {
if c.vault.IsSealed() {
c.key = nil
return nil, fmt.Errorf("csrf: vault is sealed")
}
if c.key == nil {
mk, err := c.vault.MasterKey()
if err != nil {
return nil, fmt.Errorf("csrf: %w", err)
}
c.key = deriveCSRFKey(mk)
}
}
if c.key == nil {
return nil, fmt.Errorf("csrf: no key available")
}
return c.key, nil
}
// NewToken generates a fresh CSRF token pair.
@@ -40,12 +87,16 @@ func newCSRFManager(masterKey []byte) *CSRFManager {
// - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
key, err := c.csrfKey()
if err != nil {
return "", "", err
}
raw := make([]byte, 32)
if _, err = rand.Read(raw); err != nil {
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
}
cookieVal = hex.EncodeToString(raw)
mac := hmac.New(sha256.New, c.key)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(cookieVal))
headerVal = hex.EncodeToString(mac.Sum(nil))
return cookieVal, headerVal, nil
@@ -57,7 +108,11 @@ func (c *CSRFManager) Validate(cookieVal, headerVal string) bool {
if cookieVal == "" || headerVal == "" {
return false
}
mac := hmac.New(sha256.New, c.key)
key, err := c.csrfKey()
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(cookieVal))
expected := hex.EncodeToString(mac.Sum(nil))
// Security: constant-time comparison prevents timing oracle attacks.

View File

@@ -15,7 +15,14 @@ import (
)
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
var knownRoles = []string{"admin", "user", "service"}
var knownRoles = []string{
model.RoleAdmin,
model.RoleUser,
model.RoleGuest,
model.RoleViewer,
model.RoleEditor,
model.RoleCommenter,
}
// handleAccountsList renders the accounts list page.
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
@@ -32,7 +39,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
}
u.render(w, "accounts", AccountsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Accounts: accounts,
})
}
@@ -175,17 +182,47 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
tags = nil
}
// For system accounts, load token issue delegates and the full account
// list so admins can add new ones.
var tokenDelegates []*model.ServiceAccountDelegate
var delegatableAccounts []*model.Account
if acct.AccountType == model.AccountTypeSystem && isAdmin(r) {
tokenDelegates, err = u.db.ListTokenIssueDelegates(acct.ID)
if err != nil {
u.logger.Warn("list token issue delegates", "error", err)
}
delegatableAccounts, err = u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for delegate dropdown", "error", err)
}
}
// Load WebAuthn credentials for the account detail page.
var webAuthnCreds []*model.WebAuthnCredential
if u.cfg.WebAuthnEnabled() {
webAuthnCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
u.logger.Warn("load webauthn credentials", "error", err)
}
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
PGCred: pgCred,
PGCredGrants: pgCredGrants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
Tags: tags,
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
PGCred: pgCred,
PGCredGrants: pgCredGrants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
Tags: tags,
TokenDelegates: tokenDelegates,
DelegatableAccounts: delegatableAccounts,
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
WebAuthnCreds: webAuthnCreds,
DeletePrefix: "/accounts/" + acct.UUID + "/webauthn",
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
@@ -453,7 +490,12 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
// Security: encrypt the password with AES-256-GCM before storage.
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
// is not possible. The plaintext password is not retained after this call.
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
masterKey, err := u.vault.MasterKey()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password))
if err != nil {
u.logger.Error("encrypt pg password", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
@@ -783,7 +825,7 @@ func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) {
}
u.render(w, "pgcreds", PGCredsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Creds: creds,
UncredentialedAccounts: uncredentialed,
CredGrants: credGrants,
@@ -857,7 +899,12 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
}
// Security: encrypt with AES-256-GCM; fresh nonce per call.
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
masterKey, err := u.vault.MasterKey()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password))
if err != nil {
u.logger.Error("encrypt pg password", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
@@ -907,26 +954,8 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
// storage. The plaintext is never logged or included in any response.
// Audit event EventPasswordChanged is recorded on success.
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
// Security: enforce admin role; requireCookieAuth only validates the token,
// it does not check roles. A non-admin with a valid session must not be
// able to reset arbitrary accounts' passwords.
callerClaims := claimsFromContext(r.Context())
if callerClaims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
isAdmin := false
for _, role := range callerClaims.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if !isAdmin {
u.renderError(w, r, http.StatusForbidden, "admin role required")
return
}
// Security: admin role is enforced by the requireAdminRole middleware in
// the route registration (ui.go); no inline check needed here.
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
@@ -1010,6 +1039,13 @@ func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
}
// handleIssueSystemToken issues a long-lived service token for a system account.
// Accessible to admins and to accounts that have been granted delegate access
// for this specific service account via service_account_delegates.
//
// Security: authorization is checked server-side against the JWT claims stored
// in the request context — it cannot be bypassed by client-side manipulation.
// After issuance the token string is stored in a short-lived single-use
// download nonce so the operator can retrieve it exactly once as a file.
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
@@ -1022,6 +1058,32 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
return
}
// Security: require admin role OR an explicit delegate grant for this account.
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if !isAdmin(r) {
if actorClaims == nil {
u.renderError(w, r, http.StatusForbidden, "access denied")
return
}
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err != nil {
u.renderError(w, r, http.StatusForbidden, "access denied")
return
}
actorID = &actor.ID
hasAccess, err := u.db.HasTokenIssueAccess(acct.ID, actor.ID)
if err != nil || !hasAccess {
u.renderError(w, r, http.StatusForbidden, "not authorized to issue tokens for this service account")
return
}
} else if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
@@ -1055,17 +1117,18 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
u.logger.Warn("set system token record", "error", err)
}
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
// Store the raw token in the short-lived download cache so the operator
// can retrieve it exactly once via the download endpoint.
downloadNonce, err := u.storeTokenDownload(tokenStr, acct.UUID)
if err != nil {
u.logger.Error("store token download nonce", "error", err)
// Non-fatal: fall back to showing the token in the flash message.
downloadNonce = ""
}
// Re-fetch token list including the new token.
tokens, err := u.db.ListTokensForAccount(acct.ID)
if err != nil {
@@ -1078,13 +1141,209 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
csrfToken = ""
}
// Flash the raw token once at the top so the operator can copy it.
var flash string
if downloadNonce == "" {
// Fallback: show token in flash when download nonce could not be stored.
flash = fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr)
} else {
flash = "Token issued. Download it now — it will not be available again."
}
u.render(w, "token_list", AccountDetailData{
PageData: PageData{
CSRFToken: csrfToken,
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
},
Account: acct,
Tokens: tokens,
PageData: PageData{CSRFToken: csrfToken, Flash: flash},
Account: acct,
Tokens: tokens,
DownloadNonce: downloadNonce,
})
}
// handleDownloadToken serves the just-issued service token as a file
// attachment. The nonce is single-use and expires after tokenDownloadTTL.
//
// Security: the nonce was generated with crypto/rand (128 bits) at issuance
// time and is deleted from the in-memory store on first retrieval, preventing
// replay. The response sets Content-Disposition: attachment so the browser
// saves the file rather than rendering it, reducing the risk of an XSS vector
// if the token were displayed inline.
func (u *UIServer) handleDownloadToken(w http.ResponseWriter, r *http.Request) {
nonce := r.PathValue("nonce")
if nonce == "" {
http.Error(w, "missing nonce", http.StatusBadRequest)
return
}
tokenStr, accountID, ok := u.consumeTokenDownload(nonce)
if !ok {
http.Error(w, "download link expired or already used", http.StatusGone)
return
}
filename := "service-account-" + accountID + ".token"
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
// Security: Content-Type is text/plain and Content-Disposition is attachment,
// so the browser will save the file rather than render it, mitigating XSS risk.
_, _ = fmt.Fprint(w, tokenStr) //nolint:gosec // G705: token served as attachment, not rendered by browser
}
// handleGrantTokenDelegate adds a delegate who may issue tokens for a system
// account. Only admins may call this endpoint.
//
// Security: the target system account and grantee are looked up by UUID so the
// URL/form fields cannot reference arbitrary row IDs. Audit event
// EventTokenDelegateGranted is recorded on success.
func (u *UIServer) handleGrantTokenDelegate(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "service account not found")
return
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "token issue delegates are only supported for system accounts")
return
}
granteeUUID := strings.TrimSpace(r.FormValue("grantee_uuid"))
if granteeUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
return
}
grantee, err := u.db.GetAccountByUUID(granteeUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
return
}
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
if err := u.db.GrantTokenIssueAccess(acct.ID, grantee.ID, actorID); err != nil {
u.logger.Error("grant token issue access", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to grant access")
return
}
u.writeAudit(r, model.EventTokenDelegateGranted, actorID, &acct.ID,
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
if err != nil {
u.logger.Warn("list token issue delegates after grant", "error", err)
}
allAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for delegate grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "token_delegates", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
TokenDelegates: delegates,
DelegatableAccounts: allAccounts,
})
}
// handleRevokeTokenDelegate removes a delegate's permission to issue tokens for
// a system account. Only admins may call this endpoint.
//
// Security: grantee looked up by UUID from the URL path. Audit event
// EventTokenDelegateRevoked recorded on success.
func (u *UIServer) handleRevokeTokenDelegate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "service account not found")
return
}
granteeUUID := r.PathValue("grantee")
grantee, err := u.db.GetAccountByUUID(granteeUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "grantee not found")
return
}
if err := u.db.RevokeTokenIssueAccess(acct.ID, grantee.ID); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access")
return
}
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventTokenDelegateRevoked, actorID, &acct.ID,
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
if err != nil {
u.logger.Warn("list token issue delegates after revoke", "error", err)
}
allAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for delegate dropdown", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "token_delegates", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
TokenDelegates: delegates,
DelegatableAccounts: allAccounts,
})
}
// handleServiceAccountsPage renders the /service-accounts page showing all
// system accounts the current user has delegate access to, along with the
// ability to issue and download tokens for them.
func (u *UIServer) handleServiceAccountsPage(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.redirectToLogin(w, r)
return
}
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "could not resolve actor")
return
}
accounts, err := u.db.ListDelegatedServiceAccounts(actor.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load service accounts")
return
}
u.render(w, "service_accounts", ServiceAccountsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Accounts: accounts,
})
}

View File

@@ -86,7 +86,7 @@ func (u *UIServer) handleAuditDetail(w http.ResponseWriter, r *http.Request) {
}
u.render(w, "audit_detail", AuditDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Event: event,
})
}
@@ -116,7 +116,7 @@ func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (
}
return AuditData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Events: events,
EventTypes: auditEventTypes,
FilterType: filterType,

View File

@@ -1,9 +1,9 @@
package ui
import (
"fmt"
"net/http"
"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"
@@ -13,7 +13,9 @@ import (
// handleLoginPage renders the login form.
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{})
u.render(w, "login", LoginData{
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
@@ -59,7 +61,7 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, nil, nil,
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
audit.JSON("username", username, "reason", "unknown_user"))
u.render(w, "login", LoginData{Error: "invalid credentials"})
return
}
@@ -80,7 +82,9 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if locked {
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
u.render(w, "login", LoginData{Error: "account temporarily locked, please try again later"})
// Security: return the same "invalid credentials" as wrong-password
// to prevent user-enumeration via lockout differentiation (SEC-02).
u.render(w, "login", LoginData{Error: "invalid credentials"})
return
}
@@ -130,7 +134,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
accountID, ok := u.consumeTOTPNonce(nonce)
if !ok {
u.writeAudit(r, model.EventLoginFail, nil, nil,
fmt.Sprintf(`{"username":%q,"reason":"invalid_totp_nonce"}`, username))
audit.JSON("username", username, "reason", "invalid_totp_nonce"))
u.render(w, "login", LoginData{Error: "session expired, please log in again"})
return
}
@@ -143,7 +147,12 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
}
// Decrypt and validate TOTP secret.
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
masterKey, err := u.vault.MasterKey()
if err != nil {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
if err != nil {
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
u.render(w, "login", LoginData{Error: "internal error"})
@@ -206,7 +215,12 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
// Login succeeded: clear any outstanding failure counter.
_ = u.db.ClearLoginFailures(acct.ID)
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
privKey, err := u.vault.PrivKey()
if err != nil {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
tokenStr, claims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
u.logger.Error("issue token", "error", err)
u.render(w, "login", LoginData{Error: "internal error"})
@@ -238,7 +252,7 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI))
audit.JSON("jti", claims.JTI, "via", "ui"))
// Redirect to dashboard.
if isHTMX(r) {
@@ -253,13 +267,14 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err == nil && cookie.Value != "" {
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
pubKey, _ := u.vault.PubKey()
claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
if err == nil {
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
}
u.writeAudit(r, model.EventTokenRevoked, nil, nil,
fmt.Sprintf(`{"jti":%q,"reason":"ui_logout"}`, claims.JTI))
audit.JSON("jti", claims.JTI, "reason", "ui_logout"))
}
}
u.clearSessionCookie(w)
@@ -277,12 +292,33 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
// handleProfilePage renders the profile page for the currently logged-in user.
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "profile", ProfileData{
claims := claimsFromContext(r.Context())
data := ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
})
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
DeletePrefix: "/profile/webauthn",
}
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
@@ -393,6 +429,7 @@ func (u *UIServer) handleSelfChangePassword(w http.ResponseWriter, r *http.Reque
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
Flash: "Password updated successfully. Other active sessions have been revoked.",
},
})

View File

@@ -7,7 +7,8 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// handleDashboard renders the main dashboard page with account counts and recent events.
// handleDashboard renders the main dashboard page. Admin users see account
// counts and recent audit events; non-admin users see a welcome page.
func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
@@ -16,30 +17,33 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
return
}
accounts, err := u.db.ListAccounts()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
return
admin := isAdmin(r)
data := DashboardData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: admin},
}
var total, active int
for _, a := range accounts {
total++
if a.Status == model.AccountStatusActive {
active++
if admin {
accounts, err := u.db.ListAccounts()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
return
}
for _, a := range accounts {
data.TotalAccounts++
if a.Status == model.AccountStatusActive {
data.ActiveAccounts++
}
}
events, _, err := u.db.ListAuditEventsPaged(db.AuditQueryParams{Limit: 10, Offset: 0})
if err != nil {
u.logger.Warn("load recent audit events", "error", err)
events = nil
}
data.RecentEvents = events
}
events, _, err := u.db.ListAuditEventsPaged(db.AuditQueryParams{Limit: 10, Offset: 0})
if err != nil {
u.logger.Warn("load recent audit events", "error", err)
events = nil
}
u.render(w, "dashboard", DashboardData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
TotalAccounts: total,
ActiveAccounts: active,
RecentEvents: events,
})
u.render(w, "dashboard", data)
}

View File

@@ -61,7 +61,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
}
data := PoliciesData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Rules: views,
AllActions: allActionStrings,
}
@@ -129,46 +129,69 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
priority = p
}
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
}
var ruleJSON []byte
body := policy.RuleBody{
Effect: policy.Effect(effectStr),
}
// 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)
if rawJSON := strings.TrimSpace(r.FormValue("rule_json")); rawJSON != "" {
// 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 {
u.renderError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid rule JSON: %v", err))
return
}
if body.Effect != policy.Allow && body.Effect != policy.Deny {
u.renderError(w, r, http.StatusBadRequest, "rule JSON must include effect 'allow' or 'deny'")
return
}
var err error
ruleJSON, err = json.Marshal(body)
if err != nil {
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)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
body := policy.RuleBody{
Effect: policy.Effect(effectStr),
}
// 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.

View File

@@ -0,0 +1,288 @@
package ui
import (
"encoding/base32"
"encoding/base64"
"fmt"
"html/template"
"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 := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: trusted server-generated data URI
// 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 := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: trusted server-generated data URI
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,81 @@
// UI handlers for vault unseal page.
package ui
import (
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
// UnsealData is the view model for the unseal page.
type UnsealData struct {
Error string
}
// handleUnsealPage renders the unseal form, or redirects to login if already unsealed.
func (u *UIServer) handleUnsealPage(w http.ResponseWriter, r *http.Request) {
if !u.vault.IsSealed() {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
u.render(w, "unseal", UnsealData{})
}
// handleUnsealPost processes the unseal form submission.
//
// Security: The passphrase is never logged. No CSRF protection is applied
// because there is no session to protect (the vault is sealed), and CSRF
// token generation depends on the master key (chicken-and-egg).
func (u *UIServer) handleUnsealPost(w http.ResponseWriter, r *http.Request) {
if !u.vault.IsSealed() {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.render(w, "unseal", UnsealData{Error: "invalid form data"})
return
}
passphrase := r.FormValue("passphrase")
if passphrase == "" {
u.render(w, "unseal", UnsealData{Error: "passphrase is required"})
return
}
// Derive master key from passphrase.
masterKey, err := vault.DeriveFromPassphrase(passphrase, u.db)
if err != nil {
u.logger.Error("vault unseal (UI): derive key", "error", err)
u.render(w, "unseal", UnsealData{Error: "unseal failed"})
return
}
// Decrypt the signing key.
privKey, pubKey, err := vault.DecryptSigningKey(u.db, masterKey)
if err != nil {
// Zero derived master key on failure.
for i := range masterKey {
masterKey[i] = 0
}
u.logger.Error("vault unseal (UI): decrypt signing key", "error", err)
u.render(w, "unseal", UnsealData{Error: "unseal failed"})
return
}
if err := u.vault.Unseal(masterKey, privKey, pubKey); err != nil {
u.logger.Error("vault unseal (UI): state transition", "error", err)
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ip := middleware.ClientIP(r, nil)
u.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "ui", "ip", ip))
u.logger.Info("vault unsealed via UI", "ip", ip)
http.Redirect(w, r, "/login", http.StatusFound)
}

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

@@ -2,6 +2,7 @@ package ui
import (
"crypto/ed25519"
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/token"
@@ -16,5 +17,9 @@ func validateSessionToken(pubKey ed25519.PublicKey, tokenStr, issuer string) (*t
// issueToken is a convenience method for issuing a signed JWT.
func (u *UIServer) issueToken(subject string, roles []string, expiry time.Duration) (string, *token.Claims, error) {
return token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
privKey, err := u.vault.PrivKey()
if err != nil {
return "", nil, fmt.Errorf("vault sealed: %w", err)
}
return token.IssueToken(privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
}

View File

@@ -14,7 +14,6 @@ package ui
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -24,6 +23,7 @@ import (
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
@@ -32,6 +32,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/kyle/mcias/web"
)
@@ -53,17 +54,32 @@ type pendingLogin struct {
accountID int64
}
// tokenDownload is a short-lived record that holds a just-issued service token
// string so the operator can download it as a file. It is single-use and
// expires after tokenDownloadTTL.
//
// Security: the token string is stored only for tokenDownloadTTL after
// issuance. The nonce is random (128 bits) and single-use: it is deleted from
// the map on first retrieval so it cannot be replayed.
type tokenDownload struct {
expiresAt time.Time
token string
accountID string // service account UUID (for the filename)
}
const tokenDownloadTTL = 5 * time.Minute
// UIServer serves the HTMX-based management UI.
type UIServer struct {
pendingLogins sync.Map // nonce (string) → *pendingLogin
tmpls map[string]*template.Template // page name → template set
db *db.DB
cfg *config.Config
logger *slog.Logger
csrf *CSRFManager
pubKey ed25519.PublicKey
privKey ed25519.PrivateKey
masterKey []byte
tmpls map[string]*template.Template // page name → template set
db *db.DB
cfg *config.Config
logger *slog.Logger
csrf *CSRFManager
vault *vault.Vault
pendingLogins sync.Map // nonce (string) → *pendingLogin
tokenDownloads sync.Map // nonce (string) → *tokenDownload
pendingTOTPEnrolls sync.Map // nonce (string) → *pendingTOTPEnroll
}
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
@@ -98,6 +114,48 @@ func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
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
// verification when an account is unknown or inactive (F-07).
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
@@ -107,8 +165,12 @@ func (u *UIServer) dummyHash() string {
// New constructs a UIServer, parses all templates, and returns it.
// Returns an error if template parsing fails.
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
csrf := newCSRFManager(masterKey)
//
// The CSRFManager is created lazily from vault key material when the vault
// is unsealed. When sealed, CSRF operations fail, but the sealed middleware
// prevents reaching CSRF-protected routes (chicken-and-egg resolution).
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) (*UIServer, error) {
csrf := newCSRFManagerFromVault(v)
funcMap := template.FuncMap{
"formatTime": func(t time.Time) string {
@@ -158,6 +220,13 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
}
return *actorID == *cred.OwnerID
},
// derefTime dereferences a *time.Time, returning the zero time for nil.
"derefTime": func(p *time.Time) time.Time {
if p == nil {
return time.Time{}
}
return *p
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"gt": func(a, b int) bool { return a > b },
@@ -193,6 +262,11 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"templates/fragments/policy_form.html",
"templates/fragments/password_reset_form.html",
"templates/fragments/password_change_form.html",
"templates/fragments/token_delegates.html",
"templates/fragments/webauthn_credentials.html",
"templates/fragments/webauthn_enroll.html",
"templates/fragments/totp_section.html",
"templates/fragments/totp_enroll_qr.html",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
@@ -202,15 +276,17 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
// Each page template defines "content" and "title" blocks; parsing them
// into separate clones prevents the last-defined block from winning.
pageFiles := map[string]string{
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
"accounts": "templates/accounts.html",
"account_detail": "templates/account_detail.html",
"audit": "templates/audit.html",
"audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.html",
"profile": "templates/profile.html",
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
"accounts": "templates/accounts.html",
"account_detail": "templates/account_detail.html",
"audit": "templates/audit.html",
"audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.html",
"profile": "templates/profile.html",
"unseal": "templates/unseal.html",
"service_accounts": "templates/service_accounts.html",
}
tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range pageFiles {
@@ -225,14 +301,12 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
}
srv := &UIServer{
db: database,
cfg: cfg,
pubKey: pub,
privKey: priv,
masterKey: masterKey,
logger: logger,
csrf: csrf,
tmpls: tmpls,
db: database,
cfg: cfg,
vault: v,
logger: logger,
csrf: csrf,
tmpls: tmpls,
}
// Security (DEF-02): launch a background goroutine to evict expired TOTP
@@ -240,6 +314,8 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
// entries abandoned by users who never complete step 2 would otherwise
// accumulate indefinitely, enabling a memory-exhaustion attack.
go srv.cleanupPendingLogins()
go srv.cleanupTokenDownloads()
go srv.cleanupPendingTOTPEnrolls()
return srv, nil
}
@@ -262,6 +338,72 @@ func (u *UIServer) cleanupPendingLogins() {
}
}
// storeTokenDownload saves a just-issued token string in the short-lived
// download store and returns a random single-use nonce the caller can include
// in the response. The download nonce expires after tokenDownloadTTL.
func (u *UIServer) storeTokenDownload(tokenStr, accountID string) (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("ui: generate download nonce: %w", err)
}
nonce := hex.EncodeToString(raw)
u.tokenDownloads.Store(nonce, &tokenDownload{
token: tokenStr,
accountID: accountID,
expiresAt: time.Now().Add(tokenDownloadTTL),
})
return nonce, nil
}
// consumeTokenDownload looks up, validates, and deletes the download nonce.
// Returns the token string and account UUID, or ("", "", false) if the nonce
// is unknown or expired.
//
// Security: single-use deletion prevents replay; expiry bounds the window.
func (u *UIServer) consumeTokenDownload(nonce string) (tokenStr, accountID string, ok bool) {
v, loaded := u.tokenDownloads.LoadAndDelete(nonce)
if !loaded {
return "", "", false
}
td, valid := v.(*tokenDownload)
if !valid || time.Now().After(td.expiresAt) {
return "", "", false
}
return td.token, td.accountID, true
}
// cleanupTokenDownloads periodically evicts expired entries from tokenDownloads.
func (u *UIServer) cleanupTokenDownloads() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
u.tokenDownloads.Range(func(key, value any) bool {
td, ok := value.(*tokenDownload)
if !ok || now.After(td.expiresAt) {
u.tokenDownloads.Delete(key)
}
return true
})
}
}
// 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.
// All UI responses (pages, fragments, redirects, static assets) carry the
// headers added by securityHeaders.
@@ -275,7 +417,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
if err != nil {
panic(fmt.Sprintf("ui: static sub-FS: %v", err))
}
uiMux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// Security (SEC-07): wrap the file server to suppress directory listings.
// Without this, GET /static/ returns an index of all static assets,
// revealing framework details to an attacker.
uiMux.Handle("GET /static/", http.StripPrefix("/static/", noDirListing(http.FileServerFS(staticSubFS))))
// Redirect root to login.
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
@@ -295,21 +440,34 @@ func (u *UIServer) Register(mux *http.ServeMux) {
}
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
// Vault unseal routes (no session required, no CSRF — vault is sealed).
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
uiMux.HandleFunc("GET /unseal", u.handleUnsealPage)
uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost)))
// Auth routes (no session required).
uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
uiMux.HandleFunc("POST /logout", u.handleLogout)
// WebAuthn login routes (public, rate-limited).
uiMux.Handle("POST /login/webauthn/begin", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginBegin)))
uiMux.Handle("POST /login/webauthn/finish", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginFinish)))
// Protected routes.
auth := u.requireCookieAuth
//
// Security: three distinct access levels:
// - authed: any valid session cookie (authenticated user)
// - admin: authed + admin role in JWT claims (mutating admin ops)
// - adminGet: authed + admin role (read-only admin pages, no CSRF)
authed := u.requireCookieAuth
admin := func(h http.HandlerFunc) http.Handler {
return auth(u.requireCSRF(http.HandlerFunc(h)))
return authed(u.requireAdminRole(u.requireCSRF(http.HandlerFunc(h))))
}
adminGet := func(h http.HandlerFunc) http.Handler {
return auth(http.HandlerFunc(h))
return authed(u.requireAdminRole(http.HandlerFunc(h)))
}
uiMux.Handle("GET /dashboard", adminGet(u.handleDashboard))
uiMux.Handle("GET /dashboard", authed(http.HandlerFunc(u.handleDashboard)))
uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList))
uiMux.Handle("POST /accounts", admin(u.handleCreateAccount))
uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
@@ -318,7 +476,14 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
// Token issuance is accessible to both admins and delegates; the handler
// enforces the admin-or-delegate check internally.
uiMux.Handle("POST /accounts/{id}/token", authed(u.requireCSRF(http.HandlerFunc(u.handleIssueSystemToken))))
// Token download uses a one-time nonce issued at token-issuance time.
uiMux.Handle("GET /token/download/{nonce}", authed(http.HandlerFunc(u.handleDownloadToken)))
// Token issue delegate management — admin only.
uiMux.Handle("POST /accounts/{id}/token/delegates", admin(u.handleGrantTokenDelegate))
uiMux.Handle("DELETE /accounts/{id}/token/delegates/{grantee}", admin(u.handleRevokeTokenDelegate))
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
@@ -334,9 +499,24 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
// Service accounts page — accessible to any authenticated user; shows only
// the service accounts for which the current user is a token-issue delegate.
uiMux.Handle("GET /service-accounts", authed(http.HandlerFunc(u.handleServiceAccountsPage)))
// Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", adminGet(u.handleProfilePage))
uiMux.Handle("PUT /profile/password", auth(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
// WebAuthn profile routes (enrollment and management).
uiMux.Handle("POST /profile/webauthn/begin", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnBegin))))
uiMux.Handle("POST /profile/webauthn/finish", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnFinish))))
uiMux.Handle("DELETE /profile/webauthn/{id}", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnDelete))))
// 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
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -356,7 +536,12 @@ func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
return
}
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
pubKey, err := u.vault.PubKey()
if err != nil {
u.redirectToLogin(w, r)
return
}
claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
if err != nil {
u.clearSessionCookie(w)
u.redirectToLogin(w, r)
@@ -405,6 +590,25 @@ func (u *UIServer) requireCSRF(next http.Handler) http.Handler {
})
}
// requireAdminRole checks that the authenticated user holds the "admin" role.
// Must be placed after requireCookieAuth in the middleware chain so that
// claims are available in the context.
//
// Security: This is the authoritative server-side check that prevents
// non-admin users from accessing admin-only UI endpoints. The JWT claims
// are populated from the database at login/renewal and signed with the
// server's Ed25519 private key, so they cannot be forged client-side.
func (u *UIServer) requireAdminRole(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := claimsFromContext(r.Context())
if claims == nil || !claims.HasRole("admin") {
u.renderError(w, r, http.StatusForbidden, "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
// ---- Helpers ----
// isHTMX reports whether the request was initiated by HTMX.
@@ -506,6 +710,21 @@ func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status in
// Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
const maxFormBytes = 1 << 20
// noDirListing wraps an http.Handler (typically http.FileServerFS) to return
// 404 for directory requests instead of an auto-generated directory index.
//
// Security (SEC-07): directory listings expose the names of all static assets,
// leaking framework and version information to attackers.
func noDirListing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") || r.URL.Path == "" {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// securityHeaders returns middleware that adds defensive HTTP headers to every
// UI response.
//
@@ -521,6 +740,9 @@ const maxFormBytes = 1 << 20
// requests to this origin for two years, preventing TLS-strip on revisit.
// - Referrer-Policy: suppresses the Referer header on outbound navigations so
// JWTs or session identifiers embedded in URLs are not leaked to third parties.
// - Permissions-Policy: disables browser features (camera, microphone,
// geolocation, payment) that this application does not use, reducing the
// attack surface if a content-injection vulnerability is exploited.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
@@ -530,6 +752,7 @@ func securityHeaders(next http.Handler) http.Handler {
h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
next.ServeHTTP(w, r)
})
}
@@ -545,6 +768,13 @@ func (u *UIServer) clientIP(r *http.Request) string {
return middleware.ClientIP(r, proxyIP)
}
// isAdmin reports whether the authenticated user holds the "admin" role.
// Returns false if claims are absent.
func isAdmin(r *http.Request) bool {
claims := claimsFromContext(r.Context())
return claims != nil && claims.HasRole("admin")
}
// actorName resolves the username of the currently authenticated user from the
// request context. Returns an empty string if claims are absent or the account
// cannot be found; callers should treat an empty string as "not logged in".
@@ -570,6 +800,10 @@ type PageData struct {
// ActorName is the username of the currently logged-in user, populated by
// handlers so the base template can display it in the navigation bar.
ActorName string
// IsAdmin is true when the logged-in user holds the "admin" role.
// Used by the base template to conditionally render admin-only navigation
// links (SEC-09: non-admin users must not see links they cannot access).
IsAdmin bool
}
// LoginData is the view model for the login page.
@@ -580,6 +814,8 @@ type LoginData struct {
// a short-lived server-side nonce is issued after successful password
// verification, and only the nonce is embedded in the TOTP step form.
Nonce string // single-use server-side nonce replacing the password hidden field
// WebAuthnEnabled indicates whether the passkey login button should appear.
WebAuthnEnabled bool
}
// DashboardData is the view model for the dashboard page.
@@ -597,7 +833,7 @@ type AccountsData struct {
}
// AccountDetailData is the view model for the account detail page.
type AccountDetailData struct {
type AccountDetailData struct { //nolint:govet // fieldalignment: readability over alignment for view model
Account *model.Account
// PGCred is nil if none stored or the account is not a system account.
PGCred *model.PGCredential
@@ -609,11 +845,43 @@ type AccountDetailData struct {
// ActorID is the DB id of the currently logged-in user; used in templates
// to decide whether to show the owner-only management controls.
ActorID *int64
// TokenDelegates lists accounts that may issue tokens for this service account.
// Only populated for system accounts when viewed by an admin.
TokenDelegates []*model.ServiceAccountDelegate
// DelegatableAccounts is the list of human accounts available for the
// "add delegate" dropdown. Only populated for admins.
DelegatableAccounts []*model.Account
// DownloadNonce is a one-time nonce for downloading the just-issued token.
// Populated by handleIssueSystemToken; empty otherwise.
DownloadNonce string
PageData
Roles []string
AllRoles []string
Tags []string
Tokens []*model.TokenRecord
// WebAuthnCreds lists the WebAuthn credentials for this account (metadata only).
WebAuthnCreds []*model.WebAuthnCredential
// DeletePrefix is the URL prefix for WebAuthn credential delete buttons.
DeletePrefix string
// CanIssueToken is true when the viewing actor may issue tokens for this
// system account (admin role or explicit delegate grant).
// Placed last to minimise GC scan area.
CanIssueToken bool
WebAuthnEnabled bool
}
// ServiceAccountsData is the view model for the /service-accounts page.
// It shows the system accounts for which the current user has delegate access,
// plus the just-issued token download nonce (if a token was just issued).
type ServiceAccountsData struct {
// Accounts is the list of system accounts the actor may issue tokens for.
Accounts []*model.Account
// DownloadNonce is a one-time nonce for downloading the just-issued token.
// Non-empty immediately after a successful token issuance.
DownloadNonce string
// IssuedFor is the UUID of the account whose token was just issued.
IssuedFor string
PageData
}
// AuditData is the view model for the audit log page.
@@ -656,8 +924,18 @@ type PoliciesData struct {
}
// ProfileData is the view model for the profile/settings page.
type ProfileData struct {
type ProfileData struct { //nolint:govet // fieldalignment: readability over alignment for view model
PageData
WebAuthnCreds []*model.WebAuthnCredential
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
WebAuthnEnabled bool
// TOTP enrollment fields (populated only during enrollment flow).
TOTPEnabled bool
TOTPSecret string // base32-encoded; shown once during enrollment
TOTPQR template.URL // data:image/png;base64,... QR code; template.URL bypasses URL escaping
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.

View File

@@ -13,10 +13,11 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
const testIssuer = "https://auth.example.com"
@@ -47,7 +48,8 @@ func newTestUIServer(t *testing.T) *UIServer {
cfg := config.NewTestConfig(testIssuer)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
uiSrv, err := New(database, cfg, priv, pub, masterKey, logger)
v := vault.NewUnsealed(masterKey, priv, pub)
uiSrv, err := New(database, cfg, v, logger)
if err != nil {
t.Fatalf("new UIServer: %v", err)
}
@@ -79,6 +81,7 @@ func assertSecurityHeaders(t *testing.T, h http.Header, label string) {
{"X-Frame-Options", "DENY"},
{"Strict-Transport-Security", "max-age="},
{"Referrer-Policy", "no-referrer"},
{"Permissions-Policy", "camera=()"},
}
for _, c := range checks {
val := h.Get(c.header)
@@ -317,7 +320,7 @@ func issueAdminSession(t *testing.T, u *UIServer) (tokenStr, accountUUID string,
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
t.Fatalf("SetRoles: %v", err)
}
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
tok, claims, err := u.issueToken(acct.UUID, []string{"admin"}, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
@@ -355,6 +358,34 @@ func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Requ
return req
}
// TestStaticDirectoryListingDisabled verifies that GET /static/ returns 404
// instead of a directory listing (SEC-07).
func TestStaticDirectoryListingDisabled(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/static/", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("GET /static/ status = %d, want %d (directory listing must be disabled)", rr.Code, http.StatusNotFound)
}
}
// TestStaticFileStillServed verifies that individual static files are still
// served normally after the directory listing fix (SEC-07).
func TestStaticFileStillServed(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("GET /static/style.css status = %d, want %d", rr.Code, http.StatusOK)
}
}
// TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
// endpoint returns 400 when the target account is a human (not system) account.
func TestSetPGCredsRejectsHumanAccount(t *testing.T) {
@@ -527,3 +558,195 @@ func TestAccountDetailShowsPGCredsSection(t *testing.T) {
t.Error("human account detail page must not include pgcreds-section")
}
}
// TestLoginLockedAccountShowsInvalidCredentials verifies that a locked-out
// account gets the same "invalid credentials" error as a wrong-password
// attempt in the UI login form, preventing user-enumeration via lockout
// differentiation (SEC-02).
func TestLoginLockedAccountShowsInvalidCredentials(t *testing.T) {
u := newTestUIServer(t)
// Create an account with a known password.
hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4})
if err != nil {
t.Fatalf("hash password: %v", err)
}
acct, err := u.db.CreateAccount("lockuiuser", model.AccountTypeHuman, hash)
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
for range db.LockoutThreshold {
if err := u.db.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
locked, err := u.db.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
mux := http.NewServeMux()
u.Register(mux)
// POST login for the locked account.
form := url.Values{}
form.Set("username", "lockuiuser")
form.Set("password", "testpass123")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
lockedRR := httptest.NewRecorder()
mux.ServeHTTP(lockedRR, req)
// POST login with wrong password for comparison.
form2 := url.Values{}
form2.Set("username", "lockuiuser")
form2.Set("password", "wrongpassword")
req2 := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form2.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
wrongRR := httptest.NewRecorder()
mux.ServeHTTP(wrongRR, req2)
lockedBody := lockedRR.Body.String()
wrongBody := wrongRR.Body.String()
// Neither response should mention "locked" or "try again".
if strings.Contains(lockedBody, "locked") || strings.Contains(lockedBody, "try again") {
t.Error("locked account response leaks lockout state")
}
// Both must contain "invalid credentials".
if !strings.Contains(lockedBody, "invalid credentials") {
t.Error("locked account response does not contain 'invalid credentials'")
}
if !strings.Contains(wrongBody, "invalid credentials") {
t.Error("wrong password response does not contain 'invalid credentials'")
}
}
// ---- SEC-09: admin nav link visibility tests ----
// issueUserSession creates a human account with the "user" role (non-admin),
// issues a JWT, tracks it, and returns the raw token string.
func issueUserSession(t *testing.T, u *UIServer) string {
t.Helper()
acct, err := u.db.CreateAccount("regular-user", model.AccountTypeHuman, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil {
t.Fatalf("SetRoles: %v", err)
}
tok, claims, err := u.issueToken(acct.UUID, []string{"user"}, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
return tok
}
// TestNonAdminDashboardHidesAdminNavLinks verifies that a non-admin user's
// dashboard does not contain links to admin-only pages (SEC-09).
func TestNonAdminDashboardHidesAdminNavLinks(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
userToken := issueUserSession(t, u)
req := authenticatedGET(t, userToken, "/dashboard")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, adminPath := range []string{
`href="/accounts"`,
`href="/audit"`,
`href="/policies"`,
`href="/pgcreds"`,
} {
if strings.Contains(body, adminPath) {
t.Errorf("non-admin dashboard contains admin link %s — SEC-09 violation", adminPath)
}
}
// Dashboard link should still be present.
if !strings.Contains(body, `href="/dashboard"`) {
t.Error("dashboard link missing from non-admin nav")
}
}
// TestAdminDashboardShowsAdminNavLinks verifies that an admin user's
// dashboard contains all admin navigation links.
func TestAdminDashboardShowsAdminNavLinks(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
adminToken, _, _ := issueAdminSession(t, u)
req := authenticatedGET(t, adminToken, "/dashboard")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, adminPath := range []string{
`href="/accounts"`,
`href="/audit"`,
`href="/policies"`,
`href="/pgcreds"`,
} {
if !strings.Contains(body, adminPath) {
t.Errorf("admin dashboard missing admin link %s", adminPath)
}
}
}
// TestNonAdminProfileHidesAdminNavLinks verifies that the profile page
// also hides admin nav links for non-admin users (SEC-09).
func TestNonAdminProfileHidesAdminNavLinks(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
userToken := issueUserSession(t, u)
req := authenticatedGET(t, userToken, "/profile")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, adminPath := range []string{
`href="/accounts"`,
`href="/audit"`,
`href="/policies"`,
`href="/pgcreds"`,
} {
if strings.Contains(body, adminPath) {
t.Errorf("non-admin profile page contains admin link %s — SEC-09 violation", adminPath)
}
}
}

View File

@@ -45,11 +45,22 @@ func Username(username string) error {
// password.
const MinPasswordLen = 12
// Password returns nil if the plaintext password meets the minimum length
// requirement, or a descriptive error if not.
// MaxPasswordLen is the maximum acceptable plaintext password length.
//
// Security (SEC-05): Argon2id processes the full password input. Without
// an upper bound an attacker could submit a multi-megabyte password and
// force expensive hashing. 128 characters is generous for any real
// password or passphrase while capping the cost.
const MaxPasswordLen = 128
// Password returns nil if the plaintext password meets the length
// requirements, or a descriptive error if not.
func Password(password string) error {
if len(password) < MinPasswordLen {
return fmt.Errorf("password must be at least %d characters", MinPasswordLen)
}
if len(password) > MaxPasswordLen {
return fmt.Errorf("password must be at most %d characters", MaxPasswordLen)
}
return nil
}

View File

@@ -32,6 +32,17 @@ func TestPasswordTooShort(t *testing.T) {
}
}
func TestPasswordTooLong(t *testing.T) {
// Exactly MaxPasswordLen should be accepted.
if err := Password(strings.Repeat("a", MaxPasswordLen)); err != nil {
t.Errorf("Password(len=%d) = %v, want nil", MaxPasswordLen, err)
}
// One over the limit should be rejected.
if err := Password(strings.Repeat("a", MaxPasswordLen+1)); err == nil {
t.Errorf("Password(len=%d) = nil, want error", MaxPasswordLen+1)
}
}
func TestUsernameValid(t *testing.T) {
valid := []string{
"alice",

67
internal/vault/derive.go Normal file
View File

@@ -0,0 +1,67 @@
package vault
import (
"crypto/ed25519"
"errors"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
// DeriveFromPassphrase derives the master encryption key from a passphrase
// using the Argon2id KDF with a salt stored in the database.
//
// Security: The Argon2id parameters used by crypto.DeriveKey exceed OWASP 2023
// minimums (time=3, memory=128MiB, threads=4). The salt is 32 random bytes
// stored in the database on first run.
func DeriveFromPassphrase(passphrase string, database *db.DB) ([]byte, error) {
salt, err := database.ReadMasterKeySalt()
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("no master key salt in database (first-run requires startup passphrase)")
}
if err != nil {
return nil, fmt.Errorf("read master key salt: %w", err)
}
key, err := crypto.DeriveKey(passphrase, salt)
if err != nil {
return nil, fmt.Errorf("derive master key: %w", err)
}
return key, nil
}
// DecryptSigningKey decrypts the Ed25519 signing key pair from the database
// using the provided master key.
//
// Security: The private key is stored AES-256-GCM encrypted in the database.
// A fresh random nonce is used for each encryption. The plaintext key only
// exists in memory during the process lifetime.
func DecryptSigningKey(database *db.DB, masterKey []byte) (ed25519.PrivateKey, ed25519.PublicKey, error) {
enc, nonce, err := database.ReadServerConfig()
if err != nil {
return nil, nil, fmt.Errorf("read server config: %w", err)
}
if enc == nil || nonce == nil {
return nil, nil, fmt.Errorf("no signing key in database (first-run requires startup passphrase)")
}
privPEM, err := crypto.OpenAESGCM(masterKey, nonce, enc)
if err != nil {
return nil, nil, fmt.Errorf("decrypt signing key: %w", err)
}
priv, err := crypto.ParsePrivateKeyPEM(privPEM)
if err != nil {
return nil, nil, fmt.Errorf("parse signing key PEM: %w", err)
}
// Security: ed25519.PrivateKey.Public() always returns ed25519.PublicKey,
// but we use the ok form to make the type assertion explicit and safe.
pub, ok := priv.Public().(ed25519.PublicKey)
if !ok {
return nil, nil, fmt.Errorf("signing key has unexpected public key type")
}
return priv, pub, nil
}

127
internal/vault/vault.go Normal file
View File

@@ -0,0 +1,127 @@
// Package vault provides a thread-safe container for the server's
// cryptographic key material with seal/unseal lifecycle management.
//
// Security design:
// - The Vault holds the master encryption key and Ed25519 signing key pair.
// - All accessors return ErrSealed when the vault is sealed, ensuring that
// callers cannot use key material that has been zeroed.
// - Seal() explicitly zeroes all key material before nilling the slices,
// reducing the window in which secrets remain in memory after seal.
// - All state transitions are protected by sync.RWMutex. Readers (IsSealed,
// MasterKey, PrivKey, PubKey) take a read lock; writers (Seal, Unseal)
// take a write lock.
package vault
import (
"crypto/ed25519"
"errors"
"sync"
)
// ErrSealed is returned by accessor methods when the vault is sealed.
var ErrSealed = errors.New("vault is sealed")
// Vault holds the server's cryptographic key material behind a mutex.
// All three servers (REST, UI, gRPC) share a single Vault by pointer.
type Vault struct {
masterKey []byte
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
mu sync.RWMutex
sealed bool
}
// NewSealed creates a Vault in the sealed state. No key material is held.
func NewSealed() *Vault {
return &Vault{sealed: true}
}
// NewUnsealed creates a Vault in the unsealed state with the given key material.
// This is the backward-compatible path used when the passphrase is available at
// startup.
func NewUnsealed(masterKey []byte, privKey ed25519.PrivateKey, pubKey ed25519.PublicKey) *Vault {
return &Vault{
masterKey: masterKey,
privKey: privKey,
pubKey: pubKey,
sealed: false,
}
}
// IsSealed reports whether the vault is currently sealed.
func (v *Vault) IsSealed() bool {
v.mu.RLock()
defer v.mu.RUnlock()
return v.sealed
}
// MasterKey returns the master encryption key, or ErrSealed if sealed.
func (v *Vault) MasterKey() ([]byte, error) {
v.mu.RLock()
defer v.mu.RUnlock()
if v.sealed {
return nil, ErrSealed
}
return v.masterKey, nil
}
// PrivKey returns the Ed25519 private signing key, or ErrSealed if sealed.
func (v *Vault) PrivKey() (ed25519.PrivateKey, error) {
v.mu.RLock()
defer v.mu.RUnlock()
if v.sealed {
return nil, ErrSealed
}
return v.privKey, nil
}
// PubKey returns the Ed25519 public key, or ErrSealed if sealed.
func (v *Vault) PubKey() (ed25519.PublicKey, error) {
v.mu.RLock()
defer v.mu.RUnlock()
if v.sealed {
return nil, ErrSealed
}
return v.pubKey, nil
}
// Unseal transitions the vault from sealed to unsealed, storing the provided
// key material. Returns an error if the vault is already unsealed.
func (v *Vault) Unseal(masterKey []byte, privKey ed25519.PrivateKey, pubKey ed25519.PublicKey) error {
v.mu.Lock()
defer v.mu.Unlock()
if !v.sealed {
return errors.New("vault is already unsealed")
}
v.masterKey = masterKey
v.privKey = privKey
v.pubKey = pubKey
v.sealed = false
return nil
}
// Seal transitions the vault from unsealed to sealed. All key material is
// zeroed before being released to minimize the window of memory exposure.
//
// Security: explicit zeroing loops ensure the key bytes are overwritten even
// if the garbage collector has not yet reclaimed the backing arrays.
func (v *Vault) Seal() {
v.mu.Lock()
defer v.mu.Unlock()
// Zero master key.
for i := range v.masterKey {
v.masterKey[i] = 0
}
v.masterKey = nil
// Zero private key.
for i := range v.privKey {
v.privKey[i] = 0
}
v.privKey = nil
// Zero public key (not secret, but consistent cleanup).
for i := range v.pubKey {
v.pubKey[i] = 0
}
v.pubKey = nil
v.sealed = true
}

View File

@@ -0,0 +1,150 @@
package vault
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"sync"
"testing"
)
func generateTestKeys(t *testing.T) ([]byte, ed25519.PrivateKey, ed25519.PublicKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
mk := make([]byte, 32)
if _, err := rand.Read(mk); err != nil {
t.Fatalf("generate master key: %v", err)
}
return mk, priv, pub
}
func TestNewSealed(t *testing.T) {
v := NewSealed()
if !v.IsSealed() {
t.Fatal("NewSealed() should be sealed")
}
if _, err := v.MasterKey(); !errors.Is(err, ErrSealed) {
t.Fatalf("MasterKey() error = %v, want ErrSealed", err)
}
if _, err := v.PrivKey(); !errors.Is(err, ErrSealed) {
t.Fatalf("PrivKey() error = %v, want ErrSealed", err)
}
if _, err := v.PubKey(); !errors.Is(err, ErrSealed) {
t.Fatalf("PubKey() error = %v, want ErrSealed", err)
}
}
func TestNewUnsealed(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
if v.IsSealed() {
t.Fatal("NewUnsealed() should not be sealed")
}
gotMK, err := v.MasterKey()
if err != nil {
t.Fatalf("MasterKey() error = %v", err)
}
if len(gotMK) != 32 {
t.Fatalf("MasterKey() len = %d, want 32", len(gotMK))
}
}
func TestUnsealFromSealed(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewSealed()
if err := v.Unseal(mk, priv, pub); err != nil {
t.Fatalf("Unseal() error = %v", err)
}
if v.IsSealed() {
t.Fatal("should be unsealed after Unseal()")
}
gotPriv, err := v.PrivKey()
if err != nil {
t.Fatalf("PrivKey() error = %v", err)
}
if !priv.Equal(gotPriv) {
t.Fatal("PrivKey() mismatch")
}
}
func TestUnsealAlreadyUnsealed(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
if err := v.Unseal(mk, priv, pub); err == nil {
t.Fatal("Unseal() on unsealed vault should return error")
}
}
func TestSealZeroesKeys(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
// Keep references to the backing arrays so we can verify zeroing.
mkRef := mk
privRef := priv
v := NewUnsealed(mk, priv, pub)
v.Seal()
if !v.IsSealed() {
t.Fatal("should be sealed after Seal()")
}
// Verify the original backing arrays were zeroed.
for i, b := range mkRef {
if b != 0 {
t.Fatalf("masterKey[%d] = %d, want 0", i, b)
}
}
for i, b := range privRef {
if b != 0 {
t.Fatalf("privKey[%d] = %d, want 0", i, b)
}
}
}
func TestSealUnsealCycle(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
v.Seal()
mk2, priv2, pub2 := generateTestKeys(t)
if err := v.Unseal(mk2, priv2, pub2); err != nil {
t.Fatalf("Unseal() after Seal() error = %v", err)
}
gotPub, err := v.PubKey()
if err != nil {
t.Fatalf("PubKey() error = %v", err)
}
if !pub2.Equal(gotPub) {
t.Fatal("PubKey() mismatch after re-unseal")
}
}
func TestConcurrentAccess(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
var wg sync.WaitGroup
// Concurrent readers.
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
_ = v.IsSealed()
_, _ = v.MasterKey()
_, _ = v.PrivKey()
_, _ = v.PubKey()
}()
}
// Concurrent seal/unseal cycles.
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
v.Seal()
mk2, priv2, pub2 := generateTestKeys(t)
_ = v.Unseal(mk2, priv2, pub2)
}()
}
wg.Wait()
}

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

@@ -77,7 +77,7 @@ WAL mode and foreign key enforcement are enabled automatically.
Issuer claim embedded in every JWT.
Use the base URL of your MCIAS server.
.It Sy default_expiry
.Pq optional, default 720h
.Pq optional, default 168h
Token expiry for interactive logins.
Go duration string.
.It Sy admin_expiry

View File

@@ -14,8 +14,10 @@ info:
10 requests per second per IP, burst of 10.
servers:
- url: https://auth.example.com:8443
- url: https://mcias.metacircular.net:8443
description: Production
- url: https://localhost:8443
description: Local test server
components:
securitySchemes:
@@ -84,6 +86,54 @@ components:
type: boolean
description: Whether TOTP is enrolled and required for this account.
example: false
webauthn_enabled:
type: boolean
description: Whether at least one WebAuthn credential is registered.
example: false
webauthn_count:
type: integer
description: Number of registered WebAuthn credentials.
example: 0
WebAuthnCredentialInfo:
type: object
required: [id, name, sign_count, discoverable, created_at]
properties:
id:
type: integer
format: int64
description: Database row ID.
example: 1
name:
type: string
description: User-supplied label for the credential.
example: "YubiKey 5"
aaguid:
type: string
description: Authenticator Attestation GUID.
example: "2fc0579f-8113-47ea-b116-bb5a8db9202a"
sign_count:
type: integer
format: uint32
description: Signature counter (used to detect cloned authenticators).
example: 42
discoverable:
type: boolean
description: Whether this is a discoverable (passkey/resident) credential.
example: true
transports:
type: string
description: Comma-separated transport hints (usb, nfc, ble, internal).
example: "usb,nfc"
created_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
last_used_at:
type: string
format: date-time
nullable: true
example: "2026-03-15T14:30:00Z"
AuditEvent:
type: object
@@ -221,8 +271,8 @@ components:
nullable: true
description: |
Time after which the rule is no longer active. NULL means no
constraint (never expires). Rules where `expires_at <= now()` are
skipped during evaluation.
constraint (never expires). Rules where expires_at is in the past
are skipped during evaluation.
example: "2026-06-01T00:00:00Z"
created_at:
type: string
@@ -307,6 +357,18 @@ components:
error: rate limit exceeded
code: rate_limited
VaultSealed:
description: |
The vault is sealed. The server is running but has no key material.
Unseal via `POST /v1/vault/unseal` before retrying.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: vault is sealed
code: vault_sealed
paths:
# ── Public ────────────────────────────────────────────────────────────────
@@ -314,12 +376,17 @@ paths:
/v1/health:
get:
summary: Health check
description: Returns `{"status":"ok"}` if the server is running. No auth required.
description: |
Returns server health status. Always returns HTTP 200, even when the
vault is sealed. No auth required.
When the vault is sealed, `status` is `"sealed"` and most other
endpoints return 503. When healthy, `status` is `"ok"`.
operationId: getHealth
tags: [Public]
responses:
"200":
description: Server is healthy.
description: Server is running (check `status` for sealed state).
content:
application/json:
schema:
@@ -327,6 +394,7 @@ paths:
properties:
status:
type: string
enum: [ok, sealed]
example: ok
/v1/keys/public:
@@ -369,6 +437,121 @@ paths:
description: Base64url-encoded public key bytes.
example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
/v1/vault/status:
get:
summary: Vault seal status
description: |
Returns whether the vault is currently sealed. Always accessible,
even when sealed. No auth required.
Clients should poll this after startup or after a 503 `vault_sealed`
response to determine when to attempt an unseal.
operationId: getVaultStatus
tags: [Public]
responses:
"200":
description: Current vault seal state.
content:
application/json:
schema:
type: object
required: [sealed]
properties:
sealed:
type: boolean
example: false
/v1/vault/unseal:
post:
summary: Unseal the vault
description: |
Provide the master passphrase to derive the encryption key, decrypt
the Ed25519 signing key, and unseal the vault. Once unsealed, all
other endpoints become available.
Rate limited to 3 requests per second per IP (burst 5) to limit
brute-force attempts against the passphrase.
The passphrase is never logged. A generic `"unseal failed"` error
is returned for any failure (wrong passphrase, vault already unsealed
mid-flight, etc.) to avoid leaking information.
operationId: unsealVault
tags: [Public]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [passphrase]
properties:
passphrase:
type: string
description: Master passphrase used to derive the encryption key.
example: correct-horse-battery-staple
responses:
"200":
description: Vault unsealed (or was already unsealed).
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [unsealed, already unsealed]
example: unsealed
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Wrong passphrase or key decryption failure.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: unseal failed
code: unauthorized
"429":
$ref: "#/components/responses/RateLimited"
/v1/vault/seal:
post:
summary: Seal the vault (admin)
description: |
Zero all key material in memory and transition the server to the
sealed state. After this call:
- All subsequent requests (except health, vault status, and unseal)
return 503 `vault_sealed`.
- The caller's own JWT is immediately invalidated because the public
key needed to verify it is no longer held in memory.
- The server can be unsealed again via `POST /v1/vault/unseal`.
This is an emergency operation. Use it to protect key material if a
compromise is suspected. It does **not** restart the server or wipe
the database.
operationId: sealVault
tags: [Admin — Vault]
security:
- bearerAuth: []
responses:
"200":
description: Vault sealed (or was already sealed).
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [sealed, already sealed]
example: sealed
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
/v1/auth/login:
post:
summary: Login
@@ -384,6 +567,12 @@ paths:
If the account has TOTP enrolled, `totp_code` is required.
Omitting it returns HTTP 401 with code `totp_required`.
`service_name` and `tags` identify the calling service. MCIAS
evaluates `auth:login` policy against these values after credentials
are verified. A policy-denied login returns HTTP 403 (not 401) so
callers can distinguish a service access restriction from bad credentials.
Clients should populate these from their `[mcias]` config section.
operationId: login
tags: [Public]
requestBody:
@@ -404,6 +593,21 @@ paths:
type: string
description: Current 6-digit TOTP code. Required if TOTP is enrolled.
example: "123456"
service_name:
type: string
description: >
Name of the calling service. Used by MCIAS to evaluate
auth:login policy rules that target specific services.
example: metatron
tags:
type: array
items:
type: string
description: >
Tags describing the calling service (e.g. "env:restricted").
MCIAS evaluates auth:login policy rules with required_tags
against this list.
example: ["env:restricted"]
responses:
"200":
description: Login successful. Returns JWT and expiry.
@@ -424,6 +628,17 @@ paths:
value: {error: invalid credentials, code: unauthorized}
totp_required:
value: {error: TOTP code required, code: totp_required}
"403":
description: >
Login denied by policy. Credentials were valid but an operator
policy rule blocks this account from accessing the calling service.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
examples:
policy_denied:
value: {error: access denied by policy, code: policy_denied}
"429":
$ref: "#/components/responses/RateLimited"
@@ -473,6 +688,10 @@ paths:
format: uuid
description: Subject (account UUID). Present when valid=true.
example: 550e8400-e29b-41d4-a716-446655440000
username:
type: string
description: Account username. Present when valid=true and the account exists.
example: alice
roles:
type: array
items:
@@ -486,7 +705,7 @@ paths:
example: "2026-04-10T12:34:56Z"
examples:
valid:
value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
value: {valid: true, sub: "550e8400-...", username: alice, roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
invalid:
value: {valid: false}
"429":
@@ -550,6 +769,17 @@ paths:
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
description: Current account password (required to prevent session-theft escalation).
responses:
"200":
description: TOTP secret generated.
@@ -697,6 +927,213 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
# ── WebAuthn ──────────────────────────────────────────────────────────────
/v1/auth/webauthn/register/begin:
post:
summary: Begin WebAuthn registration
description: |
Start a WebAuthn credential registration ceremony. Requires the current
password for re-authentication (same security model as TOTP enrollment).
Returns PublicKeyCredentialCreationOptions for the browser WebAuthn API.
operationId: beginWebAuthnRegister
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
description: Current password for re-authentication.
name:
type: string
description: Optional label for the credential (e.g. "YubiKey 5").
example: "YubiKey 5"
responses:
"200":
description: Registration options for navigator.credentials.create().
content:
application/json:
schema:
type: object
description: PublicKeyCredentialCreationOptions (WebAuthn spec).
"401":
$ref: "#/components/responses/Unauthorized"
"429":
description: Account temporarily locked.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/auth/webauthn/register/finish:
post:
summary: Finish WebAuthn registration
description: |
Complete the WebAuthn credential registration ceremony. The request body
contains the authenticator's response from navigator.credentials.create().
The credential is encrypted at rest with AES-256-GCM.
operationId: finishWebAuthnRegister
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
description: AuthenticatorAttestationResponse (WebAuthn spec).
responses:
"200":
description: Credential registered.
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/webauthn/login/begin:
post:
summary: Begin WebAuthn login
description: |
Start a WebAuthn authentication ceremony. Public RPC — no auth required.
With a username: returns allowCredentials for the account's registered
credentials. Without a username: starts a discoverable (passkey) flow.
operationId: beginWebAuthnLogin
tags: [Public]
requestBody:
content:
application/json:
schema:
type: object
properties:
username:
type: string
description: Optional. If omitted, starts a discoverable (passkey) flow.
example: alice
responses:
"200":
description: Assertion options for navigator.credentials.get().
content:
application/json:
schema:
type: object
description: PublicKeyCredentialRequestOptions (WebAuthn spec).
"429":
$ref: "#/components/responses/RateLimited"
/v1/auth/webauthn/login/finish:
post:
summary: Finish WebAuthn login
description: |
Complete the WebAuthn authentication ceremony. Validates the assertion,
checks the sign counter, and issues a JWT. Public RPC — no auth required.
operationId: finishWebAuthnLogin
tags: [Public]
requestBody:
required: true
content:
application/json:
schema:
type: object
description: AuthenticatorAssertionResponse (WebAuthn spec).
responses:
"200":
description: Login successful.
content:
application/json:
schema:
$ref: "#/components/schemas/TokenResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
/v1/accounts/{id}/webauthn:
get:
summary: List WebAuthn credentials (admin)
description: |
Returns metadata for all WebAuthn credentials registered to an account.
Credential material (IDs, public keys) is never included.
operationId: listWebAuthnCredentials
tags: [Admin — Accounts]
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: Account UUID.
responses:
"200":
description: Credential metadata list.
content:
application/json:
schema:
type: object
properties:
credentials:
type: array
items:
$ref: "#/components/schemas/WebAuthnCredentialInfo"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/webauthn/{credentialId}:
delete:
summary: Remove WebAuthn credential (admin)
description: |
Remove a specific WebAuthn credential from an account. Admin only.
operationId: deleteWebAuthnCredential
tags: [Admin — Accounts]
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: Account UUID.
- name: credentialId
in: path
required: true
schema:
type: integer
format: int64
description: Credential database row ID.
responses:
"204":
description: Credential removed.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/token/issue:
post:
summary: Issue service account token (admin)
@@ -1123,6 +1560,70 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
/v1/pgcreds:
get:
summary: List accessible Postgres credentials
description: |
Return all Postgres credentials accessible to the authenticated account:
credentials owned by the account plus any explicitly granted by an admin.
The `id` field is the credential record ID; use it together with the
`service_account_id` to fetch full details via
`GET /v1/accounts/{id}/pgcreds`. Passwords are **not** returned by this
endpoint.
operationId: listAccessiblePGCreds
tags: [Admin — Credentials]
security:
- bearerAuth: []
responses:
"200":
description: Array of accessible Postgres credential summaries.
content:
application/json:
schema:
type: array
items:
type: object
required: [id, service_account_id, host, port, database, username, created_at, updated_at]
properties:
id:
type: integer
description: Credential record ID.
example: 7
service_account_id:
type: string
format: uuid
description: UUID of the system account that owns these credentials.
example: 550e8400-e29b-41d4-a716-446655440000
service_account_name:
type: string
description: Username of the owning system account (omitted if unavailable).
example: payments-api
host:
type: string
example: db.example.com
port:
type: integer
example: 5432
database:
type: string
example: mydb
username:
type: string
example: myuser
created_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
updated_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
"401":
$ref: "#/components/responses/Unauthorized"
"503":
$ref: "#/components/responses/VaultSealed"
/v1/audit:
get:
summary: Query audit log (admin)
@@ -1137,7 +1638,7 @@ paths:
`pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
`pgcred_access_revoked`, `tag_added`, `tag_removed`,
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
`policy_deny`.
`policy_deny`, `vault_sealed`, `vault_unsealed`.
operationId: listAudit
tags: [Admin — Audit]
security:
@@ -1519,3 +2020,5 @@ tags:
description: Requires admin role.
- name: Admin — Policy
description: Requires admin role. Manage policy rules and account tags.
- name: Admin — Vault
description: Requires admin role. Emergency vault seal operation.

Some files were not shown because too many files have changed in this diff Show More