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>
168 lines
3.9 KiB
Go
168 lines
3.9 KiB
Go
// 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()
|
|
}
|