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>
This commit is contained in:
@@ -13,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSealed = errors.New("barrier: sealed")
|
||||
ErrNotFound = errors.New("barrier: entry not found")
|
||||
ErrSealed = errors.New("barrier: sealed")
|
||||
ErrNotFound = errors.New("barrier: entry not found")
|
||||
ErrKeyNotFound = errors.New("barrier: key not found")
|
||||
)
|
||||
|
||||
// Barrier is the encrypted storage barrier interface.
|
||||
@@ -36,11 +37,20 @@ type Barrier interface {
|
||||
List(ctx context.Context, prefix string) ([]string, error)
|
||||
}
|
||||
|
||||
// KeyInfo holds metadata about a barrier key (DEK).
|
||||
type KeyInfo struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RotatedAt string `json:"rotated_at"`
|
||||
}
|
||||
|
||||
// AESGCMBarrier implements Barrier using AES-256-GCM encryption.
|
||||
type AESGCMBarrier struct {
|
||||
db *sql.DB
|
||||
mek []byte
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
mek []byte
|
||||
keys map[string][]byte // key_id → plaintext DEK
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAESGCMBarrier creates a new AES-GCM barrier backed by the given database.
|
||||
@@ -51,15 +61,56 @@ func NewAESGCMBarrier(db *sql.DB) *AESGCMBarrier {
|
||||
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
|
||||
b.keys = make(map[string][]byte)
|
||||
|
||||
// Load DEKs from barrier_keys table.
|
||||
if err := b.loadKeys(); err != nil {
|
||||
// If the table doesn't exist yet (pre-migration), that's OK.
|
||||
// The barrier will use MEK directly for v1 entries.
|
||||
b.keys = make(map[string][]byte)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadKeys decrypts all DEKs from the barrier_keys table into memory.
|
||||
// Caller must hold b.mu.
|
||||
func (b *AESGCMBarrier) loadKeys() error {
|
||||
rows, err := b.db.Query("SELECT key_id, encrypted_dek FROM barrier_keys")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var keyID string
|
||||
var encDEK []byte
|
||||
if err := rows.Scan(&keyID, &encDEK); err != nil {
|
||||
return fmt.Errorf("barrier: scan key %q: %w", keyID, err)
|
||||
}
|
||||
dek, err := crypto.Decrypt(b.mek, encDEK, []byte(keyID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("barrier: decrypt key %q: %w", keyID, err)
|
||||
}
|
||||
b.keys[keyID] = dek
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (b *AESGCMBarrier) Seal() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Zeroize all DEKs.
|
||||
for _, dek := range b.keys {
|
||||
crypto.Zeroize(dek)
|
||||
}
|
||||
b.keys = nil
|
||||
|
||||
if b.mek != nil {
|
||||
crypto.Zeroize(b.mek)
|
||||
b.mek = nil
|
||||
@@ -73,9 +124,22 @@ func (b *AESGCMBarrier) IsSealed() bool {
|
||||
return b.mek == nil
|
||||
}
|
||||
|
||||
// resolveKeyID determines the key ID for a given barrier path.
|
||||
func resolveKeyID(path string) string {
|
||||
// Paths under engine/{type}/{mount}/... use per-engine DEKs.
|
||||
if strings.HasPrefix(path, "engine/") {
|
||||
parts := strings.SplitN(path, "/", 4) // engine/{type}/{mount}/...
|
||||
if len(parts) >= 3 {
|
||||
return "engine/" + parts[1] + "/" + parts[2]
|
||||
}
|
||||
}
|
||||
return "system"
|
||||
}
|
||||
|
||||
func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
|
||||
b.mu.RLock()
|
||||
mek := b.mek
|
||||
keys := b.keys
|
||||
b.mu.RUnlock()
|
||||
if mek == nil {
|
||||
return nil, ErrSealed
|
||||
@@ -91,22 +155,52 @@ func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("barrier: get %q: %w", path, err)
|
||||
}
|
||||
|
||||
plaintext, err := crypto.Decrypt(mek, encrypted)
|
||||
// Check version byte to determine decryption strategy.
|
||||
if len(encrypted) > 0 && encrypted[0] == crypto.BarrierVersionV2 {
|
||||
keyID, err := crypto.ExtractKeyID(encrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("barrier: extract key ID %q: %w", path, err)
|
||||
}
|
||||
dek, ok := keys[keyID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("barrier: %w: %q for path %q", ErrKeyNotFound, keyID, path)
|
||||
}
|
||||
pt, _, err := crypto.DecryptV2(dek, encrypted, []byte(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("barrier: decrypt %q: %w", path, err)
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
// v1 ciphertext — use MEK directly (backward compat).
|
||||
pt, err := crypto.Decrypt(mek, encrypted, []byte(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("barrier: decrypt %q: %w", path, err)
|
||||
}
|
||||
return plaintext, nil
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) error {
|
||||
b.mu.RLock()
|
||||
mek := b.mek
|
||||
keys := b.keys
|
||||
b.mu.RUnlock()
|
||||
if mek == nil {
|
||||
return ErrSealed
|
||||
}
|
||||
|
||||
encrypted, err := crypto.Encrypt(mek, value)
|
||||
keyID := resolveKeyID(path)
|
||||
|
||||
var encrypted []byte
|
||||
var err error
|
||||
|
||||
if dek, ok := keys[keyID]; ok {
|
||||
// Use v2 format with the appropriate DEK.
|
||||
encrypted, err = crypto.EncryptV2(dek, keyID, value, []byte(path))
|
||||
} else {
|
||||
// No DEK registered for this key ID — fall back to MEK with v1 format.
|
||||
encrypted, err = crypto.Encrypt(mek, value, []byte(path))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("barrier: encrypt %q: %w", path, err)
|
||||
}
|
||||
@@ -159,9 +253,394 @@ func (b *AESGCMBarrier) List(ctx context.Context, prefix string) ([]string, erro
|
||||
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()
|
||||
}
|
||||
|
||||
// CreateKey generates a new DEK for the given key ID, wraps it with MEK,
|
||||
// and stores it in the barrier_keys table.
|
||||
func (b *AESGCMBarrier) CreateKey(ctx context.Context, keyID string) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.mek == nil {
|
||||
return ErrSealed
|
||||
}
|
||||
|
||||
if _, exists := b.keys[keyID]; exists {
|
||||
return nil // Already exists.
|
||||
}
|
||||
|
||||
dek, err := crypto.GenerateKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("barrier: generate DEK %q: %w", keyID, err)
|
||||
}
|
||||
|
||||
encDEK, err := crypto.Encrypt(b.mek, dek, []byte(keyID))
|
||||
if err != nil {
|
||||
crypto.Zeroize(dek)
|
||||
return fmt.Errorf("barrier: wrap DEK %q: %w", keyID, err)
|
||||
}
|
||||
|
||||
_, err = b.db.ExecContext(ctx, `
|
||||
INSERT INTO barrier_keys (key_id, version, encrypted_dek)
|
||||
VALUES (?, 1, ?)
|
||||
ON CONFLICT(key_id) DO NOTHING`,
|
||||
keyID, encDEK)
|
||||
if err != nil {
|
||||
crypto.Zeroize(dek)
|
||||
return fmt.Errorf("barrier: store DEK %q: %w", keyID, err)
|
||||
}
|
||||
|
||||
b.keys[keyID] = dek
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateKey generates a new DEK for the given key ID and re-encrypts all
|
||||
// barrier entries under that key ID's prefix with the new DEK.
|
||||
func (b *AESGCMBarrier) RotateKey(ctx context.Context, keyID string) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.mek == nil {
|
||||
return ErrSealed
|
||||
}
|
||||
|
||||
oldDEK, ok := b.keys[keyID]
|
||||
if !ok {
|
||||
return fmt.Errorf("barrier: %w: %q", ErrKeyNotFound, keyID)
|
||||
}
|
||||
|
||||
// Generate new DEK.
|
||||
newDEK, err := crypto.GenerateKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("barrier: generate DEK: %w", err)
|
||||
}
|
||||
|
||||
// Wrap new DEK with MEK.
|
||||
encDEK, err := crypto.Encrypt(b.mek, newDEK, []byte(keyID))
|
||||
if err != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: wrap DEK: %w", err)
|
||||
}
|
||||
|
||||
// Determine the prefix for entries encrypted with this key.
|
||||
prefix := keyID + "/"
|
||||
if keyID == "system" {
|
||||
// System key covers non-engine paths. Re-encrypt everything
|
||||
// that doesn't start with "engine/".
|
||||
prefix = ""
|
||||
}
|
||||
|
||||
// Re-encrypt all entries under this key ID.
|
||||
tx, err := b.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Update the key in barrier_keys.
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE barrier_keys SET encrypted_dek = ?, version = version + 1, rotated_at = datetime('now')
|
||||
WHERE key_id = ?`, encDEK, keyID)
|
||||
if err != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: update key: %w", err)
|
||||
}
|
||||
|
||||
// Fetch and re-encrypt entries.
|
||||
var query string
|
||||
var args []interface{}
|
||||
if keyID == "system" {
|
||||
query = "SELECT path, value FROM barrier_entries WHERE path NOT LIKE 'engine/%'"
|
||||
} else {
|
||||
query = "SELECT path, value FROM barrier_entries WHERE path LIKE ?"
|
||||
args = append(args, prefix+"%")
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: query entries: %w", err)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
path string
|
||||
value []byte
|
||||
}
|
||||
var entries []entry
|
||||
for rows.Next() {
|
||||
var e entry
|
||||
if err := rows.Scan(&e.path, &e.value); err != nil {
|
||||
_ = rows.Close()
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: scan entry: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
_ = rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: rows error: %w", err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
// Decrypt with old DEK (handle v1 or v2).
|
||||
var plaintext []byte
|
||||
if len(e.value) > 0 && e.value[0] == crypto.BarrierVersionV2 {
|
||||
pt, _, decErr := crypto.DecryptV2(oldDEK, e.value, []byte(e.path))
|
||||
if decErr != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: decrypt %q during rotation: %w", e.path, decErr)
|
||||
}
|
||||
plaintext = pt
|
||||
} else {
|
||||
// v1: encrypted with MEK.
|
||||
pt, decErr := crypto.Decrypt(b.mek, e.value, []byte(e.path))
|
||||
if decErr != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: decrypt v1 %q during rotation: %w", e.path, decErr)
|
||||
}
|
||||
plaintext = pt
|
||||
}
|
||||
|
||||
// Re-encrypt with new DEK using v2 format.
|
||||
newCiphertext, encErr := crypto.EncryptV2(newDEK, keyID, plaintext, []byte(e.path))
|
||||
if encErr != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: re-encrypt %q: %w", e.path, encErr)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE barrier_entries SET value = ?, updated_at = datetime('now') WHERE path = ?",
|
||||
newCiphertext, e.path)
|
||||
if err != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: update %q: %w", e.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
crypto.Zeroize(newDEK)
|
||||
return fmt.Errorf("barrier: commit rotation: %w", err)
|
||||
}
|
||||
|
||||
// Swap the in-memory key.
|
||||
crypto.Zeroize(oldDEK)
|
||||
b.keys[keyID] = newDEK
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListKeys returns metadata about all registered barrier keys.
|
||||
func (b *AESGCMBarrier) ListKeys(ctx context.Context) ([]KeyInfo, error) {
|
||||
b.mu.RLock()
|
||||
mek := b.mek
|
||||
b.mu.RUnlock()
|
||||
if mek == nil {
|
||||
return nil, ErrSealed
|
||||
}
|
||||
|
||||
rows, err := b.db.QueryContext(ctx,
|
||||
"SELECT key_id, version, created_at, rotated_at FROM barrier_keys ORDER BY key_id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("barrier: list keys: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var keys []KeyInfo
|
||||
for rows.Next() {
|
||||
var ki KeyInfo
|
||||
if err := rows.Scan(&ki.KeyID, &ki.Version, &ki.CreatedAt, &ki.RotatedAt); err != nil {
|
||||
return nil, fmt.Errorf("barrier: scan key info: %w", err)
|
||||
}
|
||||
keys = append(keys, ki)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// MigrateToV2 creates per-engine DEKs and re-encrypts entries from v1
|
||||
// (MEK-encrypted) to v2 (DEK-encrypted) format. On first call after upgrade,
|
||||
// it creates a "system" DEK equal to the MEK for zero-cost backward compat,
|
||||
// then creates per-engine DEKs and re-encrypts those entries.
|
||||
func (b *AESGCMBarrier) MigrateToV2(ctx context.Context) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.mek == nil {
|
||||
return 0, ErrSealed
|
||||
}
|
||||
|
||||
// Ensure the "system" key exists.
|
||||
if _, ok := b.keys["system"]; !ok {
|
||||
if err := b.createKeyLocked(ctx, "system"); err != nil {
|
||||
return 0, fmt.Errorf("barrier: create system DEK: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Find all entries still in v1 format.
|
||||
rows, err := b.db.QueryContext(ctx, "SELECT path, value FROM barrier_entries")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("barrier: query entries: %w", err)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
path string
|
||||
value []byte
|
||||
}
|
||||
var toMigrate []entry
|
||||
for rows.Next() {
|
||||
var e entry
|
||||
if err := rows.Scan(&e.path, &e.value); err != nil {
|
||||
_ = rows.Close()
|
||||
return 0, fmt.Errorf("barrier: scan: %w", err)
|
||||
}
|
||||
if len(e.value) > 0 && e.value[0] == crypto.BarrierVersionV1 {
|
||||
toMigrate = append(toMigrate, e)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(toMigrate) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := b.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("barrier: begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
migrated := 0
|
||||
for _, e := range toMigrate {
|
||||
// Decrypt with MEK (v1).
|
||||
plaintext, decErr := crypto.Decrypt(b.mek, e.value, []byte(e.path))
|
||||
if decErr != nil {
|
||||
return migrated, fmt.Errorf("barrier: decrypt %q: %w", e.path, decErr)
|
||||
}
|
||||
|
||||
keyID := resolveKeyID(e.path)
|
||||
|
||||
// Ensure the DEK exists for this key ID.
|
||||
if _, ok := b.keys[keyID]; !ok {
|
||||
if err := b.createKeyLockedTx(ctx, tx, keyID); err != nil {
|
||||
return migrated, fmt.Errorf("barrier: create DEK %q: %w", keyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
dek := b.keys[keyID]
|
||||
newCiphertext, encErr := crypto.EncryptV2(dek, keyID, plaintext, []byte(e.path))
|
||||
if encErr != nil {
|
||||
return migrated, fmt.Errorf("barrier: encrypt v2 %q: %w", e.path, encErr)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE barrier_entries SET value = ?, updated_at = datetime('now') WHERE path = ?",
|
||||
newCiphertext, e.path)
|
||||
if err != nil {
|
||||
return migrated, fmt.Errorf("barrier: update %q: %w", e.path, err)
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return migrated, fmt.Errorf("barrier: commit migration: %w", err)
|
||||
}
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
// createKeyLocked generates and stores a new DEK. Caller must hold b.mu.
|
||||
func (b *AESGCMBarrier) createKeyLocked(ctx context.Context, keyID string) error {
|
||||
dek, err := crypto.GenerateKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encDEK, err := crypto.Encrypt(b.mek, dek, []byte(keyID))
|
||||
if err != nil {
|
||||
crypto.Zeroize(dek)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = b.db.ExecContext(ctx, `
|
||||
INSERT INTO barrier_keys (key_id, version, encrypted_dek)
|
||||
VALUES (?, 1, ?) ON CONFLICT(key_id) DO NOTHING`, keyID, encDEK)
|
||||
if err != nil {
|
||||
crypto.Zeroize(dek)
|
||||
return err
|
||||
}
|
||||
|
||||
b.keys[keyID] = dek
|
||||
return nil
|
||||
}
|
||||
|
||||
// createKeyLockedTx is like createKeyLocked but uses an existing transaction.
|
||||
func (b *AESGCMBarrier) createKeyLockedTx(ctx context.Context, tx *sql.Tx, keyID string) error {
|
||||
dek, err := crypto.GenerateKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encDEK, err := crypto.Encrypt(b.mek, dek, []byte(keyID))
|
||||
if err != nil {
|
||||
crypto.Zeroize(dek)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO barrier_keys (key_id, version, encrypted_dek)
|
||||
VALUES (?, 1, ?) ON CONFLICT(key_id) DO NOTHING`, keyID, encDEK)
|
||||
if err != nil {
|
||||
crypto.Zeroize(dek)
|
||||
return err
|
||||
}
|
||||
|
||||
b.keys[keyID] = dek
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReWrapKeys re-encrypts all DEKs with a new MEK. Called during MEK rotation.
|
||||
// The new MEK is already set in b.mek by the caller.
|
||||
func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.mek == nil {
|
||||
return ErrSealed
|
||||
}
|
||||
|
||||
tx, err := b.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("barrier: begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for keyID, dek := range b.keys {
|
||||
encDEK, err := crypto.Encrypt(newMEK, dek, []byte(keyID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("barrier: re-wrap key %q: %w", keyID, err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE barrier_keys SET encrypted_dek = ?, rotated_at = datetime('now') WHERE key_id = ?",
|
||||
encDEK, keyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("barrier: update key %q: %w", keyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("barrier: commit re-wrap: %w", err)
|
||||
}
|
||||
|
||||
// Update the MEK in memory.
|
||||
crypto.Zeroize(b.mek)
|
||||
k := make([]byte, len(newMEK))
|
||||
copy(k, newMEK)
|
||||
b.mek = k
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -158,3 +158,309 @@ func TestBarrierOverwrite(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user