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

67
internal/vault/derive.go Normal file
View File

@@ -0,0 +1,67 @@
package vault
import (
"crypto/ed25519"
"errors"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
// DeriveFromPassphrase derives the master encryption key from a passphrase
// using the Argon2id KDF with a salt stored in the database.
//
// Security: The Argon2id parameters used by crypto.DeriveKey exceed OWASP 2023
// minimums (time=3, memory=128MiB, threads=4). The salt is 32 random bytes
// stored in the database on first run.
func DeriveFromPassphrase(passphrase string, database *db.DB) ([]byte, error) {
salt, err := database.ReadMasterKeySalt()
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("no master key salt in database (first-run requires startup passphrase)")
}
if err != nil {
return nil, fmt.Errorf("read master key salt: %w", err)
}
key, err := crypto.DeriveKey(passphrase, salt)
if err != nil {
return nil, fmt.Errorf("derive master key: %w", err)
}
return key, nil
}
// DecryptSigningKey decrypts the Ed25519 signing key pair from the database
// using the provided master key.
//
// Security: The private key is stored AES-256-GCM encrypted in the database.
// A fresh random nonce is used for each encryption. The plaintext key only
// exists in memory during the process lifetime.
func DecryptSigningKey(database *db.DB, masterKey []byte) (ed25519.PrivateKey, ed25519.PublicKey, error) {
enc, nonce, err := database.ReadServerConfig()
if err != nil {
return nil, nil, fmt.Errorf("read server config: %w", err)
}
if enc == nil || nonce == nil {
return nil, nil, fmt.Errorf("no signing key in database (first-run requires startup passphrase)")
}
privPEM, err := crypto.OpenAESGCM(masterKey, nonce, enc)
if err != nil {
return nil, nil, fmt.Errorf("decrypt signing key: %w", err)
}
priv, err := crypto.ParsePrivateKeyPEM(privPEM)
if err != nil {
return nil, nil, fmt.Errorf("parse signing key PEM: %w", err)
}
// Security: ed25519.PrivateKey.Public() always returns ed25519.PublicKey,
// but we use the ok form to make the type assertion explicit and safe.
pub, ok := priv.Public().(ed25519.PublicKey)
if !ok {
return nil, nil, fmt.Errorf("signing key has unexpected public key type")
}
return priv, pub, nil
}

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
}

View File

@@ -0,0 +1,149 @@
package vault
import (
"crypto/ed25519"
"crypto/rand"
"sync"
"testing"
)
func generateTestKeys(t *testing.T) ([]byte, ed25519.PrivateKey, ed25519.PublicKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
mk := make([]byte, 32)
if _, err := rand.Read(mk); err != nil {
t.Fatalf("generate master key: %v", err)
}
return mk, priv, pub
}
func TestNewSealed(t *testing.T) {
v := NewSealed()
if !v.IsSealed() {
t.Fatal("NewSealed() should be sealed")
}
if _, err := v.MasterKey(); err != ErrSealed {
t.Fatalf("MasterKey() error = %v, want ErrSealed", err)
}
if _, err := v.PrivKey(); err != ErrSealed {
t.Fatalf("PrivKey() error = %v, want ErrSealed", err)
}
if _, err := v.PubKey(); err != ErrSealed {
t.Fatalf("PubKey() error = %v, want ErrSealed", err)
}
}
func TestNewUnsealed(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
if v.IsSealed() {
t.Fatal("NewUnsealed() should not be sealed")
}
gotMK, err := v.MasterKey()
if err != nil {
t.Fatalf("MasterKey() error = %v", err)
}
if len(gotMK) != 32 {
t.Fatalf("MasterKey() len = %d, want 32", len(gotMK))
}
}
func TestUnsealFromSealed(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewSealed()
if err := v.Unseal(mk, priv, pub); err != nil {
t.Fatalf("Unseal() error = %v", err)
}
if v.IsSealed() {
t.Fatal("should be unsealed after Unseal()")
}
gotPriv, err := v.PrivKey()
if err != nil {
t.Fatalf("PrivKey() error = %v", err)
}
if !priv.Equal(gotPriv) {
t.Fatal("PrivKey() mismatch")
}
}
func TestUnsealAlreadyUnsealed(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
if err := v.Unseal(mk, priv, pub); err == nil {
t.Fatal("Unseal() on unsealed vault should return error")
}
}
func TestSealZeroesKeys(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
// Keep references to the backing arrays so we can verify zeroing.
mkRef := mk
privRef := priv
v := NewUnsealed(mk, priv, pub)
v.Seal()
if !v.IsSealed() {
t.Fatal("should be sealed after Seal()")
}
// Verify the original backing arrays were zeroed.
for i, b := range mkRef {
if b != 0 {
t.Fatalf("masterKey[%d] = %d, want 0", i, b)
}
}
for i, b := range privRef {
if b != 0 {
t.Fatalf("privKey[%d] = %d, want 0", i, b)
}
}
}
func TestSealUnsealCycle(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
v.Seal()
mk2, priv2, pub2 := generateTestKeys(t)
if err := v.Unseal(mk2, priv2, pub2); err != nil {
t.Fatalf("Unseal() after Seal() error = %v", err)
}
gotPub, err := v.PubKey()
if err != nil {
t.Fatalf("PubKey() error = %v", err)
}
if !pub2.Equal(gotPub) {
t.Fatal("PubKey() mismatch after re-unseal")
}
}
func TestConcurrentAccess(t *testing.T) {
mk, priv, pub := generateTestKeys(t)
v := NewUnsealed(mk, priv, pub)
var wg sync.WaitGroup
// Concurrent readers.
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
_ = v.IsSealed()
_, _ = v.MasterKey()
_, _ = v.PrivKey()
_, _ = v.PubKey()
}()
}
// Concurrent seal/unseal cycles.
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
v.Seal()
mk2, priv2, pub2 := generateTestKeys(t)
_ = v.Unseal(mk2, priv2, pub2)
}()
}
wg.Wait()
}