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

@@ -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 {
key []byte
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.