Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs stored in a new barrier_keys table, rather than encrypting all barrier entries directly. A v2 ciphertext format (0x02) embeds the key ID so the barrier can resolve which DEK to use on decryption. v1 ciphertext remains supported for backward compatibility. Key changes: - crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs - barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys) - seal: RotateMEK re-wraps DEKs without re-encrypting data - engine: Mount auto-creates per-engine DEK - REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate - proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate - db: migration v2 adds barrier_keys table Also includes: security audit report, CSRF protection, engine design specs (sshca, transit, user), path-bound AAD migration tool, policy engine enhancements, and ARCHITECTURE.md updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
467 lines
11 KiB
Go
467 lines
11 KiB
Go
package barrier
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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 !errors.Is(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 !errors.Is(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"); !errors.Is(err, ErrSealed) {
|
|
t.Fatalf("Get when sealed: expected ErrSealed, got: %v", err)
|
|
}
|
|
if err := b.Put(ctx, "test", []byte("data")); !errors.Is(err, ErrSealed) {
|
|
t.Fatalf("Put when sealed: expected ErrSealed, got: %v", err)
|
|
}
|
|
if err := b.Delete(ctx, "test"); !errors.Is(err, ErrSealed) {
|
|
t.Fatalf("Delete when sealed: expected ErrSealed, got: %v", err)
|
|
}
|
|
if _, err := b.List(ctx, "test"); !errors.Is(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")
|
|
}
|
|
}
|
|
|
|
// --- DEK / Key Registry Tests ---
|
|
|
|
func TestBarrierCreateKey(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
if err := b.CreateKey(ctx, "engine/ca/prod"); err != nil {
|
|
t.Fatalf("CreateKey: %v", err)
|
|
}
|
|
|
|
// Duplicate should be a no-op.
|
|
if err := b.CreateKey(ctx, "engine/ca/prod"); err != nil {
|
|
t.Fatalf("CreateKey duplicate: %v", err)
|
|
}
|
|
|
|
keys, err := b.ListKeys(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListKeys: %v", err)
|
|
}
|
|
if len(keys) != 1 {
|
|
t.Fatalf("expected 1 key, got %d", len(keys))
|
|
}
|
|
if keys[0].KeyID != "engine/ca/prod" {
|
|
t.Fatalf("key ID: got %q, want %q", keys[0].KeyID, "engine/ca/prod")
|
|
}
|
|
}
|
|
|
|
func TestBarrierDEKEncryption(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
// Create a DEK for ca/prod.
|
|
_ = b.CreateKey(ctx, "engine/ca/prod")
|
|
|
|
// Write data under the engine path — should use DEK.
|
|
data := []byte("engine secret data")
|
|
if err := b.Put(ctx, "engine/ca/prod/config.json", data); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
got, err := b.Get(ctx, "engine/ca/prod/config.json")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if string(got) != string(data) {
|
|
t.Fatalf("roundtrip: got %q, want %q", got, data)
|
|
}
|
|
|
|
// Verify the raw ciphertext is v2 format.
|
|
var raw []byte
|
|
err = b.db.QueryRowContext(ctx,
|
|
"SELECT value FROM barrier_entries WHERE path = ?",
|
|
"engine/ca/prod/config.json").Scan(&raw)
|
|
if err != nil {
|
|
t.Fatalf("read raw: %v", err)
|
|
}
|
|
if raw[0] != crypto.BarrierVersionV2 {
|
|
t.Fatalf("expected v2 ciphertext, got version %d", raw[0])
|
|
}
|
|
}
|
|
|
|
func TestBarrierV1FallbackWithoutDEK(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
// Write system data without any DEK — should use v1 with MEK.
|
|
data := []byte("system data")
|
|
if err := b.Put(ctx, "policy/rule1", data); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
got, err := b.Get(ctx, "policy/rule1")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if string(got) != string(data) {
|
|
t.Fatalf("roundtrip: got %q, want %q", got, data)
|
|
}
|
|
}
|
|
|
|
func TestBarrierSystemDEK(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
// Create system DEK.
|
|
_ = b.CreateKey(ctx, "system")
|
|
|
|
// Write system data — should use system DEK with v2 format.
|
|
data := []byte("system with dek")
|
|
if err := b.Put(ctx, "policy/rule1", data); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
got, err := b.Get(ctx, "policy/rule1")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if string(got) != string(data) {
|
|
t.Fatalf("roundtrip: got %q, want %q", got, data)
|
|
}
|
|
|
|
// Verify v2 format.
|
|
var raw []byte
|
|
_ = b.db.QueryRowContext(ctx,
|
|
"SELECT value FROM barrier_entries WHERE path = ?",
|
|
"policy/rule1").Scan(&raw)
|
|
if raw[0] != crypto.BarrierVersionV2 {
|
|
t.Fatalf("expected v2 ciphertext, got version %d", raw[0])
|
|
}
|
|
}
|
|
|
|
func TestBarrierRotateKey(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
_ = b.CreateKey(ctx, "engine/ca/prod")
|
|
|
|
// Write some data.
|
|
_ = b.Put(ctx, "engine/ca/prod/cert1", []byte("cert-data-1"))
|
|
_ = b.Put(ctx, "engine/ca/prod/cert2", []byte("cert-data-2"))
|
|
|
|
// Rotate the key.
|
|
if err := b.RotateKey(ctx, "engine/ca/prod"); err != nil {
|
|
t.Fatalf("RotateKey: %v", err)
|
|
}
|
|
|
|
// Data should still be readable.
|
|
got, err := b.Get(ctx, "engine/ca/prod/cert1")
|
|
if err != nil {
|
|
t.Fatalf("Get after rotation: %v", err)
|
|
}
|
|
if string(got) != "cert-data-1" {
|
|
t.Fatalf("data corrupted after rotation: %q", got)
|
|
}
|
|
|
|
got2, err := b.Get(ctx, "engine/ca/prod/cert2")
|
|
if err != nil {
|
|
t.Fatalf("Get after rotation: %v", err)
|
|
}
|
|
if string(got2) != "cert-data-2" {
|
|
t.Fatalf("data corrupted after rotation: %q", got2)
|
|
}
|
|
|
|
// Check key version incremented.
|
|
keys, _ := b.ListKeys(ctx)
|
|
for _, k := range keys {
|
|
if k.KeyID == "engine/ca/prod" && k.Version != 2 {
|
|
t.Fatalf("expected version 2 after rotation, got %d", k.Version)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBarrierRotateKeyNotFound(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
err := b.RotateKey(ctx, "nonexistent")
|
|
if !errors.Is(err, ErrKeyNotFound) {
|
|
t.Fatalf("expected ErrKeyNotFound, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBarrierMigrateToV2(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
// Write v1 data (no DEKs registered, so it uses MEK).
|
|
_ = b.Put(ctx, "policy/rule1", []byte("policy-data"))
|
|
_ = b.Put(ctx, "engine/ca/prod/config", []byte("ca-config"))
|
|
_ = b.Put(ctx, "engine/transit/main/key1", []byte("transit-key"))
|
|
|
|
// Migrate.
|
|
migrated, err := b.MigrateToV2(ctx)
|
|
if err != nil {
|
|
t.Fatalf("MigrateToV2: %v", err)
|
|
}
|
|
if migrated != 3 {
|
|
t.Fatalf("expected 3 entries migrated, got %d", migrated)
|
|
}
|
|
|
|
// All data should still be readable.
|
|
got, err := b.Get(ctx, "policy/rule1")
|
|
if err != nil {
|
|
t.Fatalf("Get policy after migration: %v", err)
|
|
}
|
|
if string(got) != "policy-data" {
|
|
t.Fatalf("policy data: got %q", got)
|
|
}
|
|
|
|
got, err = b.Get(ctx, "engine/ca/prod/config")
|
|
if err != nil {
|
|
t.Fatalf("Get engine data after migration: %v", err)
|
|
}
|
|
if string(got) != "ca-config" {
|
|
t.Fatalf("engine data: got %q", got)
|
|
}
|
|
|
|
// Running again should migrate 0 (all already v2).
|
|
migrated2, err := b.MigrateToV2(ctx)
|
|
if err != nil {
|
|
t.Fatalf("MigrateToV2 second run: %v", err)
|
|
}
|
|
if migrated2 != 0 {
|
|
t.Fatalf("expected 0 entries on second migration, got %d", migrated2)
|
|
}
|
|
}
|
|
|
|
func TestBarrierSealUnsealPreservesDEKs(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
// Create DEK and write data.
|
|
_ = b.CreateKey(ctx, "engine/ca/prod")
|
|
_ = b.Put(ctx, "engine/ca/prod/secret", []byte("my-secret"))
|
|
|
|
// Seal and unseal.
|
|
_ = b.Seal()
|
|
_ = b.Unseal(mek)
|
|
|
|
// Data should still be readable (DEKs reloaded from barrier_keys).
|
|
got, err := b.Get(ctx, "engine/ca/prod/secret")
|
|
if err != nil {
|
|
t.Fatalf("Get after seal/unseal: %v", err)
|
|
}
|
|
if string(got) != "my-secret" {
|
|
t.Fatalf("data after seal/unseal: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBarrierReWrapKeys(t *testing.T) {
|
|
b, cleanup := setupBarrier(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
mek, _ := crypto.GenerateKey()
|
|
_ = b.Unseal(mek)
|
|
|
|
_ = b.CreateKey(ctx, "system")
|
|
_ = b.CreateKey(ctx, "engine/ca/prod")
|
|
|
|
_ = b.Put(ctx, "policy/rule1", []byte("policy"))
|
|
_ = b.Put(ctx, "engine/ca/prod/cert", []byte("cert"))
|
|
|
|
// Re-wrap with new MEK.
|
|
newMEK, _ := crypto.GenerateKey()
|
|
if err := b.ReWrapKeys(ctx, newMEK); err != nil {
|
|
t.Fatalf("ReWrapKeys: %v", err)
|
|
}
|
|
|
|
// Data should still be readable.
|
|
got, _ := b.Get(ctx, "policy/rule1")
|
|
if string(got) != "policy" {
|
|
t.Fatalf("policy after rewrap: got %q", got)
|
|
}
|
|
got2, _ := b.Get(ctx, "engine/ca/prod/cert")
|
|
if string(got2) != "cert" {
|
|
t.Fatalf("cert after rewrap: got %q", got2)
|
|
}
|
|
|
|
// Seal and unseal with new MEK should work.
|
|
_ = b.Seal()
|
|
if err := b.Unseal(newMEK); err != nil {
|
|
t.Fatalf("Unseal with new MEK: %v", err)
|
|
}
|
|
|
|
got3, err := b.Get(ctx, "engine/ca/prod/cert")
|
|
if err != nil {
|
|
t.Fatalf("Get after unseal with new MEK: %v", err)
|
|
}
|
|
if string(got3) != "cert" {
|
|
t.Fatalf("data after new MEK unseal: got %q", got3)
|
|
}
|
|
}
|