Implement Phase 1: core framework, operational tooling, and runbook

Core packages: crypto (Argon2id/AES-256-GCM), config (TOML/viper),
db (SQLite/migrations), barrier (encrypted storage), seal (state machine
with rate-limited unseal), auth (MCIAS integration with token cache),
policy (priority-based ACL engine), engine (interface + registry).

Server: HTTPS with TLS 1.2+, REST API, auth/admin middleware, htmx web UI
(init, unseal, login, dashboard pages).

CLI: cobra/viper subcommands (server, init, status, snapshot) with env
var override support (METACRYPT_ prefix).

Operational tooling: Dockerfile (multi-stage, non-root), docker-compose,
hardened systemd units (service + daily backup timer), install script,
backup script with retention pruning, production config examples.

Runbook covering installation, configuration, daily operations,
backup/restore, monitoring, troubleshooting, and security procedures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 20:43:11 -07:00
commit 4ddd32b117
60 changed files with 4644 additions and 0 deletions

242
internal/seal/seal.go Normal file
View File

@@ -0,0 +1,242 @@
// Package seal implements the seal/unseal state machine for Metacrypt.
package seal
import (
"context"
"database/sql"
"errors"
"fmt"
"sync"
"time"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/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 {
db *sql.DB
barrier *barrier.AESGCMBarrier
mu sync.RWMutex
state ServiceState
mek []byte // nil when sealed
// Rate limiting for unseal attempts.
unsealAttempts int
lastAttempt time.Time
lockoutUntil time.Time
}
// NewManager creates a new seal manager.
func NewManager(db *sql.DB, b *barrier.AESGCMBarrier) *Manager {
return &Manager{
db: db,
barrier: b,
state: StateUninitialized,
}
}
// 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
} else {
m.state = StateUninitialized
}
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.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)
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
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
}
// Rate limiting.
now := time.Now()
if now.Before(m.lockoutUntil) {
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
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)
if err != nil {
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
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
}
if m.mek != nil {
crypto.Zeroize(m.mek)
m.mek = nil
}
m.barrier.Seal()
m.state = StateSealed
return nil
}

136
internal/seal/seal_test.go Normal file
View File

@@ -0,0 +1,136 @@
package seal
import (
"context"
"path/filepath"
"testing"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/db"
)
func setupSeal(t *testing.T) (*Manager, func()) {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate: %v", err)
}
b := barrier.NewAESGCMBarrier(database)
mgr := NewManager(database, b)
return mgr, func() { database.Close() }
}
func TestSealInitializeAndUnseal(t *testing.T) {
mgr, cleanup := setupSeal(t)
defer cleanup()
if err := mgr.CheckInitialized(); err != nil {
t.Fatalf("CheckInitialized: %v", err)
}
if mgr.State() != StateUninitialized {
t.Fatalf("state: got %v, want Uninitialized", mgr.State())
}
password := []byte("test-password-123")
// Use fast params for testing.
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
if err := mgr.Initialize(context.Background(), password, params); err != nil {
t.Fatalf("Initialize: %v", err)
}
if mgr.State() != StateUnsealed {
t.Fatalf("state after init: got %v, want Unsealed", mgr.State())
}
// Seal.
if err := mgr.Seal(); err != nil {
t.Fatalf("Seal: %v", err)
}
if mgr.State() != StateSealed {
t.Fatalf("state after seal: got %v, want Sealed", mgr.State())
}
// Unseal with correct password.
if err := mgr.Unseal(password); err != nil {
t.Fatalf("Unseal: %v", err)
}
if mgr.State() != StateUnsealed {
t.Fatalf("state after unseal: got %v, want Unsealed", mgr.State())
}
}
func TestSealWrongPassword(t *testing.T) {
mgr, cleanup := setupSeal(t)
defer cleanup()
mgr.CheckInitialized()
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
mgr.Initialize(context.Background(), []byte("correct"), params)
mgr.Seal()
err := mgr.Unseal([]byte("wrong"))
if err != ErrInvalidPassword {
t.Fatalf("expected ErrInvalidPassword, got: %v", err)
}
}
func TestSealDoubleInitialize(t *testing.T) {
mgr, cleanup := setupSeal(t)
defer cleanup()
mgr.CheckInitialized()
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
mgr.Initialize(context.Background(), []byte("password"), params)
err := mgr.Initialize(context.Background(), []byte("password"), params)
if err != ErrAlreadyInitialized {
t.Fatalf("expected ErrAlreadyInitialized, got: %v", err)
}
}
func TestSealCheckInitializedPersists(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// First: initialize.
database, _ := db.Open(dbPath)
db.Migrate(database)
b := barrier.NewAESGCMBarrier(database)
mgr := NewManager(database, b)
mgr.CheckInitialized()
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
mgr.Initialize(context.Background(), []byte("password"), params)
database.Close()
// Second: reopen and check.
database2, _ := db.Open(dbPath)
defer database2.Close()
b2 := barrier.NewAESGCMBarrier(database2)
mgr2 := NewManager(database2, b2)
mgr2.CheckInitialized()
if mgr2.State() != StateSealed {
t.Fatalf("state after reopen: got %v, want Sealed", mgr2.State())
}
}
func TestSealStateString(t *testing.T) {
tests := []struct {
state ServiceState
want string
}{
{StateUninitialized, "uninitialized"},
{StateSealed, "sealed"},
{StateInitializing, "initializing"},
{StateUnsealed, "unsealed"},
}
for _, tt := range tests {
if got := tt.state.String(); got != tt.want {
t.Errorf("State(%d).String() = %q, want %q", tt.state, got, tt.want)
}
}
}