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

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
}