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>
This commit is contained in:
@@ -213,6 +213,140 @@ func (g *Garden) ChangePassphrase(newPassphrase string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
|
||||
// re-wraps the new DEK with all existing KEK slots. The old DEK must
|
||||
// already be unlocked. A passphrase prompt is required to re-derive
|
||||
// the KEK for the passphrase slot. An optional FIDO2 device re-wraps
|
||||
// FIDO2 slots; FIDO2 slots without a matching device are dropped.
|
||||
func (g *Garden) RotateDEK(promptPassphrase func() (string, error), fido2Device ...FIDO2Device) error {
|
||||
if g.dek == nil {
|
||||
return fmt.Errorf("DEK not unlocked")
|
||||
}
|
||||
|
||||
enc := g.manifest.Encryption
|
||||
if enc == nil {
|
||||
return fmt.Errorf("encryption not initialized")
|
||||
}
|
||||
|
||||
oldDEK := g.dek
|
||||
|
||||
// Generate new DEK.
|
||||
newDEK := make([]byte, dekSize)
|
||||
if _, err := rand.Read(newDEK); err != nil {
|
||||
return fmt.Errorf("generating new DEK: %w", err)
|
||||
}
|
||||
|
||||
// Re-encrypt all encrypted blobs.
|
||||
for i := range g.manifest.Files {
|
||||
entry := &g.manifest.Files[i]
|
||||
if !entry.Encrypted || entry.Hash == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read encrypted blob.
|
||||
ciphertext, err := g.store.Read(entry.Hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading blob %s for %s: %w", entry.Hash, entry.Path, err)
|
||||
}
|
||||
|
||||
// Decrypt with old DEK.
|
||||
g.dek = oldDEK
|
||||
plaintext, err := g.decryptBlob(ciphertext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
|
||||
}
|
||||
|
||||
// Re-encrypt with new DEK.
|
||||
g.dek = newDEK
|
||||
newCiphertext, err := g.encryptBlob(plaintext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-encrypting %s: %w", entry.Path, err)
|
||||
}
|
||||
|
||||
// Write new blob.
|
||||
newHash, err := g.store.Write(newCiphertext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing re-encrypted blob for %s: %w", entry.Path, err)
|
||||
}
|
||||
|
||||
entry.Hash = newHash
|
||||
// PlaintextHash stays the same — the plaintext didn't change.
|
||||
}
|
||||
|
||||
// Re-wrap new DEK with all existing KEK slots.
|
||||
for name, slot := range enc.KekSlots {
|
||||
var kek []byte
|
||||
|
||||
switch slot.Type {
|
||||
case "passphrase":
|
||||
if promptPassphrase == nil {
|
||||
return fmt.Errorf("passphrase required to re-wrap slot %q", name)
|
||||
}
|
||||
passphrase, err := promptPassphrase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading passphrase: %w", err)
|
||||
}
|
||||
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding salt for slot %q: %w", name, err)
|
||||
}
|
||||
kek = derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
|
||||
|
||||
case "fido2":
|
||||
var device FIDO2Device
|
||||
if len(fido2Device) > 0 {
|
||||
device = fido2Device[0]
|
||||
}
|
||||
if device == nil || !device.Available() {
|
||||
// Drop FIDO2 slots without a matching device.
|
||||
delete(enc.KekSlots, name)
|
||||
continue
|
||||
}
|
||||
credID, err := base64.StdEncoding.DecodeString(slot.CredentialID)
|
||||
if err != nil {
|
||||
delete(enc.KekSlots, name)
|
||||
continue
|
||||
}
|
||||
if !device.MatchesCredential(credID) {
|
||||
delete(enc.KekSlots, name)
|
||||
continue
|
||||
}
|
||||
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
|
||||
if err != nil {
|
||||
delete(enc.KekSlots, name)
|
||||
continue
|
||||
}
|
||||
fido2KEK, err := device.Derive(credID, salt)
|
||||
if err != nil {
|
||||
delete(enc.KekSlots, name)
|
||||
continue
|
||||
}
|
||||
if len(fido2KEK) < dekSize {
|
||||
delete(enc.KekSlots, name)
|
||||
continue
|
||||
}
|
||||
kek = fido2KEK[:dekSize]
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown slot type %q for slot %q", slot.Type, name)
|
||||
}
|
||||
|
||||
wrappedDEK, err := wrapDEK(newDEK, kek)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-wrapping DEK for slot %q: %w", name, err)
|
||||
}
|
||||
slot.WrappedDEK = base64.StdEncoding.EncodeToString(wrappedDEK)
|
||||
}
|
||||
|
||||
g.dek = newDEK
|
||||
|
||||
if err := g.manifest.Save(g.manifestPath); err != nil {
|
||||
return fmt.Errorf("saving manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NeedsDEK reports whether any of the given entries are encrypted.
|
||||
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool {
|
||||
for _, e := range entries {
|
||||
|
||||
239
garden/encrypt_rotate_test.go
Normal file
239
garden/encrypt_rotate_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user