Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)

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:
2026-03-16 18:27:44 -07:00
parent ac4577f778
commit 64d921827e
44 changed files with 5184 additions and 90 deletions

View File

@@ -0,0 +1,191 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/db"
)
var migrateAADCmd = &cobra.Command{
Use: "migrate-aad",
Short: "Migrate barrier entries to path-bound AAD encryption",
Long: `Re-encrypts all barrier entries so that each entry's path is included
as GCM additional authenticated data (AAD). Entries already encrypted with
AAD are left untouched. Requires the unseal password.
This is a one-off migration for the nil-AAD to path-AAD transition. Run it
while the server is stopped to avoid concurrent access.`,
RunE: runMigrateAAD,
}
var migrateAADDryRun bool
func init() {
migrateAADCmd.Flags().BoolVar(&migrateAADDryRun, "dry-run", false, "report what would be migrated without writing")
rootCmd.AddCommand(migrateAADCmd)
}
func runMigrateAAD(cmd *cobra.Command, args []string) error {
configPath := cfgFile
if configPath == "" {
configPath = "/srv/metacrypt/metacrypt.toml"
}
cfg, err := config.Load(configPath)
if err != nil {
return err
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
return err
}
defer func() { _ = database.Close() }()
// Read unseal password.
fmt.Fprint(os.Stderr, "Unseal password: ")
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Fprintln(os.Stderr)
if err != nil {
return fmt.Errorf("read password: %w", err)
}
defer crypto.Zeroize(passwordBytes)
// Load seal config and derive MEK.
mek, err := deriveMEK(database, passwordBytes)
if err != nil {
return err
}
defer crypto.Zeroize(mek)
// Enumerate all barrier entries.
ctx := context.Background()
rows, err := database.QueryContext(ctx, "SELECT path, value FROM barrier_entries")
if err != nil {
return fmt.Errorf("query entries: %w", err)
}
defer func() { _ = rows.Close() }()
type entry struct {
path string
value []byte
}
var toMigrate []entry
var alreadyMigrated, total int
for rows.Next() {
var path string
var encrypted []byte
if err := rows.Scan(&path, &encrypted); err != nil {
return fmt.Errorf("scan entry: %w", err)
}
total++
// Try decrypting with path AAD first (already migrated).
if _, err := crypto.Decrypt(mek, encrypted, []byte(path)); err == nil {
alreadyMigrated++
continue
}
// Try decrypting with nil AAD (needs migration).
if _, err := crypto.Decrypt(mek, encrypted, nil); err != nil {
return fmt.Errorf("entry %q: cannot decrypt with AAD or without AAD — data may be corrupt", path)
}
toMigrate = append(toMigrate, entry{path: path, value: encrypted})
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate entries: %w", err)
}
fmt.Printf("Total entries: %d\n", total)
fmt.Printf("Already migrated: %d\n", alreadyMigrated)
fmt.Printf("Need migration: %d\n", len(toMigrate))
if len(toMigrate) == 0 {
fmt.Println("Nothing to migrate.")
return nil
}
if migrateAADDryRun {
fmt.Println("\nDry run — entries that would be migrated:")
for _, e := range toMigrate {
fmt.Printf(" %s\n", e.path)
}
return nil
}
// Migrate entries in a transaction.
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.PrepareContext(ctx, "UPDATE barrier_entries SET value = ?, updated_at = datetime('now') WHERE path = ?")
if err != nil {
return fmt.Errorf("prepare update: %w", err)
}
defer func() { _ = stmt.Close() }()
for _, e := range toMigrate {
// Decrypt with nil AAD.
plaintext, err := crypto.Decrypt(mek, e.value, nil)
if err != nil {
return fmt.Errorf("decrypt %q: %w", e.path, err)
}
// Re-encrypt with path AAD.
newEncrypted, err := crypto.Encrypt(mek, plaintext, []byte(e.path))
crypto.Zeroize(plaintext)
if err != nil {
return fmt.Errorf("encrypt %q: %w", e.path, err)
}
if _, err := stmt.ExecContext(ctx, newEncrypted, e.path); err != nil {
return fmt.Errorf("update %q: %w", e.path, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
fmt.Printf("Migrated %d entries.\n", len(toMigrate))
return nil
}
// deriveMEK reads the seal config and derives the MEK from the password.
func deriveMEK(database *sql.DB, password []byte) ([]byte, error) {
var (
encryptedMEK []byte
salt []byte
argTime, argMem uint32
argThreads uint8
)
err := database.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 nil, fmt.Errorf("read seal config: %w", err)
}
params := crypto.Argon2Params{Time: argTime, Memory: argMem, Threads: argThreads}
kwk := crypto.DeriveKey(password, salt, params)
defer crypto.Zeroize(kwk)
mek, err := crypto.Decrypt(kwk, encryptedMEK, nil)
if err != nil {
return nil, fmt.Errorf("invalid unseal password")
}
return mek, nil
}

View File

@@ -32,7 +32,7 @@ func init() {
}
func runStatus(cmd *cobra.Command, args []string) error {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS13}
if statusCACert != "" {
pem, err := os.ReadFile(statusCACert) //nolint:gosec

View File

@@ -57,7 +57,7 @@ func runUnseal(cmd *cobra.Command, args []string) error {
}
func buildTLSConfig(caCertPath string) (*tls.Config, error) {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS13}
if caCertPath != "" {
pem, err := os.ReadFile(caCertPath) //nolint:gosec
if err != nil {