Files
sgard/garden/encrypt_rotate_test.go
Kyle Isom 5529fff649 Step 24: DEK rotation.
RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
re-wraps with all existing KEK slots (passphrase + FIDO2). CLI wired
as `sgard encrypt rotate-dek`. 4 tests covering rotation, persistence,
FIDO2 re-wrap, and requires-unlock guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:01:57 -07:00

240 lines
5.9 KiB
Go

package garden
import (
"os"
"path/filepath"
"testing"
)
func TestRotateDEK(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "test-passphrase"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Add an encrypted file and a plaintext file.
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("secret data"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
plainFile := filepath.Join(root, "plain")
if err := os.WriteFile(plainFile, []byte("plain data"), 0o644); err != nil {
t.Fatalf("writing plain: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
if err := g.Add([]string{plainFile}); err != nil {
t.Fatalf("Add plain: %v", err)
}
// Record pre-rotation state.
var origEncHash, origEncPtHash, origPlainHash string
for _, e := range g.manifest.Files {
if e.Encrypted {
origEncHash = e.Hash
origEncPtHash = e.PlaintextHash
} else {
origPlainHash = e.Hash
}
}
oldDEK := make([]byte, len(g.dek))
copy(oldDEK, g.dek)
// Rotate.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// DEK should have changed.
if string(g.dek) == string(oldDEK) {
t.Error("DEK should change after rotation")
}
// Check manifest entries.
for _, e := range g.manifest.Files {
if e.Encrypted {
// Ciphertext hash should change (new nonce + new key).
if e.Hash == origEncHash {
t.Error("encrypted entry hash should change after rotation")
}
// Plaintext hash should NOT change.
if e.PlaintextHash != origEncPtHash {
t.Errorf("plaintext hash changed: %s → %s", origEncPtHash, e.PlaintextHash)
}
} else {
// Plaintext entry should be untouched.
if e.Hash != origPlainHash {
t.Errorf("plaintext entry hash changed: %s → %s", origPlainHash, e.Hash)
}
}
}
// Verify the new blob decrypts correctly.
_ = os.Remove(secretFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore after rotation: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading restored file: %v", err)
}
if string(got) != "secret data" {
t.Errorf("restored content = %q, want %q", got, "secret data")
}
}
func TestRotateDEK_UnlockWithNewPassphrase(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "original"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Rotate with the same passphrase.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// Re-open and verify unlock still works with the same passphrase.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := g2.UnlockDEK(prompt); err != nil {
t.Fatalf("UnlockDEK after rotation: %v", err)
}
// Verify restore works.
_ = os.Remove(secretFile)
if err := g2.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore after re-open: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "data" {
t.Errorf("got %q, want %q", got, "data")
}
}
func TestRotateDEK_WithFIDO2(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "passphrase"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Add a FIDO2 slot.
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "testkey"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("fido2 data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Rotate with both passphrase and FIDO2 device.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt, device); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// Both slots should still exist.
slots := g.ListSlots()
if _, ok := slots["passphrase"]; !ok {
t.Error("passphrase slot should still exist after rotation")
}
if _, ok := slots["fido2/testkey"]; !ok {
t.Error("fido2/testkey slot should still exist after rotation")
}
// Unlock via FIDO2 should work.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := g2.UnlockDEK(nil, device); err != nil {
t.Fatalf("UnlockDEK via FIDO2 after rotation: %v", err)
}
// Verify decryption.
_ = os.Remove(secretFile)
if err := g2.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "fido2 data" {
t.Errorf("got %q, want %q", got, "fido2 data")
}
}
func TestRotateDEK_RequiresUnlock(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
if err := g.EncryptInit("pass"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Re-open without unlocking.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
err = g2.RotateDEK(func() (string, error) { return "pass", nil })
if err == nil {
t.Fatal("RotateDEK without unlock should fail")
}
}