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:
191
cmd/metacrypt/migrate_aad.go
Normal file
191
cmd/metacrypt/migrate_aad.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user