All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
359 lines
9.3 KiB
Go
359 lines
9.3 KiB
Go
// Package seal implements the seal/unseal state machine for Metacrypt.
|
|
package seal
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/mc/metacrypt/internal/audit"
|
|
"git.wntrmute.dev/mc/metacrypt/internal/barrier"
|
|
"git.wntrmute.dev/mc/metacrypt/internal/crypto"
|
|
)
|
|
|
|
// ServiceState represents the current state of the Metacrypt service.
|
|
type ServiceState int
|
|
|
|
const (
|
|
StateUninitialized ServiceState = iota
|
|
StateSealed
|
|
StateInitializing
|
|
StateUnsealed
|
|
)
|
|
|
|
func (s ServiceState) String() string {
|
|
switch s {
|
|
case StateUninitialized:
|
|
return "uninitialized"
|
|
case StateSealed:
|
|
return "sealed"
|
|
case StateInitializing:
|
|
return "initializing"
|
|
case StateUnsealed:
|
|
return "unsealed"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
var (
|
|
ErrAlreadyInitialized = errors.New("seal: already initialized")
|
|
ErrNotInitialized = errors.New("seal: not initialized")
|
|
ErrInvalidPassword = errors.New("seal: invalid password")
|
|
ErrSealed = errors.New("seal: service is sealed")
|
|
ErrNotSealed = errors.New("seal: service is not sealed")
|
|
ErrRateLimited = errors.New("seal: too many unseal attempts, try again later")
|
|
)
|
|
|
|
// Manager manages the seal/unseal lifecycle.
|
|
type Manager struct {
|
|
lastAttempt time.Time
|
|
lockoutUntil time.Time
|
|
db *sql.DB
|
|
barrier *barrier.AESGCMBarrier
|
|
audit *audit.Logger
|
|
logger *slog.Logger
|
|
mek []byte
|
|
state ServiceState
|
|
unsealAttempts int
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewManager creates a new seal manager.
|
|
func NewManager(db *sql.DB, b *barrier.AESGCMBarrier, auditLog *audit.Logger, logger *slog.Logger) *Manager {
|
|
return &Manager{
|
|
db: db,
|
|
barrier: b,
|
|
audit: auditLog,
|
|
logger: logger,
|
|
state: StateUninitialized,
|
|
}
|
|
}
|
|
|
|
// Barrier returns the underlying barrier for direct access by subsystems
|
|
// that need to read/write encrypted storage (e.g. ACME state).
|
|
// The barrier must only be used when the service is unsealed.
|
|
func (m *Manager) Barrier() *barrier.AESGCMBarrier {
|
|
return m.barrier
|
|
}
|
|
|
|
// State returns the current service state.
|
|
func (m *Manager) State() ServiceState {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.state
|
|
}
|
|
|
|
// CheckInitialized checks the database for an existing seal config and
|
|
// updates the state accordingly. Should be called on startup.
|
|
func (m *Manager) CheckInitialized() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
var count int
|
|
err := m.db.QueryRow("SELECT COUNT(*) FROM seal_config").Scan(&count)
|
|
if err != nil {
|
|
return fmt.Errorf("seal: check initialized: %w", err)
|
|
}
|
|
if count > 0 {
|
|
m.state = StateSealed
|
|
m.logger.Debug("seal config found, state set to sealed")
|
|
} else {
|
|
m.state = StateUninitialized
|
|
m.logger.Debug("no seal config found, state set to uninitialized")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Initialize performs first-time setup: generates MEK, encrypts it with the
|
|
// password-derived KWK, and stores everything in seal_config.
|
|
func (m *Manager) Initialize(ctx context.Context, password []byte, params crypto.Argon2Params) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.state != StateUninitialized {
|
|
return ErrAlreadyInitialized
|
|
}
|
|
|
|
m.logger.Debug("initializing seal manager")
|
|
m.state = StateInitializing
|
|
defer func() {
|
|
if m.mek == nil {
|
|
// If we failed, go back to uninitialized.
|
|
m.state = StateUninitialized
|
|
}
|
|
}()
|
|
|
|
// Generate salt and MEK.
|
|
salt, err := crypto.GenerateSalt()
|
|
if err != nil {
|
|
return fmt.Errorf("seal: generate salt: %w", err)
|
|
}
|
|
|
|
mek, err := crypto.GenerateKey()
|
|
if err != nil {
|
|
return fmt.Errorf("seal: generate mek: %w", err)
|
|
}
|
|
|
|
// Derive KWK from password.
|
|
kwk := crypto.DeriveKey(password, salt, params)
|
|
defer crypto.Zeroize(kwk)
|
|
|
|
// Encrypt MEK with KWK.
|
|
encryptedMEK, err := crypto.Encrypt(kwk, mek, nil)
|
|
if err != nil {
|
|
crypto.Zeroize(mek)
|
|
return fmt.Errorf("seal: encrypt mek: %w", err)
|
|
}
|
|
|
|
// Store in database.
|
|
_, err = m.db.ExecContext(ctx, `
|
|
INSERT INTO seal_config (id, encrypted_mek, kdf_salt, argon2_time, argon2_memory, argon2_threads)
|
|
VALUES (1, ?, ?, ?, ?, ?)`,
|
|
encryptedMEK, salt, params.Time, params.Memory, params.Threads)
|
|
if err != nil {
|
|
crypto.Zeroize(mek)
|
|
return fmt.Errorf("seal: store config: %w", err)
|
|
}
|
|
|
|
// Unseal the barrier with the MEK.
|
|
if err := m.barrier.Unseal(mek); err != nil {
|
|
crypto.Zeroize(mek)
|
|
return fmt.Errorf("seal: unseal barrier: %w", err)
|
|
}
|
|
|
|
m.mek = mek
|
|
m.state = StateUnsealed
|
|
m.logger.Debug("seal initialization complete, barrier unsealed")
|
|
return nil
|
|
}
|
|
|
|
// Unseal decrypts the MEK using the provided password and unseals the barrier.
|
|
func (m *Manager) Unseal(password []byte) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.state == StateUninitialized {
|
|
return ErrNotInitialized
|
|
}
|
|
if m.state == StateUnsealed {
|
|
return ErrNotSealed
|
|
}
|
|
|
|
m.logger.Debug("unseal attempt")
|
|
// Rate limiting.
|
|
now := time.Now()
|
|
if now.Before(m.lockoutUntil) {
|
|
m.logger.Debug("unseal attempt rate limited")
|
|
return ErrRateLimited
|
|
}
|
|
if now.Sub(m.lastAttempt) > time.Minute {
|
|
m.unsealAttempts = 0
|
|
}
|
|
m.unsealAttempts++
|
|
m.lastAttempt = now
|
|
if m.unsealAttempts > 5 {
|
|
m.lockoutUntil = now.Add(60 * time.Second)
|
|
m.unsealAttempts = 0
|
|
m.logger.Debug("unseal attempts exceeded, locking out")
|
|
return ErrRateLimited
|
|
}
|
|
|
|
// Read seal config.
|
|
var (
|
|
encryptedMEK []byte
|
|
salt []byte
|
|
argTime, argMem uint32
|
|
argThreads uint8
|
|
)
|
|
err := m.db.QueryRow(`
|
|
SELECT encrypted_mek, kdf_salt, argon2_time, argon2_memory, argon2_threads
|
|
FROM seal_config WHERE id = 1`).Scan(&encryptedMEK, &salt, &argTime, &argMem, &argThreads)
|
|
if err != nil {
|
|
return fmt.Errorf("seal: read config: %w", err)
|
|
}
|
|
|
|
params := crypto.Argon2Params{Time: argTime, Memory: argMem, Threads: argThreads}
|
|
|
|
// Derive KWK and decrypt MEK.
|
|
kwk := crypto.DeriveKey(password, salt, params)
|
|
defer crypto.Zeroize(kwk)
|
|
|
|
mek, err := crypto.Decrypt(kwk, encryptedMEK, nil)
|
|
if err != nil {
|
|
m.logger.Debug("unseal failed: invalid password")
|
|
m.audit.Log(context.Background(), audit.Event{
|
|
Caller: "operator", Operation: "unseal", Outcome: "denied",
|
|
Error: "invalid password",
|
|
})
|
|
return ErrInvalidPassword
|
|
}
|
|
|
|
// Unseal the barrier.
|
|
if err := m.barrier.Unseal(mek); err != nil {
|
|
crypto.Zeroize(mek)
|
|
return fmt.Errorf("seal: unseal barrier: %w", err)
|
|
}
|
|
|
|
m.mek = mek
|
|
m.state = StateUnsealed
|
|
m.unsealAttempts = 0
|
|
m.audit.Log(context.Background(), audit.Event{
|
|
Caller: "operator", Operation: "unseal", Outcome: "success",
|
|
})
|
|
m.logger.Debug("unseal succeeded, barrier unsealed")
|
|
return nil
|
|
}
|
|
|
|
// RotateMEK generates a new MEK, re-wraps all DEKs in the barrier, and
|
|
// updates the encrypted MEK in seal_config. The password is required to
|
|
// derive the KWK for re-encrypting the new MEK.
|
|
func (m *Manager) RotateMEK(ctx context.Context, password []byte) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.state != StateUnsealed {
|
|
return ErrSealed
|
|
}
|
|
|
|
// Read seal config for KDF params.
|
|
var (
|
|
salt []byte
|
|
argTime, argMem uint32
|
|
argThreads uint8
|
|
)
|
|
err := m.db.QueryRow(`
|
|
SELECT kdf_salt, argon2_time, argon2_memory, argon2_threads
|
|
FROM seal_config WHERE id = 1`).Scan(&salt, &argTime, &argMem, &argThreads)
|
|
if err != nil {
|
|
return fmt.Errorf("seal: read config: %w", err)
|
|
}
|
|
|
|
// Verify password by decrypting existing MEK.
|
|
params := crypto.Argon2Params{Time: argTime, Memory: argMem, Threads: argThreads}
|
|
kwk := crypto.DeriveKey(password, salt, params)
|
|
defer crypto.Zeroize(kwk)
|
|
|
|
var encryptedMEK []byte
|
|
err = m.db.QueryRow("SELECT encrypted_mek FROM seal_config WHERE id = 1").Scan(&encryptedMEK)
|
|
if err != nil {
|
|
return fmt.Errorf("seal: read encrypted mek: %w", err)
|
|
}
|
|
_, err = crypto.Decrypt(kwk, encryptedMEK, nil)
|
|
if err != nil {
|
|
return ErrInvalidPassword
|
|
}
|
|
|
|
// Generate new MEK.
|
|
newMEK, err := crypto.GenerateKey()
|
|
if err != nil {
|
|
return fmt.Errorf("seal: generate new mek: %w", err)
|
|
}
|
|
|
|
// Encrypt new MEK with KWK before starting the transaction.
|
|
newEncMEK, err := crypto.Encrypt(kwk, newMEK, nil)
|
|
if err != nil {
|
|
crypto.Zeroize(newMEK)
|
|
return fmt.Errorf("seal: encrypt new mek: %w", err)
|
|
}
|
|
|
|
// Re-wrap DEKs and update seal_config in a single atomic transaction.
|
|
tx, err := m.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
crypto.Zeroize(newMEK)
|
|
return fmt.Errorf("seal: begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if err := m.barrier.ReWrapKeysTx(ctx, tx, newMEK); err != nil {
|
|
crypto.Zeroize(newMEK)
|
|
return fmt.Errorf("seal: re-wrap keys: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
"UPDATE seal_config SET encrypted_mek = ? WHERE id = 1", newEncMEK)
|
|
if err != nil {
|
|
crypto.Zeroize(newMEK)
|
|
return fmt.Errorf("seal: update seal config: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
crypto.Zeroize(newMEK)
|
|
return fmt.Errorf("seal: commit mek rotation: %w", err)
|
|
}
|
|
|
|
// Only after commit: swap in-memory state.
|
|
m.barrier.SwapMEK(newMEK)
|
|
crypto.Zeroize(m.mek)
|
|
m.mek = newMEK
|
|
m.logger.Info("MEK rotated successfully")
|
|
return nil
|
|
}
|
|
|
|
// Seal seals the service: zeroizes MEK, seals the barrier.
|
|
func (m *Manager) Seal() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.state != StateUnsealed {
|
|
return ErrNotSealed
|
|
}
|
|
|
|
m.logger.Debug("sealing service")
|
|
if m.mek != nil {
|
|
crypto.Zeroize(m.mek)
|
|
m.mek = nil
|
|
}
|
|
_ = m.barrier.Seal()
|
|
m.state = StateSealed
|
|
m.audit.Log(context.Background(), audit.Event{
|
|
Caller: "operator", Operation: "seal", Outcome: "success",
|
|
})
|
|
m.logger.Debug("service sealed")
|
|
return nil
|
|
}
|