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>
This commit is contained in:
2026-03-14 23:55:37 -07:00
parent 5c242f8abb
commit d87b4b4042
28 changed files with 1292 additions and 119 deletions

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