9 Commits

Author SHA1 Message Date
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
54 changed files with 4833 additions and 329 deletions

View File

@@ -431,11 +431,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
@@ -458,6 +470,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 |
@@ -797,6 +810,8 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
| `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) |
| `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` |
---
@@ -1010,9 +1025,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

171
AUDIT.md
View File

@@ -1,24 +1,153 @@
# MCIAS Security Audit Report
**Date:** 2026-03-14 (updated — all findings remediated)
**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 localhost:8443 — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, headers, and operational security.
**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 has a strong security posture. All findings from 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.
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.
**All findings from this audit have been remediated.** See the remediation table below for details.
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.
---
## Open Findings (PEN-01 through PEN-07)
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.
| 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 |
<details>
<summary>Finding descriptions (click to expand)</summary>
### PEN-01 — `extractBearerFromRequest` Does Not Validate "Bearer " Prefix (Medium)
**File:** `internal/server/server.go` (lines 14141425)
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
// Current (vulnerable):
if len(auth) <= len(prefix) {
return "", fmt.Errorf("malformed Authorization header")
}
return auth[len(prefix):], nil // no prefix check
```
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.
**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.
---
### PEN-02 — Security Headers Missing from Live Instance Responses (Medium)
**Live probe:** `https://mcias.metacircular.net:8443/login`
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
// 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
}
```
**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.
**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.
---
### PEN-07 — Rate Limiter Is Per-IP Only, No Per-Account Limiting (Informational)
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 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.
**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.
</details>
---
## Remediated Findings (SEC-01 through SEC-12)
All findings from this audit have been remediated. The original descriptions are preserved below for reference.
All findings from the SEC audit round have been remediated. The original descriptions are preserved below for reference.
| ID | Severity | Finding | Status |
|----|----------|---------|--------|
@@ -186,9 +315,35 @@ These implementation details are exemplary and should be maintained:
---
## Penetration Test — Attacks That Failed (2026-03-14)
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) |
---
## Remediation Status
**All findings remediated.** No open items remain. Next audit should focus on:
- Any new features added since 2026-03-14
**CRIT/DEF/SEC series:** All 24 findings remediated. No open items.
**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
- Live penetration testing of remediated endpoints
- Re-evaluate PEN-03 if Swagger UI self-hosting becomes desirable

View File

@@ -2,7 +2,86 @@
Source of truth for current development state.
---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only).
### 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

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 \

464
RUNBOOK.md Normal file
View File

@@ -0,0 +1,464 @@
# 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`
---
## 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/
```

View File

@@ -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"`
}

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
//
// rekey
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()
@@ -107,6 +109,8 @@ func main() {
tool.runAudit(subArgs)
case "pgcreds":
tool.runPGCreds(subArgs)
case "rekey":
tool.runRekey(subArgs)
default:
fatalf("unknown command %q; run with no args for usage", command)
}
@@ -259,6 +263,9 @@ 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)
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.")
}

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,30 +73,47 @@ 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)
//
// 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) == "" {
// 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)
}
defer func() {
// Zero the master key when done — reduces the window of exposure.
for i := range masterKey {
masterKey[i] = 0
v = vault.NewSealed()
logger.Info("vault starting in sealed state")
} else {
return fmt.Errorf("load master key: %w", mkErr)
}
}()
} 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.
// Security: HTTPS/gRPC-TLS is mandatory; no plaintext listener is provided.
@@ -108,8 +126,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 +149,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)

51
dist/install.sh vendored
View File

@@ -6,7 +6,7 @@
# 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.
# 3. Creates /srv/mcias/ with correct permissions.
# 4. Installs the systemd service unit.
# 5. Prints post-install instructions.
#
@@ -25,8 +25,7 @@ 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"
@@ -114,23 +113,19 @@ 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.
info "Creating $SRV_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
# 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"
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.
if [ -d "$SYSTEMD_DIR" ]; then
info "Installing systemd service unit to $SYSTEMD_DIR"
@@ -175,26 +170,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/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. 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.
@@ -208,16 +203,16 @@ Next steps:
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

@@ -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"

View File

@@ -1,38 +1,36 @@
# 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"

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
@@ -113,13 +113,13 @@ 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"

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.

10
dist/mcias.service vendored
View File

@@ -11,11 +11,11 @@ User=mcias
Group=mcias
# Configuration and secrets.
# /etc/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
# See dist/mcias.env.example for the template.
EnvironmentFile=/etc/mcias/env
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

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"

View File

@@ -1245,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

@@ -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

@@ -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

@@ -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

@@ -86,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")
@@ -121,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")
@@ -186,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")
}
@@ -245,7 +257,11 @@ func (a *authServiceServer) EnrollTOTP(ctx context.Context, req *mciasv1.EnrollT
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")
}
@@ -283,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")
}

View File

@@ -30,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 (
@@ -73,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)

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,7 +71,11 @@ 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")
}

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

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,25 @@ 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"
)
// 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:"-"`
}
// 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

@@ -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

@@ -10,14 +10,15 @@
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"
@@ -27,9 +28,11 @@ 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/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"
)
@@ -38,20 +41,155 @@ type Server struct {
db *db.DB
cfg *config.Config
logger *slog.Logger
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
masterKey []byte
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,
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,
}
}
@@ -109,57 +247,114 @@ func (s *Server) Handler() http.Handler {
_, _ = w.Write(specYAML)
})))
// 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)))
// 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", 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)))
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)))
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 and security headers.
// 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
@@ -177,12 +372,21 @@ func (s *Server) Handler() http.Handler {
// ---- 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{
@@ -190,7 +394,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)
}
@@ -270,13 +474,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")
@@ -316,7 +530,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")
@@ -386,7 +605,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
@@ -416,6 +640,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"`
@@ -438,7 +663,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
@@ -450,12 +680,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 {
@@ -478,7 +712,12 @@ 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
@@ -869,7 +1108,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
@@ -912,7 +1156,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
@@ -1172,7 +1421,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
@@ -1209,7 +1463,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
@@ -1250,13 +1509,13 @@ func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Requ
type pgCredResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Port int `json:"port"`
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))
@@ -1412,16 +1671,23 @@ 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

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
}
@@ -620,8 +648,9 @@ func TestRenewToken(t *testing.T) {
acct := createTestHumanAccount(t, srv, "renew-user")
handler := srv.Handler()
// Issue a short-lived token (2s) so we can wait past the 50% threshold.
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, 2*time.Second)
// 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)
}
@@ -630,8 +659,8 @@ func TestRenewToken(t *testing.T) {
t.Fatalf("TrackToken: %v", err)
}
// Wait for >50% of the 2s lifetime to elapse.
time.Sleep(1100 * time.Millisecond)
// 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 {
@@ -793,6 +822,46 @@ func TestLoginLockedAccountReturns401(t *testing.T) {
// 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")
@@ -816,3 +885,237 @@ func TestRenewTokenTooEarly(t *testing.T) {
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,67 @@ 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 {
mu sync.Mutex
key []byte
vault *vault.Vault
}
// newCSRFManager creates a CSRFManager whose key is derived from masterKey.
// newCSRFManager creates a CSRFManager with a static key derived from masterKey.
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
func newCSRFManager(masterKey []byte) *CSRFManager {
return &CSRFManager{key: deriveCSRFKey(masterKey)}
}
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
// 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 +93,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 +114,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

@@ -182,6 +182,21 @@ 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)
}
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Account: acct,
@@ -193,6 +208,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
GrantableAccounts: grantableAccounts,
ActorID: actorID,
Tags: tags,
TokenDelegates: tokenDelegates,
DelegatableAccounts: delegatableAccounts,
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
})
}
@@ -460,7 +478,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")
@@ -864,7 +887,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")
@@ -999,6 +1027,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)
@@ -1011,6 +1046,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")
@@ -1044,17 +1105,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 {
@@ -1067,13 +1129,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),
},
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

@@ -145,7 +145,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"})
@@ -208,7 +213,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"})
@@ -255,7 +265,8 @@ 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)

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

@@ -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"
@@ -33,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"
)
@@ -54,17 +54,31 @@ 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
vault *vault.Vault
pendingLogins sync.Map // nonce (string) → *pendingLogin
tokenDownloads sync.Map // nonce (string) → *tokenDownload
}
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
@@ -108,8 +122,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 {
@@ -194,6 +212,7 @@ 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",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
@@ -212,6 +231,8 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"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 {
@@ -228,9 +249,7 @@ 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,
vault: v,
logger: logger,
csrf: csrf,
tmpls: tmpls,
@@ -241,6 +260,7 @@ 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()
return srv, nil
}
@@ -263,6 +283,56 @@ 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
})
}
}
// 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.
@@ -299,6 +369,11 @@ 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)))
@@ -327,7 +402,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))
@@ -343,6 +425,10 @@ 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", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
@@ -365,7 +451,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)
@@ -667,11 +758,38 @@ 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
// 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
}
// 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.

View File

@@ -17,7 +17,7 @@ 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/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
const testIssuer = "https://auth.example.com"
@@ -48,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)
}
@@ -319,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)
}
@@ -645,7 +646,7 @@ func issueUserSession(t *testing.T, u *UIServer) string {
if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil {
t.Fatalf("SetRoles: %v", err)
}
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"user"}, time.Hour)
tok, claims, err := u.issueToken(acct.UUID, []string{"user"}, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}

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 {
mu sync.RWMutex
masterKey []byte
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
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,149 @@
package vault
import (
"crypto/ed25519"
"crypto/rand"
"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(); err != ErrSealed {
t.Fatalf("MasterKey() error = %v, want ErrSealed", err)
}
if _, err := v.PrivKey(); err != ErrSealed {
t.Fatalf("PrivKey() error = %v, want ErrSealed", err)
}
if _, err := v.PubKey(); 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

@@ -221,8 +221,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 +307,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 +326,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 +344,7 @@ paths:
properties:
status:
type: string
enum: [ok, sealed]
example: ok
/v1/keys/public:
@@ -369,6 +387,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
@@ -473,6 +606,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 +623,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":
@@ -1134,6 +1271,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)
@@ -1148,7 +1349,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:
@@ -1530,3 +1731,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.

View File

@@ -36,6 +36,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/server"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
const e2eIssuer = "https://auth.e2e.test"
@@ -73,7 +74,8 @@ func newTestEnv(t *testing.T) *testEnv {
cfg := config.NewTestConfig(e2eIssuer)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := server.New(database, cfg, priv, pub, masterKey, logger)
v := vault.NewUnsealed(masterKey, priv, pub)
srv := server.New(database, cfg, v, logger)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(func() {
@@ -225,9 +227,11 @@ func TestE2ETokenRenewal(t *testing.T) {
e := newTestEnv(t)
acct := e.createAccount(t, "bob")
// Issue a short-lived token (2s) directly so we can wait past the 50%
// Issue a short-lived token (10s) directly so we can wait past the 50%
// renewal threshold (SEC-03) without blocking the test for minutes.
oldToken, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, nil, 2*time.Second)
// 10s gives ample headroom: we sleep 6s (>50%), leaving 4s for the HTTP
// round-trip before expiry — eliminating the race that plagued the 2s token.
oldToken, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, nil, 10*time.Second)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
@@ -235,8 +239,8 @@ func TestE2ETokenRenewal(t *testing.T) {
t.Fatalf("TrackToken: %v", err)
}
// Wait for >50% of the 2s lifetime to elapse.
time.Sleep(1100 * time.Millisecond)
// Wait for >50% of the 10s lifetime to elapse.
time.Sleep(6 * time.Second)
// Renew.
resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken)

View File

@@ -118,6 +118,121 @@ components:
description: JSON blob with event-specific metadata. Never contains credentials.
example: '{"jti":"f47ac10b-..."}'
TagsResponse:
type: object
required: [tags]
properties:
tags:
type: array
items:
type: string
description: Current tag list for the account.
example: ["env:production", "svc:payments-api"]
RuleBody:
type: object
required: [effect]
description: |
The match conditions and effect of a policy rule. All fields except
`effect` are optional; an omitted field acts as a wildcard.
properties:
effect:
type: string
enum: [allow, deny]
example: allow
roles:
type: array
items:
type: string
description: Subject must have at least one of these roles.
example: ["svc:payments-api"]
account_types:
type: array
items:
type: string
enum: [human, system]
description: Subject account type must be one of these.
example: ["system"]
subject_uuid:
type: string
format: uuid
description: Match only this specific subject UUID.
example: 550e8400-e29b-41d4-a716-446655440000
actions:
type: array
items:
type: string
description: |
One of the defined action constants, e.g. `pgcreds:read`,
`accounts:list`. Subject action must be in this list.
example: ["pgcreds:read"]
resource_type:
type: string
description: Resource type the rule applies to.
example: pgcreds
owner_matches_subject:
type: boolean
description: Resource owner UUID must equal the subject UUID.
example: true
service_names:
type: array
items:
type: string
description: Resource service name must be one of these.
example: ["payments-api"]
required_tags:
type: array
items:
type: string
description: Resource must have ALL of these tags.
example: ["env:staging"]
PolicyRule:
type: object
required: [id, priority, description, rule, enabled, created_at, updated_at]
properties:
id:
type: integer
example: 1
priority:
type: integer
description: Lower number = evaluated first.
example: 100
description:
type: string
example: Allow payments-api to read its own pgcreds
rule:
$ref: "#/components/schemas/RuleBody"
enabled:
type: boolean
example: true
not_before:
type: string
format: date-time
nullable: true
description: |
Earliest time the rule becomes active. NULL means no constraint
(always active). Rules where `not_before > now()` are skipped
during evaluation.
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
nullable: true
description: |
Time after which the rule is no longer active. NULL means no
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
format: date-time
example: "2026-03-11T09:00:00Z"
updated_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
PGCreds:
type: object
required: [host, port, database, username, password]
@@ -192,6 +307,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 ────────────────────────────────────────────────────────────────
@@ -199,12 +326,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:
@@ -212,6 +344,7 @@ paths:
properties:
status:
type: string
enum: [ok, sealed]
example: ok
/v1/keys/public:
@@ -254,6 +387,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
@@ -358,6 +606,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:
@@ -371,7 +623,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":
@@ -496,6 +748,68 @@ paths:
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/password:
put:
summary: Change own password (self-service)
description: |
Change the password of the currently authenticated human account.
The caller must supply the correct `current_password` to prevent
token-theft attacks: possession of a valid JWT alone is not sufficient.
On success:
- The stored Argon2id hash is replaced with the new password hash.
- All active sessions *except* the caller's current token are revoked.
- The lockout failure counter is cleared.
On failure (wrong current password):
- A login failure is recorded against the account, subject to the
same lockout rules as `POST /v1/auth/login`.
Only applies to human accounts. System accounts have no password.
operationId: changePassword
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [current_password, new_password]
properties:
current_password:
type: string
description: The account's current password (required for verification).
example: old-s3cr3t
new_password:
type: string
description: The new password. Minimum 12 characters.
example: new-s3cr3t-long
responses:
"204":
description: Password changed. Other active sessions revoked.
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Current password is incorrect.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: current password is incorrect
code: unauthorized
"429":
description: Account temporarily locked due to too many failed attempts.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: account temporarily locked
code: account_locked
# ── Admin ──────────────────────────────────────────────────────────────────
/v1/auth/totp:
@@ -829,6 +1143,76 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
post:
summary: Grant a role to an account (admin)
description: |
Add a single role to an account's role set. If the role already exists,
this is a no-op. Roles take effect in the **next** token issued or
renewed; existing tokens continue to carry the roles embedded at
issuance time.
operationId: grantRole
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role]
properties:
role:
type: string
example: editor
responses:
"204":
description: Role granted.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/roles/{role}:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
- name: role
in: path
required: true
schema:
type: string
example: editor
delete:
summary: Revoke a role from an account (admin)
description: |
Remove a single role from an account's role set. Roles take effect in
the **next** token issued or renewed; existing tokens continue to carry
the roles embedded at issuance time.
operationId: revokeRole
tags: [Admin — Accounts]
security:
- bearerAuth: []
responses:
"204":
description: Role revoked.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/pgcreds:
parameters:
- name: id
@@ -887,6 +1271,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)
@@ -898,7 +1346,10 @@ paths:
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
`account_created`, `account_updated`, `account_deleted`,
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
`pgcred_accessed`, `pgcred_updated`.
`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`, `vault_sealed`, `vault_unsealed`.
operationId: listAudit
tags: [Admin — Audit]
security:
@@ -959,6 +1410,310 @@ paths:
"403":
$ref: "#/components/responses/Forbidden"
/v1/accounts/{id}/tags:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
get:
summary: Get account tags (admin)
description: |
Return the current tag set for an account. Tags are used by the policy
engine for machine/service gating (e.g. `env:production`,
`svc:payments-api`).
operationId: getAccountTags
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"200":
description: Tag list.
content:
application/json:
schema:
$ref: "#/components/schemas/TagsResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
put:
summary: Set account tags (admin)
description: |
Replace the account's full tag set atomically. Pass an empty array to
clear all tags. Changes take effect immediately for new policy
evaluations; no token renewal is required.
operationId: setAccountTags
tags: [Admin — Policy]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tags]
properties:
tags:
type: array
items:
type: string
example: ["env:production", "svc:payments-api"]
responses:
"200":
description: Updated tag list.
content:
application/json:
schema:
$ref: "#/components/schemas/TagsResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/password:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
put:
summary: Admin password reset (admin)
description: |
Reset the password for a human account without requiring the current
password. This is intended for account recovery (e.g. a user forgot
their password).
On success:
- The stored Argon2id hash is replaced with the new password hash.
- All active sessions for the target account are revoked.
Only applies to human accounts. The new password must be at least
12 characters.
operationId: adminSetPassword
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [new_password]
properties:
new_password:
type: string
description: The new password. Minimum 12 characters.
example: new-s3cr3t-long
responses:
"204":
description: Password reset. All active sessions for the account revoked.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/policy/rules:
get:
summary: List policy rules (admin)
description: |
Return all operator-defined policy rules ordered by priority (ascending).
Built-in default rules (IDs -1 to -7) are not included.
operationId: listPolicyRules
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"200":
description: Array of policy rules.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/PolicyRule"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
post:
summary: Create policy rule (admin)
description: |
Create a new operator policy rule. Rules are evaluated in priority order
(lower number = evaluated first, default 100). Deny-wins: if any matching
rule has effect `deny`, access is denied regardless of allow rules.
operationId: createPolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [description, rule]
properties:
description:
type: string
example: Allow payments-api to read its own pgcreds
priority:
type: integer
description: Evaluation priority. Lower = first. Default 100.
example: 50
rule:
$ref: "#/components/schemas/RuleBody"
not_before:
type: string
format: date-time
description: Earliest activation time (RFC3339, optional).
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
description: Expiry time (RFC3339, optional).
example: "2026-06-01T00:00:00Z"
responses:
"201":
description: Rule created.
content:
application/json:
schema:
$ref: "#/components/schemas/PolicyRule"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
/v1/policy/rules/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: integer
example: 1
get:
summary: Get policy rule (admin)
operationId: getPolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"200":
description: Policy rule.
content:
application/json:
schema:
$ref: "#/components/schemas/PolicyRule"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
patch:
summary: Update policy rule (admin)
description: |
Update one or more fields of an existing policy rule. All fields are
optional; omitted fields are left unchanged.
operationId: updatePolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
description:
type: string
example: Updated description
priority:
type: integer
example: 75
enabled:
type: boolean
example: false
rule:
$ref: "#/components/schemas/RuleBody"
not_before:
type: string
format: date-time
description: Set earliest activation time (RFC3339).
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
description: Set expiry time (RFC3339).
example: "2026-06-01T00:00:00Z"
clear_not_before:
type: boolean
description: Set to true to remove not_before constraint.
clear_expires_at:
type: boolean
description: Set to true to remove expires_at constraint.
responses:
"200":
description: Updated rule.
content:
application/json:
schema:
$ref: "#/components/schemas/PolicyRule"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
delete:
summary: Delete policy rule (admin)
description: Permanently delete a policy rule. This action cannot be undone.
operationId: deletePolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"204":
description: Rule deleted.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
tags:
- name: Public
description: No authentication required.
@@ -974,3 +1729,7 @@ tags:
description: Requires admin role.
- name: Admin — Audit
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.

View File

@@ -26,7 +26,7 @@
<div class="card">
<div class="d-flex align-center justify-between" style="margin-bottom:1rem">
<h2 style="font-size:1rem;font-weight:600">Tokens</h2>
{{if eq (string .Account.AccountType) "system"}}
{{if and (eq (string .Account.AccountType) "system") .CanIssueToken}}
<button class="btn btn-sm btn-secondary"
hx-post="/accounts/{{.Account.UUID}}/token"
hx-target="#token-list" hx-swap="outerHTML">Issue Token</button>
@@ -39,6 +39,10 @@
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Postgres Credentials</h2>
{{template "pgcreds_form" .}}
</div>
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Token Issue Access</h2>
<div id="token-delegates-section">{{template "token_delegates" .}}</div>
</div>
{{end}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>

View File

@@ -15,7 +15,7 @@
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
<li><a href="/audit">Audit</a></li>
<li><a href="/policies">Policies</a></li>
<li><a href="/pgcreds">PG Creds</a></li>{{end}}
<li><a href="/pgcreds">PG Creds</a></li>{{else}}<li><a href="/service-accounts">Service Accounts</a></li>{{end}}
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
</ul>

View File

@@ -0,0 +1,47 @@
{{define "token_delegates"}}
<div id="token-delegates-section">
<h3 style="font-size:.9rem;font-weight:600;margin-bottom:.5rem">Token Issue Delegates</h3>
<p class="text-muted text-small" style="margin-bottom:.75rem">
Delegates can issue and rotate tokens for this service account without holding the admin role.
</p>
{{if .TokenDelegates}}
<table class="table table-sm" style="font-size:.85rem;margin-bottom:.75rem">
<thead>
<tr><th>Account</th><th>Granted</th><th></th></tr>
</thead>
<tbody>
{{range .TokenDelegates}}
<tr>
<td>{{.GranteeName}}</td>
<td class="text-small text-muted">{{formatTime .GrantedAt}}</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/accounts/{{$.Account.UUID}}/token/delegates/{{.GranteeUUID}}"
hx-target="#token-delegates-section" hx-swap="outerHTML"
hx-confirm="Remove delegate access for {{.GranteeName}}?">Remove</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted text-small" style="margin-bottom:.75rem">No delegates.</p>
{{end}}
{{if .DelegatableAccounts}}
<form hx-post="/accounts/{{.Account.UUID}}/token/delegates"
hx-target="#token-delegates-section" hx-swap="outerHTML"
style="display:flex;gap:.5rem;align-items:center">
<select class="form-control" name="grantee_uuid" required style="flex:1">
<option value="">— select account to add as delegate —</option>
{{range .DelegatableAccounts}}
{{if eq (string .AccountType) "human"}}
<option value="{{.UUID}}">{{.Username}}</option>
{{end}}
{{end}}
</select>
<button class="btn btn-sm btn-secondary" type="submit">Add Delegate</button>
</form>
{{end}}
</div>
{{end}}

View File

@@ -1,5 +1,16 @@
{{define "token_list"}}
<div id="token-list">
{{if .Flash}}
<div class="alert alert-success" role="alert" style="margin-bottom:1rem">
{{.Flash}}
{{if .DownloadNonce}}
<div style="margin-top:.5rem">
<a class="btn btn-sm btn-secondary"
href="/token/download/{{.DownloadNonce}}">Download token as file</a>
</div>
{{end}}
</div>
{{end}}
{{if .Tokens}}
<div class="table-wrapper">
<table>

View File

@@ -11,10 +11,10 @@
<div class="login-box">
<div class="brand-heading">MCIAS</div>
<div class="brand-subtitle">Metacircular Identity &amp; Access System</div>
<div class="card">
<div class="card" id="login-card">
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
<form id="login-form" method="POST" action="/login"
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
hx-post="/login" hx-target="#login-card" hx-swap="outerHTML" hx-select="#login-card">
<div class="form-group">
<label for="username">Username</label>
<input class="form-control" type="text" id="username" name="username"

View File

@@ -0,0 +1,47 @@
{{define "service_accounts"}}{{template "base" .}}{{end}}
{{define "title"}}Service Accounts — MCIAS{{end}}
{{define "content"}}
<div class="page-header">
<h1>Service Accounts</h1>
<p class="text-muted text-small">Service accounts for which you have been granted token-issue access.</p>
</div>
{{if .DownloadNonce}}
<div class="alert alert-success" role="alert" style="margin-bottom:1rem">
Token issued.
<a class="btn btn-sm btn-secondary" style="margin-left:.5rem"
href="/token/download/{{.DownloadNonce}}">Download token as file</a>
</div>
{{end}}
{{if .Accounts}}
<div class="card">
<table>
<thead>
<tr><th>Name</th><th>Status</th><th>Action</th></tr>
</thead>
<tbody>
{{range .Accounts}}
<tr>
<td>{{.Username}}</td>
<td><span class="badge badge-{{string .Status}}">{{string .Status}}</span></td>
<td>
<button class="btn btn-sm btn-secondary"
hx-post="/accounts/{{.UUID}}/token"
hx-target="#issue-result-{{.UUID}}"
hx-swap="outerHTML">Issue Token</button>
</td>
</tr>
<tr>
<td colspan="3">
<div id="issue-result-{{.UUID}}"></div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card">
<p class="text-muted text-small">You have not been granted access to any service accounts.</p>
</div>
{{end}}
{{end}}

31
web/templates/unseal.html Normal file
View File

@@ -0,0 +1,31 @@
{{define "unseal"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Unseal Vault — MCIAS</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-wrapper">
<div class="login-box">
<div class="brand-heading">MCIAS</div>
<div class="brand-subtitle">Vault is Sealed</div>
<div class="card">
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
<form id="unseal-form" method="POST" action="/unseal">
<div class="form-group">
<label for="passphrase">Master Passphrase</label>
<input class="form-control" type="password" id="passphrase" name="passphrase"
autocomplete="off" required autofocus>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit" style="width:100%">Unseal</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
{{end}}