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.
250 lines
5.8 KiB
Go
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
|
|
}
|