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