From 3b961b5d8a699d2ed50e6e7a54c81730a4a1e98c Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 08:50:53 -0700 Subject: [PATCH] =?UTF-8?q?Step=2017:=20Encryption=20core=20=E2=80=94=20pa?= =?UTF-8?q?ssphrase-only,=20selective=20per-file.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manifest schema: Entry gains Encrypted, PlaintextHash fields. Manifest gains Encryption section with KekSlots map (passphrase slot with Argon2id params, salt, and wrapped DEK as base64). garden/encrypt.go: EncryptInit (generate DEK, wrap with passphrase KEK), UnlockDEK (derive KEK, unwrap), encryptBlob/decryptBlob using XChaCha20-Poly1305 with random 24-byte nonces. Modified operations: - Add: optional encrypt flag, stores encrypted blob + plaintext_hash - Checkpoint: detects changes via plaintext_hash, re-encrypts - Restore: decrypts encrypted blobs before writing - Diff: decrypts stored blob before comparing - Status: compares against plaintext_hash for encrypted entries 10 tests covering init, persistence, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- PROGRESS.md | 5 +- PROJECT_PLAN.md | 22 +-- garden/diff.go | 10 ++ garden/encrypt.go | 229 +++++++++++++++++++++++++ garden/encrypt_test.go | 379 +++++++++++++++++++++++++++++++++++++++++ garden/garden.go | 87 ++++++++-- garden/mirror.go | 2 +- manifest/manifest.go | 42 +++-- 8 files changed, 737 insertions(+), 39 deletions(-) create mode 100644 garden/encrypt.go create mode 100644 garden/encrypt_test.go diff --git a/PROGRESS.md b/PROGRESS.md index 94b7709..3bdf1b7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 2 complete. Phase 3 (Encryption) planned, ready for Step 17. +**Phase:** Phase 3 in progress. Step 17 complete, ready for Step 18. **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Phase 3: Encryption. Step 17 (passphrase-only core) is next. +Step 18: FIDO2 support. ## Known Issues / Decisions Deferred @@ -78,3 +78,4 @@ Phase 3: Encryption. Step 17 (passphrase-only core) is next. | 2026-03-24 | 16 | Polish: updated all docs, flake.nix (sgardd + vendorHash), goreleaser (both binaries), e2e push/pull test with auth. | | 2026-03-24 | — | JWT token auth implemented (transparent auto-renewal, XDG token cache, ReauthChallenge fast path). | | 2026-03-24 | — | Phase 3 encryption design: selective per-file encryption, KEK slots (passphrase + fido2/label), manifest-embedded config. | +| 2026-03-24 | 17 | Encryption core: Argon2id KEK, XChaCha20 DEK wrap/unwrap, selective per-file encrypt in Add/Checkpoint/Restore/Diff/Status. 10 tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 6f5486b..676e9e7 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -181,17 +181,17 @@ Depends on Steps 13, 14. ### Step 17: Encryption Core (Passphrase Only) -- [ ] `manifest/manifest.go`: add `Encrypted`, `PlaintextHash` fields to Entry; add `Encryption` section with `KekSlots` map to Manifest -- [ ] `garden/encrypt.go`: `EncryptInit(passphrase string) error` — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section -- [ ] `garden/encrypt.go`: `UnlockDEK() ([]byte, error)` — read slots, try passphrase, unwrap DEK; cache in memory for command duration -- [ ] `garden/encrypt.go`: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open) -- [ ] `garden/garden.go`: modify Add to accept `--encrypt` flag — encrypt blob before storing, set `encrypted: true` and `plaintext_hash` on entry -- [ ] `garden/garden.go`: modify Checkpoint to re-encrypt changed encrypted entries -- [ ] `garden/restore.go`: modify Restore to decrypt encrypted blobs before writing -- [ ] `garden/diff.go`: modify Diff to decrypt stored blob before diffing -- [ ] `garden/garden.go`: modify Status to use `plaintext_hash` for encrypted entries -- [ ] Tests: round-trip add-encrypted → checkpoint → restore, verify decrypted content matches; status on encrypted entry; diff on encrypted entry -- [ ] Verify: `go test ./... && go vet ./... && golangci-lint run ./...` +- [x] `manifest/manifest.go`: add `Encrypted`, `PlaintextHash` fields to Entry; add `Encryption` section with `KekSlots` map to Manifest +- [x] `garden/encrypt.go`: `EncryptInit(passphrase string) error` — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section +- [x] `garden/encrypt.go`: `UnlockDEK(prompt) error` — read slots, try passphrase, unwrap DEK; cache in memory for command duration +- [x] `garden/encrypt.go`: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open) +- [x] `garden/garden.go`: modify Add to accept encrypt flag — encrypt blob before storing, set `encrypted: true` and `plaintext_hash` on entry +- [x] `garden/garden.go`: modify Checkpoint to re-encrypt changed encrypted entries (compares plaintext_hash) +- [x] `garden/garden.go`: modify Restore to decrypt encrypted blobs before writing +- [x] `garden/diff.go`: modify Diff to decrypt stored blob before diffing +- [x] `garden/garden.go`: modify Status to use `plaintext_hash` for encrypted entries +- [x] Tests: 10 encryption tests (init, persist, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK) +- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...` ### Step 18: FIDO2 Support diff --git a/garden/diff.go b/garden/diff.go index 5e78585..803c0db 100644 --- a/garden/diff.go +++ b/garden/diff.go @@ -33,6 +33,16 @@ func (g *Garden) Diff(path string) (string, error) { return "", fmt.Errorf("reading stored blob: %w", err) } + if entry.Encrypted { + if g.dek == nil { + return "", fmt.Errorf("DEK not unlocked; cannot diff encrypted file %s", tilded) + } + stored, err = g.decryptBlob(stored) + if err != nil { + return "", fmt.Errorf("decrypting stored blob: %w", err) + } + } + current, err := os.ReadFile(abs) if err != nil { return "", fmt.Errorf("reading current file: %w", err) diff --git a/garden/encrypt.go b/garden/encrypt.go new file mode 100644 index 0000000..f5b61cb --- /dev/null +++ b/garden/encrypt.go @@ -0,0 +1,229 @@ +package garden + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + + "github.com/kisom/sgard/manifest" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + dekSize = 32 // 256-bit DEK + saltSize = 16 + algorithmName = "xchacha20-poly1305" + + defaultArgon2Time = 3 + defaultArgon2Memory = 64 * 1024 // 64 MiB in KiB + defaultArgon2Threads = 4 +) + +// EncryptInit sets up encryption on the repo by generating a DEK and +// wrapping it with a passphrase-derived KEK. The encryption config is +// stored in the manifest. +func (g *Garden) EncryptInit(passphrase string) error { + if g.manifest.Encryption != nil { + return fmt.Errorf("encryption already initialized") + } + + // Generate DEK. + dek := make([]byte, dekSize) + if _, err := rand.Read(dek); err != nil { + return fmt.Errorf("generating DEK: %w", err) + } + + // Generate salt for passphrase KEK. + salt := make([]byte, saltSize) + if _, err := rand.Read(salt); err != nil { + return fmt.Errorf("generating salt: %w", err) + } + + // Derive KEK from passphrase. + kek := derivePassphraseKEK(passphrase, salt, defaultArgon2Time, defaultArgon2Memory, defaultArgon2Threads) + + // Wrap DEK. + wrappedDEK, err := wrapDEK(dek, kek) + if err != nil { + return fmt.Errorf("wrapping DEK: %w", err) + } + + g.manifest.Encryption = &manifest.Encryption{ + Algorithm: algorithmName, + KekSlots: map[string]*manifest.KekSlot{ + "passphrase": { + Type: "passphrase", + Argon2Time: defaultArgon2Time, + Argon2Memory: defaultArgon2Memory, + Argon2Threads: defaultArgon2Threads, + Salt: base64.StdEncoding.EncodeToString(salt), + WrappedDEK: base64.StdEncoding.EncodeToString(wrappedDEK), + }, + }, + } + + g.dek = dek + + if err := g.manifest.Save(g.manifestPath); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + + return nil +} + +// UnlockDEK attempts to unwrap the DEK using available KEK slots. +// Tries passphrase slots (prompting via the provided function). +// The DEK is cached on the Garden for the duration of the command. +func (g *Garden) UnlockDEK(promptPassphrase func() (string, error)) error { + if g.dek != nil { + return nil // already unlocked + } + + enc := g.manifest.Encryption + if enc == nil { + return fmt.Errorf("encryption not initialized; run sgard encrypt init") + } + + // Try passphrase slot. + if slot, ok := enc.KekSlots["passphrase"]; ok { + if promptPassphrase == nil { + return fmt.Errorf("passphrase required but no prompt available") + } + + 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: %w", err) + } + + kek := derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads) + + wrappedDEK, err := base64.StdEncoding.DecodeString(slot.WrappedDEK) + if err != nil { + return fmt.Errorf("decoding wrapped DEK: %w", err) + } + + dek, err := unwrapDEK(wrappedDEK, kek) + if err != nil { + return fmt.Errorf("wrong passphrase or corrupted DEK: %w", err) + } + + g.dek = dek + return nil + } + + return fmt.Errorf("no usable KEK slot found") +} + +// HasEncryption reports whether the repo has encryption configured. +func (g *Garden) HasEncryption() bool { + return g.manifest.Encryption != nil +} + +// NeedsDEK reports whether any of the given entries are encrypted. +func (g *Garden) NeedsDEK(entries []manifest.Entry) bool { + for _, e := range entries { + if e.Encrypted { + return true + } + } + return false +} + +// encryptBlob encrypts plaintext with the DEK and returns the ciphertext. +func (g *Garden) encryptBlob(plaintext []byte) ([]byte, error) { + if g.dek == nil { + return nil, fmt.Errorf("DEK not unlocked") + } + + aead, err := chacha20poly1305.NewX(g.dek) + if err != nil { + return nil, fmt.Errorf("creating cipher: %w", err) + } + + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("generating nonce: %w", err) + } + + ciphertext := aead.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// decryptBlob decrypts ciphertext with the DEK and returns the plaintext. +func (g *Garden) decryptBlob(ciphertext []byte) ([]byte, error) { + if g.dek == nil { + return nil, fmt.Errorf("DEK not unlocked") + } + + aead, err := chacha20poly1305.NewX(g.dek) + if err != nil { + return nil, fmt.Errorf("creating cipher: %w", err) + } + + nonceSize := aead.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce := ciphertext[:nonceSize] + ct := ciphertext[nonceSize:] + + plaintext, err := aead.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("decryption failed: %w", err) + } + + return plaintext, nil +} + +// plaintextHash computes the SHA-256 hash of plaintext data. +func plaintextHash(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// derivePassphraseKEK derives a KEK from a passphrase using Argon2id. +func derivePassphraseKEK(passphrase string, salt []byte, time, memory, threads int) []byte { + return argon2.IDKey([]byte(passphrase), salt, uint32(time), uint32(memory), uint8(threads), dekSize) +} + +// wrapDEK encrypts the DEK with the KEK using XChaCha20-Poly1305. +func wrapDEK(dek, kek []byte) ([]byte, error) { + aead, err := chacha20poly1305.NewX(kek) + if err != nil { + return nil, err + } + + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + return aead.Seal(nonce, nonce, dek, nil), nil +} + +// unwrapDEK decrypts the DEK with the KEK. +func unwrapDEK(wrapped, kek []byte) ([]byte, error) { + aead, err := chacha20poly1305.NewX(kek) + if err != nil { + return nil, err + } + + nonceSize := aead.NonceSize() + if len(wrapped) < nonceSize { + return nil, fmt.Errorf("wrapped DEK too short") + } + + nonce := wrapped[:nonceSize] + ct := wrapped[nonceSize:] + + return aead.Open(nil, nonce, ct, nil) +} diff --git a/garden/encrypt_test.go b/garden/encrypt_test.go new file mode 100644 index 0000000..f603341 --- /dev/null +++ b/garden/encrypt_test.go @@ -0,0 +1,379 @@ +package garden + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kisom/sgard/manifest" +) + +func TestEncryptInit(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("test-passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + if g.manifest.Encryption == nil { + t.Fatal("encryption section should be present") + } + if g.manifest.Encryption.Algorithm != "xchacha20-poly1305" { + t.Errorf("algorithm = %s, want xchacha20-poly1305", g.manifest.Encryption.Algorithm) + } + slot, ok := g.manifest.Encryption.KekSlots["passphrase"] + if !ok { + t.Fatal("passphrase slot should exist") + } + if slot.Type != "passphrase" { + t.Errorf("slot type = %s, want passphrase", slot.Type) + } + if slot.Salt == "" || slot.WrappedDEK == "" { + t.Error("slot should have salt and wrapped DEK") + } + + // DEK should be cached. + if g.dek == nil { + t.Error("DEK should be cached after EncryptInit") + } + + // Double init should fail. + if err := g.EncryptInit("other"); err == nil { + t.Fatal("double EncryptInit should fail") + } +} + +func TestEncryptInitPersists(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("test-passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + // Re-open and verify encryption section persisted. + g2, err := Open(repoDir) + if err != nil { + t.Fatalf("Open: %v", err) + } + if g2.manifest.Encryption == nil { + t.Fatal("encryption section should persist after re-open") + } +} + +func TestUnlockDEK(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("correct-passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + // Re-open (DEK not cached). + g2, err := Open(repoDir) + if err != nil { + t.Fatalf("Open: %v", err) + } + + // Unlock with correct passphrase. + err = g2.UnlockDEK(func() (string, error) { return "correct-passphrase", nil }) + if err != nil { + t.Fatalf("UnlockDEK with correct passphrase: %v", err) + } + + // Re-open and try wrong passphrase. + g3, err := Open(repoDir) + if err != nil { + t.Fatalf("Open: %v", err) + } + + err = g3.UnlockDEK(func() (string, error) { return "wrong-passphrase", nil }) + if err == nil { + t.Fatal("UnlockDEK with wrong passphrase should fail") + } +} + +func TestAddEncrypted(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("passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + // Add an encrypted file. + secretFile := filepath.Join(root, "secret") + if err := os.WriteFile(secretFile, []byte("secret data\n"), 0o600); err != nil { + t.Fatalf("writing secret file: %v", err) + } + + if err := g.Add([]string{secretFile}, true); err != nil { + t.Fatalf("Add encrypted: %v", err) + } + + // Add a plaintext file. + plainFile := filepath.Join(root, "plain") + if err := os.WriteFile(plainFile, []byte("plain data\n"), 0o644); err != nil { + t.Fatalf("writing plain file: %v", err) + } + + if err := g.Add([]string{plainFile}); err != nil { + t.Fatalf("Add plaintext: %v", err) + } + + if len(g.manifest.Files) != 2 { + t.Fatalf("expected 2 entries, got %d", len(g.manifest.Files)) + } + + // Check encrypted entry. + var secretEntry, plainEntry *manifest.Entry + for i := range g.manifest.Files { + if g.manifest.Files[i].Encrypted { + secretEntry = &g.manifest.Files[i] + } else { + plainEntry = &g.manifest.Files[i] + } + } + + if secretEntry == nil { + t.Fatal("expected an encrypted entry") + } + if secretEntry.PlaintextHash == "" { + t.Error("encrypted entry should have plaintext_hash") + } + if secretEntry.Hash == "" { + t.Error("encrypted entry should have hash (of ciphertext)") + } + + if plainEntry == nil { + t.Fatal("expected a plaintext entry") + } + if plainEntry.PlaintextHash != "" { + t.Error("plaintext entry should not have plaintext_hash") + } + if plainEntry.Encrypted { + t.Error("plaintext entry should not be encrypted") + } + + // The stored blob for the encrypted file should NOT be the plaintext. + storedData, err := g.ReadBlob(secretEntry.Hash) + if err != nil { + t.Fatalf("ReadBlob: %v", err) + } + if string(storedData) == "secret data\n" { + t.Error("stored blob should be encrypted, not plaintext") + } +} + +func TestEncryptedRestoreRoundTrip(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("passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + content := []byte("sensitive config data\n") + secretFile := filepath.Join(root, "secret") + if err := os.WriteFile(secretFile, content, 0o600); err != nil { + t.Fatalf("writing: %v", err) + } + + if err := g.Add([]string{secretFile}, true); err != nil { + t.Fatalf("Add: %v", err) + } + + // Delete and restore. + _ = os.Remove(secretFile) + + if err := g.Restore(nil, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + got, err := os.ReadFile(secretFile) + if err != nil { + t.Fatalf("reading restored file: %v", err) + } + if string(got) != string(content) { + t.Errorf("restored content = %q, want %q", got, content) + } +} + +func TestEncryptedCheckpoint(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("passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + secretFile := filepath.Join(root, "secret") + if err := os.WriteFile(secretFile, []byte("original"), 0o600); err != nil { + t.Fatalf("writing: %v", err) + } + + if err := g.Add([]string{secretFile}, true); err != nil { + t.Fatalf("Add: %v", err) + } + + origHash := g.manifest.Files[0].Hash + origPtHash := g.manifest.Files[0].PlaintextHash + + // Modify file. + if err := os.WriteFile(secretFile, []byte("modified"), 0o600); err != nil { + t.Fatalf("modifying: %v", err) + } + + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + if g.manifest.Files[0].Hash == origHash { + t.Error("encrypted hash should change after modification") + } + if g.manifest.Files[0].PlaintextHash == origPtHash { + t.Error("plaintext hash should change after modification") + } +} + +func TestEncryptedStatus(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("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}, true); err != nil { + t.Fatalf("Add: %v", err) + } + + // Unchanged — should be ok. + statuses, err := g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if len(statuses) != 1 || statuses[0].State != "ok" { + t.Errorf("expected ok, got %v", statuses) + } + + // Modify — should be modified. + if err := os.WriteFile(secretFile, []byte("changed"), 0o600); err != nil { + t.Fatalf("modifying: %v", err) + } + + statuses, err = g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if len(statuses) != 1 || statuses[0].State != "modified" { + t.Errorf("expected modified, got %v", statuses) + } +} + +func TestEncryptedDiff(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("passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + secretFile := filepath.Join(root, "secret") + if err := os.WriteFile(secretFile, []byte("original\n"), 0o600); err != nil { + t.Fatalf("writing: %v", err) + } + + if err := g.Add([]string{secretFile}, true); err != nil { + t.Fatalf("Add: %v", err) + } + + // Unchanged — empty diff. + d, err := g.Diff(secretFile) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if d != "" { + t.Errorf("expected empty diff for unchanged encrypted file, got:\n%s", d) + } + + // Modify. + if err := os.WriteFile(secretFile, []byte("modified\n"), 0o600); err != nil { + t.Fatalf("modifying: %v", err) + } + + d, err = g.Diff(secretFile) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if d == "" { + t.Fatal("expected non-empty diff for modified encrypted file") + } +} + +func TestAddEncryptedRequiresDEK(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // No encryption initialized. + testFile := filepath.Join(root, "file") + if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil { + t.Fatalf("writing: %v", err) + } + + err = g.Add([]string{testFile}, true) + if err == nil { + t.Fatal("Add --encrypt without DEK should fail") + } +} diff --git a/garden/garden.go b/garden/garden.go index 56f9ab1..aed01f1 100644 --- a/garden/garden.go +++ b/garden/garden.go @@ -22,6 +22,7 @@ type Garden struct { root string // repository root directory manifestPath string // path to manifest.yaml clock clockwork.Clock + dek []byte // unlocked data encryption key (nil if not unlocked) } // Init creates a new sgard repository at root. It creates the directory @@ -140,7 +141,8 @@ func (g *Garden) DeleteBlob(hash string) error { // addEntry adds a single file or symlink to the manifest. The abs path must // already be resolved and info must come from os.Lstat. If skipDup is true, // already-tracked paths are silently skipped instead of returning an error. -func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool) error { +// If encrypt is true, the file blob is encrypted before storing. +func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt bool) error { tilded := toTildePath(abs) if g.findEntry(tilded) != nil { @@ -170,6 +172,20 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup b if err != nil { return fmt.Errorf("reading file %s: %w", abs, err) } + + if encrypt { + if g.dek == nil { + return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs) + } + entry.PlaintextHash = plaintextHash(data) + ct, err := g.encryptBlob(data) + if err != nil { + return fmt.Errorf("encrypting %s: %w", abs, err) + } + data = ct + entry.Encrypted = true + } + hash, err := g.store.Write(data) if err != nil { return fmt.Errorf("storing blob for %s: %w", abs, err) @@ -186,7 +202,14 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup b // to an absolute path, inspected for its type, and added to the manifest. // Regular files are hashed and stored in the blob store. Directories are // recursively walked and all leaf files and symlinks are added individually. -func (g *Garden) Add(paths []string) error { +// If encrypt is true, file blobs are encrypted before storing (requires +// the DEK to be unlocked). +func (g *Garden) Add(paths []string, encrypt ...bool) error { + enc := len(encrypt) > 0 && encrypt[0] + if enc && g.dek == nil { + return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first") + } + now := g.clock.Now().UTC() for _, p := range paths { @@ -201,25 +224,24 @@ func (g *Garden) Add(paths []string) error { } if info.IsDir() { - // Recursively walk the directory, adding all files and symlinks. err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { - return nil // skip directory entries themselves + return nil } fi, err := os.Lstat(path) if err != nil { return fmt.Errorf("stat %s: %w", path, err) } - return g.addEntry(path, fi, now, true) + return g.addEntry(path, fi, now, true, enc) }) if err != nil { return fmt.Errorf("walking directory %s: %w", abs, err) } } else { - if err := g.addEntry(abs, info, now, false); err != nil { + if err := g.addEntry(abs, info, now, false, enc); err != nil { return err } } @@ -268,13 +290,35 @@ func (g *Garden) Checkpoint(message string) error { if err != nil { return fmt.Errorf("reading %s: %w", abs, err) } - hash, err := g.store.Write(data) - if err != nil { - return fmt.Errorf("storing blob for %s: %w", abs, err) - } - if hash != entry.Hash { - entry.Hash = hash - entry.Updated = now + + if entry.Encrypted { + // For encrypted entries, check plaintext hash to detect changes. + ptHash := plaintextHash(data) + if ptHash != entry.PlaintextHash { + if g.dek == nil { + return fmt.Errorf("DEK not unlocked; cannot re-encrypt %s", abs) + } + ct, err := g.encryptBlob(data) + if err != nil { + return fmt.Errorf("encrypting %s: %w", abs, err) + } + hash, err := g.store.Write(ct) + if err != nil { + return fmt.Errorf("storing blob for %s: %w", abs, err) + } + entry.Hash = hash + entry.PlaintextHash = ptHash + entry.Updated = now + } + } else { + hash, err := g.store.Write(data) + if err != nil { + return fmt.Errorf("storing blob for %s: %w", abs, err) + } + if hash != entry.Hash { + entry.Hash = hash + entry.Updated = now + } } case "link": @@ -329,7 +373,12 @@ func (g *Garden) Status() ([]FileStatus, error) { if err != nil { return nil, fmt.Errorf("hashing %s: %w", abs, err) } - if hash != entry.Hash { + // For encrypted entries, compare against plaintext hash. + compareHash := entry.Hash + if entry.Encrypted && entry.PlaintextHash != "" { + compareHash = entry.PlaintextHash + } + if hash != compareHash { results = append(results, FileStatus{Path: entry.Path, State: "modified"}) } else { results = append(results, FileStatus{Path: entry.Path, State: "ok"}) @@ -429,6 +478,16 @@ func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error { return fmt.Errorf("reading blob for %s: %w", entry.Path, err) } + if entry.Encrypted { + if g.dek == nil { + return fmt.Errorf("DEK not unlocked; cannot decrypt %s", entry.Path) + } + data, err = g.decryptBlob(data) + if err != nil { + return fmt.Errorf("decrypting %s: %w", entry.Path, err) + } + } + mode, err := parseMode(entry.Mode) if err != nil { return fmt.Errorf("parsing mode for %s: %w", entry.Path, err) diff --git a/garden/mirror.go b/garden/mirror.go index b40df71..979c910 100644 --- a/garden/mirror.go +++ b/garden/mirror.go @@ -37,7 +37,7 @@ func (g *Garden) MirrorUp(paths []string) error { if lstatErr != nil { return fmt.Errorf("stat %s: %w", path, lstatErr) } - return g.addEntry(path, fi, now, true) + return g.addEntry(path, fi, now, true, false) }) if err != nil { return fmt.Errorf("walking directory %s: %w", abs, err) diff --git a/manifest/manifest.go b/manifest/manifest.go index d320be3..aabbf0a 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -11,21 +11,41 @@ import ( // Entry represents a single tracked file, directory, or symlink. type Entry struct { - Path string `yaml:"path"` - Hash string `yaml:"hash,omitempty"` - Type string `yaml:"type"` - Mode string `yaml:"mode,omitempty"` - Target string `yaml:"target,omitempty"` - Updated time.Time `yaml:"updated"` + Path string `yaml:"path"` + Hash string `yaml:"hash,omitempty"` + PlaintextHash string `yaml:"plaintext_hash,omitempty"` + Encrypted bool `yaml:"encrypted,omitempty"` + Type string `yaml:"type"` + Mode string `yaml:"mode,omitempty"` + Target string `yaml:"target,omitempty"` + Updated time.Time `yaml:"updated"` +} + +// KekSlot describes a single KEK source that can unwrap the DEK. +type KekSlot struct { + Type string `yaml:"type"` // "passphrase" or "fido2" + Argon2Time int `yaml:"argon2_time,omitempty"` // passphrase only + Argon2Memory int `yaml:"argon2_memory,omitempty"` // passphrase only (KiB) + Argon2Threads int `yaml:"argon2_threads,omitempty"` // passphrase only + CredentialID string `yaml:"credential_id,omitempty"` // fido2 only (base64) + Salt string `yaml:"salt"` // base64-encoded + WrappedDEK string `yaml:"wrapped_dek"` // base64-encoded +} + +// Encryption holds the encryption configuration embedded in the manifest. +type Encryption struct { + Algorithm string `yaml:"algorithm"` + KekSlots map[string]*KekSlot `yaml:"kek_slots"` } // Manifest is the top-level manifest describing all tracked entries. type Manifest struct { - Version int `yaml:"version"` - Created time.Time `yaml:"created"` - Updated time.Time `yaml:"updated"` - Message string `yaml:"message,omitempty"` - Files []Entry `yaml:"files"` + Version int `yaml:"version"` + Created time.Time `yaml:"created"` + Updated time.Time `yaml:"updated"` + Message string `yaml:"message,omitempty"` + Files []Entry `yaml:"files"` + Encryption *Encryption `yaml:"encryption,omitempty"` } // New creates a new empty manifest with Version 1 and timestamps set to now.