Files
metacrypt/internal/seal/seal.go
Kyle Isom 167db48eb4 Add ACME (RFC 8555) server and Go client library
Implements full ACME protocol support in Metacrypt:

- internal/acme: core types, JWS verification (ES256/384/512 + RS256),
  nonce store, per-mount handler, all RFC 8555 protocol endpoints,
  HTTP-01 and DNS-01 challenge validation, EAB management
- internal/server/acme.go: management REST routes (EAB create, config,
  list accounts/orders) + ACME protocol route dispatch
- proto/metacrypt/v1/acme.proto: ACMEService (CreateEAB, SetConfig,
  ListAccounts, ListOrders) — protocol endpoints are HTTP-only per RFC
- clients/go: new Go module with MCIAS-auth bootstrap, ACME account
  registration, certificate issuance/renewal, HTTP-01 and DNS-01
  challenge providers
- .claude/launch.json: dev server configuration

EAB is required for all account creation; MCIAS-authenticated users
obtain a single-use KID + HMAC-SHA256 key via POST /v1/acme/{mount}/eab.
2026-03-15 08:09:12 -07:00

250 lines
5.8 KiB
Go

// 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,
}
}
// 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
} 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
}