Files
metacrypt/internal/engine/engine.go
Kyle Isom 64d921827e Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)
Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs
stored in a new barrier_keys table, rather than encrypting all barrier
entries directly. A v2 ciphertext format (0x02) embeds the key ID so the
barrier can resolve which DEK to use on decryption. v1 ciphertext remains
supported for backward compatibility.

Key changes:
- crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs
- barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys)
- seal: RotateMEK re-wraps DEKs without re-encrypting data
- engine: Mount auto-creates per-engine DEK
- REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate
- proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate
- db: migration v2 adds barrier_keys table

Also includes: security audit report, CSRF protection, engine design specs
(sshca, transit, user), path-bound AAD migration tool, policy engine
enhancements, and ARCHITECTURE.md updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:27:44 -07:00

380 lines
10 KiB
Go

// Package engine defines the Engine interface and mount registry.
// Phase 1: interface and registry only, no concrete implementations.
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
)
// EngineType identifies a cryptographic engine type.
type EngineType string
const (
EngineTypeCA EngineType = "ca"
EngineTypeSSHCA EngineType = "sshca"
EngineTypeTransit EngineType = "transit"
EngineTypeUser EngineType = "user"
)
var (
ErrMountExists = errors.New("engine: mount already exists")
ErrMountNotFound = errors.New("engine: mount not found")
ErrUnknownType = errors.New("engine: unknown engine type")
)
// CallerInfo carries authentication context into engines.
type CallerInfo struct {
Username string
Roles []string
IsAdmin bool
}
// IsUser returns true if the caller has the "user" or "admin" role (i.e. not guest-only).
func (c *CallerInfo) IsUser() bool {
if c.IsAdmin {
return true
}
for _, r := range c.Roles {
if r == "user" {
return true
}
}
return false
}
// PolicyChecker evaluates whether the caller has access to a specific resource.
// Returns the policy effect ("allow" or "deny") and whether a matching rule was found.
// When matched is false, the caller should fall back to default access rules.
type PolicyChecker func(resource, action string) (effect string, matched bool)
// Request is a request to an engine.
type Request struct {
Data map[string]interface{}
CallerInfo *CallerInfo
CheckPolicy PolicyChecker
Operation string
Path string
}
// Response is a response from an engine.
type Response struct {
Data map[string]interface{}
}
// Engine is the interface that all cryptographic engines must implement.
type Engine interface {
// Type returns the engine type.
Type() EngineType
// Initialize sets up the engine for first use.
Initialize(ctx context.Context, b barrier.Barrier, mountPath string, config map[string]interface{}) error
// Unseal opens the engine using state from the barrier.
Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error
// Seal closes the engine and zeroizes key material.
Seal() error
// HandleRequest processes a request.
HandleRequest(ctx context.Context, req *Request) (*Response, error)
}
// Factory creates a new engine instance of a given type.
type Factory func() Engine
// Mount represents a mounted engine instance.
type Mount struct {
Engine Engine `json:"-"`
Name string `json:"name"`
Type EngineType `json:"type"`
MountPath string `json:"mount_path"`
}
// Registry manages mounted engine instances.
type Registry struct {
barrier barrier.Barrier
mounts map[string]*Mount
factories map[EngineType]Factory
logger *slog.Logger
mu sync.RWMutex
}
// NewRegistry creates a new engine registry.
func NewRegistry(b barrier.Barrier, logger *slog.Logger) *Registry {
return &Registry{
mounts: make(map[string]*Mount),
factories: make(map[EngineType]Factory),
barrier: b,
logger: logger,
}
}
// RegisterFactory registers a factory for the given engine type.
func (r *Registry) RegisterFactory(t EngineType, f Factory) {
r.mu.Lock()
defer r.mu.Unlock()
r.logger.Debug("registering engine factory", "type", t)
r.factories[t] = f
}
// mountMeta is persisted to the barrier so mounts survive seal/unseal cycles.
type mountMeta struct {
Name string `json:"name"`
Type EngineType `json:"type"`
}
const mountsPrefix = "engine/_mounts/"
// Mount creates and initializes a new engine mount.
func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.mounts[name]; exists {
return ErrMountExists
}
factory, ok := r.factories[engineType]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownType, engineType)
}
r.logger.Debug("mounting engine", "name", name, "type", engineType)
eng := factory()
mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name)
// Create a per-engine DEK in the barrier for this mount.
if aesBarrier, ok := r.barrier.(*barrier.AESGCMBarrier); ok {
dekKeyID := fmt.Sprintf("engine/%s/%s", engineType, name)
if err := aesBarrier.CreateKey(ctx, dekKeyID); err != nil {
return fmt.Errorf("engine: create DEK %q: %w", dekKeyID, err)
}
}
if err := eng.Initialize(ctx, r.barrier, mountPath, config); err != nil {
return fmt.Errorf("engine: initialize %q: %w", name, err)
}
// Persist mount metadata so it can be reloaded after unseal.
meta, err := json.Marshal(mountMeta{Name: name, Type: engineType})
if err != nil {
return fmt.Errorf("engine: marshal mount metadata: %w", err)
}
if err := r.barrier.Put(ctx, mountsPrefix+name+".json", meta); err != nil {
return fmt.Errorf("engine: persist mount metadata: %w", err)
}
r.mounts[name] = &Mount{
Name: name,
Type: engineType,
MountPath: mountPath,
Engine: eng,
}
r.logger.Debug("engine mounted", "name", name, "type", engineType, "mount_path", mountPath)
return nil
}
// GetEngine returns the engine for the given mount name.
func (r *Registry) GetEngine(name string) (Engine, error) {
r.mu.RLock()
defer r.mu.RUnlock()
mount, exists := r.mounts[name]
if !exists {
return nil, ErrMountNotFound
}
return mount.Engine, nil
}
// GetMount returns the mount for the given name.
func (r *Registry) GetMount(name string) (*Mount, error) {
r.mu.RLock()
defer r.mu.RUnlock()
mount, exists := r.mounts[name]
if !exists {
return nil, ErrMountNotFound
}
return mount, nil
}
// Unmount removes and seals an engine mount.
func (r *Registry) Unmount(ctx context.Context, name string) error {
r.mu.Lock()
defer r.mu.Unlock()
mount, exists := r.mounts[name]
if !exists {
return ErrMountNotFound
}
r.logger.Debug("unmounting engine", "name", name, "type", mount.Type)
if err := mount.Engine.Seal(); err != nil {
return fmt.Errorf("engine: seal %q: %w", name, err)
}
// Remove persisted mount metadata.
if err := r.barrier.Delete(ctx, mountsPrefix+name+".json"); err != nil {
return fmt.Errorf("engine: delete mount metadata %q: %w", name, err)
}
delete(r.mounts, name)
r.logger.Debug("engine unmounted", "name", name)
return nil
}
// UnsealAll discovers persisted mounts from the barrier and reloads them into
// memory. It must be called after the barrier is unsealed.
//
// It first loads mounts recorded in engine/_mounts/, then does a discovery
// pass over each registered engine type to pick up any mounts that predate
// the metadata mechanism (one-time migration).
func (r *Registry) UnsealAll(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
// Load mounts from persisted metadata.
if err := r.loadFromMetadata(ctx); err != nil {
return err
}
// Discovery pass: scan each registered engine type for mounts that exist
// in the barrier but have no metadata entry yet (pre-migration mounts).
for engineType, factory := range r.factories {
prefix := fmt.Sprintf("engine/%s/", engineType)
paths, err := r.barrier.List(ctx, prefix)
if err != nil {
continue // no entries for this type
}
// Extract unique mount names from paths like "myca/config.json".
seen := make(map[string]bool)
for _, p := range paths {
parts := strings.SplitN(p, "/", 2)
if len(parts) == 0 || parts[0] == "" || parts[0] == "_mounts" {
continue
}
seen[parts[0]] = true
}
for name := range seen {
if _, exists := r.mounts[name]; exists {
continue // already loaded
}
r.logger.Debug("discovered pre-migration engine mount", "name", name, "type", engineType)
eng := factory()
mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name)
if err := eng.Unseal(ctx, r.barrier, mountPath); err != nil {
return fmt.Errorf("engine: unseal (discovered) %q: %w", name, err)
}
// Write metadata so future restarts don't need the discovery pass.
meta, _ := json.Marshal(mountMeta{Name: name, Type: engineType})
_ = r.barrier.Put(ctx, mountsPrefix+name+".json", meta)
r.mounts[name] = &Mount{
Name: name,
Type: engineType,
MountPath: mountPath,
Engine: eng,
}
}
}
return nil
}
// loadFromMetadata loads mounts recorded under engine/_mounts/. Caller must hold r.mu.
func (r *Registry) loadFromMetadata(ctx context.Context) error {
paths, err := r.barrier.List(ctx, mountsPrefix)
if err != nil {
return nil // no metadata written yet
}
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
data, err := r.barrier.Get(ctx, mountsPrefix+p)
if err != nil {
return fmt.Errorf("engine: read mount metadata %q: %w", p, err)
}
var meta mountMeta
if err := json.Unmarshal(data, &meta); err != nil {
return fmt.Errorf("engine: parse mount metadata %q: %w", p, err)
}
factory, ok := r.factories[meta.Type]
if !ok {
return fmt.Errorf("%w: %s (mount %q)", ErrUnknownType, meta.Type, meta.Name)
}
r.logger.Debug("unsealing engine from metadata", "name", meta.Name, "type", meta.Type)
eng := factory()
mountPath := fmt.Sprintf("engine/%s/%s/", meta.Type, meta.Name)
if err := eng.Unseal(ctx, r.barrier, mountPath); err != nil {
return fmt.Errorf("engine: unseal %q: %w", meta.Name, err)
}
r.mounts[meta.Name] = &Mount{
Name: meta.Name,
Type: meta.Type,
MountPath: mountPath,
Engine: eng,
}
}
return nil
}
// ListMounts returns all current mounts.
func (r *Registry) ListMounts() []Mount {
r.mu.RLock()
defer r.mu.RUnlock()
mounts := make([]Mount, 0, len(r.mounts))
for _, m := range r.mounts {
mounts = append(mounts, Mount{
Name: m.Name,
Type: m.Type,
MountPath: m.MountPath,
})
}
return mounts
}
// HandleRequest routes a request to the appropriate engine.
func (r *Registry) HandleRequest(ctx context.Context, mountName string, req *Request) (*Response, error) {
r.mu.RLock()
mount, exists := r.mounts[mountName]
r.mu.RUnlock()
if !exists {
return nil, ErrMountNotFound
}
r.logger.Debug("routing engine request", "mount", mountName, "operation", req.Operation, "path", req.Path)
return mount.Engine.HandleRequest(ctx, req)
}
// SealAll seals all mounted engines.
func (r *Registry) SealAll() error {
r.mu.Lock()
defer r.mu.Unlock()
r.logger.Debug("sealing all engines", "count", len(r.mounts))
for name, mount := range r.mounts {
if err := mount.Engine.Seal(); err != nil {
return fmt.Errorf("engine: seal %q: %w", name, err)
}
}
return nil
}