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

@@ -36,6 +36,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
"git.wntrmute.dev/kyle/mcias/internal/server"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
func main() {
@@ -72,29 +73,46 @@ func run(configPath string, logger *slog.Logger) error {
}
logger.Info("database ready", "path", cfg.Database.Path)
// Derive or load the master encryption key.
// Derive or load the master encryption key and build the vault.
// Security: The master key encrypts TOTP secrets, Postgres passwords, and
// the signing key at rest. It is derived from a passphrase via Argon2id
// (or loaded directly from a key file). The KDF salt is stored in the DB
// for stability across restarts. The passphrase env var is cleared after use.
masterKey, err := loadMasterKey(cfg, database)
if err != nil {
return fmt.Errorf("load master key: %w", err)
}
defer func() {
// Zero the master key when done — reduces the window of exposure.
for i := range masterKey {
masterKey[i] = 0
//
// When the passphrase is not available (empty env var in passphrase mode
// with no key file), the server starts in sealed state. The operator must
// provide the passphrase via the /v1/vault/unseal API or the /unseal UI page.
// First run (no signing key in DB) still requires the passphrase at startup.
var v *vault.Vault
masterKey, mkErr := loadMasterKey(cfg, database)
if mkErr != nil {
// Check if we can start sealed (passphrase mode, empty env var).
if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" {
// Verify that this is not a first run — the signing key must already exist.
enc, nonce, scErr := database.ReadServerConfig()
if scErr != nil || enc == nil || nonce == nil {
return fmt.Errorf("first run requires passphrase: %w", mkErr)
}
v = vault.NewSealed()
logger.Info("vault starting in sealed state")
} else {
return fmt.Errorf("load master key: %w", mkErr)
}
}()
// Load or generate the Ed25519 signing key.
// Security: The private signing key is stored AES-256-GCM encrypted in the
// database. On first run it is generated and stored. The key is decrypted
// with the master key each startup.
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
if err != nil {
return fmt.Errorf("signing key: %w", err)
} else {
// Load or generate the Ed25519 signing key.
// Security: The private signing key is stored AES-256-GCM encrypted in the
// database. On first run it is generated and stored. The key is decrypted
// with the master key each startup.
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
if err != nil {
// Zero master key on failure.
for i := range masterKey {
masterKey[i] = 0
}
return fmt.Errorf("signing key: %w", err)
}
v = vault.NewUnsealed(masterKey, privKey, pubKey)
logger.Info("vault unsealed at startup")
}
// Configure TLS. We require TLS 1.2+ and prefer TLS 1.3.
@@ -108,8 +126,8 @@ func run(configPath string, logger *slog.Logger) error {
},
}
// Build the REST handler.
restSrv := server.New(database, cfg, privKey, pubKey, masterKey, logger)
// Build the REST handler. All servers share the same vault by pointer.
restSrv := server.New(database, cfg, v, logger)
httpServer := &http.Server{
Addr: cfg.Server.ListenAddr,
Handler: restSrv.Handler(),
@@ -131,7 +149,7 @@ func run(configPath string, logger *slog.Logger) error {
return fmt.Errorf("load gRPC TLS credentials: %w", err)
}
grpcSrvImpl := grpcserver.New(database, cfg, privKey, pubKey, masterKey, logger)
grpcSrvImpl := grpcserver.New(database, cfg, v, logger)
// Build server directly with TLS credentials. GRPCServerWithCreds builds
// the server with transport credentials at construction time per gRPC idiom.
grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds)