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:
102
internal/server/vault.go
Normal file
102
internal/server/vault.go
Normal 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()})
|
||||
}
|
||||
Reference in New Issue
Block a user