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

167
internal/barrier/barrier.go Normal file
View File

@@ -0,0 +1,167 @@
// Package barrier provides an encrypted storage barrier backed by SQLite.
package barrier
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
)
var (
ErrSealed = errors.New("barrier: sealed")
ErrNotFound = errors.New("barrier: entry not found")
)
// Barrier is the encrypted storage barrier interface.
type Barrier interface {
// Unseal opens the barrier with the given master encryption key.
Unseal(mek []byte) error
// Seal closes the barrier and zeroizes the key material.
Seal() error
// IsSealed returns true if the barrier is sealed.
IsSealed() bool
// Get retrieves and decrypts a value by path.
Get(ctx context.Context, path string) ([]byte, error)
// Put encrypts and stores a value at the given path.
Put(ctx context.Context, path string, value []byte) error
// Delete removes an entry by path.
Delete(ctx context.Context, path string) error
// List returns paths with the given prefix.
List(ctx context.Context, prefix string) ([]string, error)
}
// AESGCMBarrier implements Barrier using AES-256-GCM encryption.
type AESGCMBarrier struct {
db *sql.DB
mu sync.RWMutex
mek []byte // nil when sealed
}
// NewAESGCMBarrier creates a new AES-GCM barrier backed by the given database.
func NewAESGCMBarrier(db *sql.DB) *AESGCMBarrier {
return &AESGCMBarrier{db: db}
}
func (b *AESGCMBarrier) Unseal(mek []byte) error {
b.mu.Lock()
defer b.mu.Unlock()
k := make([]byte, len(mek))
copy(k, mek)
b.mek = k
return nil
}
func (b *AESGCMBarrier) Seal() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.mek != nil {
crypto.Zeroize(b.mek)
b.mek = nil
}
return nil
}
func (b *AESGCMBarrier) IsSealed() bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.mek == nil
}
func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
b.mu.RLock()
mek := b.mek
b.mu.RUnlock()
if mek == nil {
return nil, ErrSealed
}
var encrypted []byte
err := b.db.QueryRowContext(ctx,
"SELECT value FROM barrier_entries WHERE path = ?", path).Scan(&encrypted)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("barrier: get %q: %w", path, err)
}
plaintext, err := crypto.Decrypt(mek, encrypted)
if err != nil {
return nil, fmt.Errorf("barrier: decrypt %q: %w", path, err)
}
return plaintext, nil
}
func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) error {
b.mu.RLock()
mek := b.mek
b.mu.RUnlock()
if mek == nil {
return ErrSealed
}
encrypted, err := crypto.Encrypt(mek, value)
if err != nil {
return fmt.Errorf("barrier: encrypt %q: %w", path, err)
}
_, err = b.db.ExecContext(ctx, `
INSERT INTO barrier_entries (path, value) VALUES (?, ?)
ON CONFLICT(path) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
path, encrypted)
if err != nil {
return fmt.Errorf("barrier: put %q: %w", path, err)
}
return nil
}
func (b *AESGCMBarrier) Delete(ctx context.Context, path string) error {
b.mu.RLock()
mek := b.mek
b.mu.RUnlock()
if mek == nil {
return ErrSealed
}
_, err := b.db.ExecContext(ctx,
"DELETE FROM barrier_entries WHERE path = ?", path)
if err != nil {
return fmt.Errorf("barrier: delete %q: %w", path, err)
}
return nil
}
func (b *AESGCMBarrier) List(ctx context.Context, prefix string) ([]string, error) {
b.mu.RLock()
mek := b.mek
b.mu.RUnlock()
if mek == nil {
return nil, ErrSealed
}
rows, err := b.db.QueryContext(ctx,
"SELECT path FROM barrier_entries WHERE path LIKE ?",
prefix+"%")
if err != nil {
return nil, fmt.Errorf("barrier: list %q: %w", prefix, err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, fmt.Errorf("barrier: list scan: %w", err)
}
// Strip the prefix and return just the next segment.
remainder := strings.TrimPrefix(p, prefix)
paths = append(paths, remainder)
}
return paths, rows.Err()
}

View File

@@ -0,0 +1,159 @@
package barrier
import (
"context"
"path/filepath"
"testing"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/db"
)
func setupBarrier(t *testing.T) (*AESGCMBarrier, 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 := NewAESGCMBarrier(database)
return b, func() { database.Close() }
}
func TestBarrierSealUnseal(t *testing.T) {
b, cleanup := setupBarrier(t)
defer cleanup()
if !b.IsSealed() {
t.Fatal("new barrier should be sealed")
}
mek, _ := crypto.GenerateKey()
if err := b.Unseal(mek); err != nil {
t.Fatalf("Unseal: %v", err)
}
if b.IsSealed() {
t.Fatal("barrier should be unsealed")
}
if err := b.Seal(); err != nil {
t.Fatalf("Seal: %v", err)
}
if !b.IsSealed() {
t.Fatal("barrier should be sealed after Seal()")
}
}
func TestBarrierPutGet(t *testing.T) {
b, cleanup := setupBarrier(t)
defer cleanup()
ctx := context.Background()
mek, _ := crypto.GenerateKey()
b.Unseal(mek)
data := []byte("test value")
if err := b.Put(ctx, "test/path", data); err != nil {
t.Fatalf("Put: %v", err)
}
got, err := b.Get(ctx, "test/path")
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(got) != string(data) {
t.Fatalf("Get: got %q, want %q", got, data)
}
}
func TestBarrierGetNotFound(t *testing.T) {
b, cleanup := setupBarrier(t)
defer cleanup()
ctx := context.Background()
mek, _ := crypto.GenerateKey()
b.Unseal(mek)
_, err := b.Get(ctx, "nonexistent")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got: %v", err)
}
}
func TestBarrierDelete(t *testing.T) {
b, cleanup := setupBarrier(t)
defer cleanup()
ctx := context.Background()
mek, _ := crypto.GenerateKey()
b.Unseal(mek)
b.Put(ctx, "test/delete-me", []byte("data"))
if err := b.Delete(ctx, "test/delete-me"); err != nil {
t.Fatalf("Delete: %v", err)
}
_, err := b.Get(ctx, "test/delete-me")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound after delete, got: %v", err)
}
}
func TestBarrierList(t *testing.T) {
b, cleanup := setupBarrier(t)
defer cleanup()
ctx := context.Background()
mek, _ := crypto.GenerateKey()
b.Unseal(mek)
b.Put(ctx, "engine/ca/default/config", []byte("cfg"))
b.Put(ctx, "engine/ca/default/dek", []byte("key"))
b.Put(ctx, "engine/transit/main/config", []byte("cfg"))
paths, err := b.List(ctx, "engine/ca/")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(paths) != 2 {
t.Fatalf("List: got %d paths, want 2", len(paths))
}
}
func TestBarrierSealedOperations(t *testing.T) {
b, cleanup := setupBarrier(t)
defer cleanup()
ctx := context.Background()
if _, err := b.Get(ctx, "test"); err != ErrSealed {
t.Fatalf("Get when sealed: expected ErrSealed, got: %v", err)
}
if err := b.Put(ctx, "test", []byte("data")); err != ErrSealed {
t.Fatalf("Put when sealed: expected ErrSealed, got: %v", err)
}
if err := b.Delete(ctx, "test"); err != ErrSealed {
t.Fatalf("Delete when sealed: expected ErrSealed, got: %v", err)
}
if _, err := b.List(ctx, "test"); err != ErrSealed {
t.Fatalf("List when sealed: expected ErrSealed, got: %v", err)
}
}
func TestBarrierOverwrite(t *testing.T) {
b, cleanup := setupBarrier(t)
defer cleanup()
ctx := context.Background()
mek, _ := crypto.GenerateKey()
b.Unseal(mek)
b.Put(ctx, "test/overwrite", []byte("v1"))
b.Put(ctx, "test/overwrite", []byte("v2"))
got, _ := b.Get(ctx, "test/overwrite")
if string(got) != "v2" {
t.Fatalf("overwrite: got %q, want %q", got, "v2")
}
}