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

@@ -10,7 +10,6 @@
package server
import (
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
@@ -31,28 +30,25 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/ui"
"git.wntrmute.dev/kyle/mcias/internal/validate"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/kyle/mcias/web"
)
// Server holds the dependencies injected into all handlers.
type Server struct {
db *db.DB
cfg *config.Config
logger *slog.Logger
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
masterKey []byte
db *db.DB
cfg *config.Config
logger *slog.Logger
vault *vault.Vault
}
// 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 {
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,
logger: logger,
db: database,
cfg: cfg,
vault: v,
logger: logger,
}
}
@@ -110,8 +106,14 @@ 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)
requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)
requireAdmin := func(h http.Handler) http.Handler {
return requireAuth(middleware.RequireRole("admin")(h))
}
@@ -152,15 +154,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(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
@@ -178,12 +183,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{
@@ -191,7 +205,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)
}
@@ -282,7 +296,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
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")
@@ -322,7 +341,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")
@@ -392,7 +416,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
@@ -444,7 +473,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
@@ -484,7 +518,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
@@ -875,7 +914,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
@@ -918,7 +962,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
@@ -1178,7 +1227,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
@@ -1215,7 +1269,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