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