From 5529fff6498cd92d8ff0dc681f622cb47fc3ab53 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 12:01:57 -0700 Subject: [PATCH] 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) --- ARCHITECTURE.md | 20 ++- PROGRESS.md | 5 +- PROJECT_PLAN.md | 6 +- README.md | 1 + cmd/sgard/encrypt.go | 32 +++++ garden/encrypt.go | 134 +++++++++++++++++++ garden/encrypt_rotate_test.go | 239 ++++++++++++++++++++++++++++++++++ 7 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 garden/encrypt_rotate_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2cd3e38..b9d757d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -577,12 +577,24 @@ Both flags must be specified together on the server side; on the client side `--tls` alone uses the system trust store, and `--tls-ca` adds a custom root. -### Planned: DEK Rotation (Phase 4) +### DEK Rotation `sgard encrypt rotate-dek` generates a new DEK, re-encrypts all -encrypted blobs, and re-wraps the new DEK with all existing KEK slots. -Required when the DEK is suspected compromised (re-wrapping alone is -insufficient since the old DEK could decrypt the existing blobs). +encrypted blobs with the new key, and re-wraps the new DEK with all +existing KEK slots. Required when the DEK is suspected compromised +(re-wrapping alone is insufficient since the old DEK could decrypt +the existing blobs). + +The rotation process: +1. Generate a new random 256-bit DEK +2. For each encrypted entry: decrypt with old DEK, re-encrypt with new DEK, + write new blob to store, update manifest hash (plaintext hash unchanged) +3. Re-derive each KEK (passphrase via Argon2id, FIDO2 via device) and + re-wrap the new DEK. FIDO2 slots without a matching connected device + are dropped during rotation. +4. Save updated manifest + +Plaintext entries are untouched. ### Planned: Multi-Repo + Per-Machine Inclusion (Phase 5) diff --git a/PROGRESS.md b/PROGRESS.md index 05d47d2..d711247 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 4 in progress. Steps 21–23 complete, ready for Step 24. +**Phase:** Phase 4 in progress. Steps 21–24 complete, ready for Step 25. **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 24: DEK Rotation. +Step 25: Real FIDO2 Hardware Binding. ## Known Issues / Decisions Deferred @@ -87,3 +87,4 @@ Step 24: DEK Rotation. | 2026-03-24 | 21 | Lock/unlock toggle commands. garden/lock.go, cmd/sgard/lock.go, 6 tests. | | 2026-03-24 | 22 | Shell completion: cobra built-in, README docs for bash/zsh/fish. | | 2026-03-24 | 23 | TLS transport: sgardd --tls-cert/--tls-key, sgard --tls/--tls-ca, 2 integration tests. | +| 2026-03-24 | 24 | DEK rotation: RotateDEK re-encrypts all blobs, re-wraps all slots, CLI command, 4 tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 3b8a23c..fb07fdb 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -246,9 +246,9 @@ Depends on Steps 17, 18. ### Step 24: DEK Rotation -- [ ] `garden/encrypt.go`: `RotateDEK(promptPassphrase func() (string, error)) error` — generate new DEK, re-encrypt all encrypted blobs, re-wrap with all existing KEK slots -- [ ] `cmd/sgard/encrypt.go`: `sgard encrypt rotate-dek` -- [ ] Tests: rotate DEK, verify all encrypted entries still decrypt correctly +- [x] `garden/encrypt.go`: `RotateDEK(promptPassphrase, fido2Device)` — generate new DEK, re-encrypt all encrypted blobs, re-wrap with all existing KEK slots +- [x] `cmd/sgard/encrypt.go`: `sgard encrypt rotate-dek` +- [x] Tests: rotate DEK, verify decryption, verify plaintext untouched, FIDO2 re-wrap, requires-unlock (4 tests) ### Step 25: Real FIDO2 Hardware Binding diff --git a/README.md b/README.md index 7aec7ec..bb3f3c7 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ but doesn't touch its contents. | `encrypt remove-slot ` | Remove a KEK slot | | `encrypt list-slots` | List all KEK slots | | `encrypt change-passphrase` | Change the passphrase | +| `encrypt rotate-dek` | Generate new DEK and re-encrypt all encrypted blobs | | `add --encrypt ...` | Track files with encryption | ### Remote sync diff --git a/cmd/sgard/encrypt.go b/cmd/sgard/encrypt.go index bcd564c..fb736ed 100644 --- a/cmd/sgard/encrypt.go +++ b/cmd/sgard/encrypt.go @@ -152,6 +152,37 @@ var changePassphraseCmd = &cobra.Command{ }, } +var rotateDEKCmd = &cobra.Command{ + Use: "rotate-dek", + Short: "Generate a new DEK and re-encrypt all encrypted blobs", + Long: "Generates a new data encryption key, re-encrypts all encrypted blobs, and re-wraps the DEK with all KEK slots. Required when the DEK is suspected compromised.", + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + if !g.HasEncryption() { + return fmt.Errorf("encryption not initialized") + } + + // Unlock with current passphrase. + fmt.Println("Enter passphrase to unlock:") + if err := g.UnlockDEK(promptPassphrase); err != nil { + return err + } + + // Rotate — re-prompts for passphrase to re-wrap slot. + fmt.Println("Enter passphrase to re-wrap DEK:") + if err := g.RotateDEK(promptPassphrase); err != nil { + return err + } + + fmt.Println("DEK rotated. All encrypted blobs re-encrypted.") + return nil + }, +} + func init() { encryptInitCmd.Flags().BoolVar(&fido2InitFlag, "fido2", false, "also set up FIDO2 (placeholder)") addFido2Cmd.Flags().StringVar(&fido2LabelFlag, "label", "", "slot label (default: fido2/)") @@ -161,6 +192,7 @@ func init() { encryptCmd.AddCommand(removeSlotCmd) encryptCmd.AddCommand(listSlotsCmd) encryptCmd.AddCommand(changePassphraseCmd) + encryptCmd.AddCommand(rotateDEKCmd) rootCmd.AddCommand(encryptCmd) } diff --git a/garden/encrypt.go b/garden/encrypt.go index 0e66f2f..66ec39b 100644 --- a/garden/encrypt.go +++ b/garden/encrypt.go @@ -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 { diff --git a/garden/encrypt_rotate_test.go b/garden/encrypt_rotate_test.go new file mode 100644 index 0000000..3dac9d8 --- /dev/null +++ b/garden/encrypt_rotate_test.go @@ -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") + } +}