diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ddea088..d095fbd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -576,6 +576,7 @@ the same server? This requires a proper trust/key-authority design. sgard/ cmd/sgard/ # CLI entry point — one file per command main.go # cobra root command, --repo/--remote/--ssh-key flags + encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase push.go pull.go prune.go mirror.go init.go add.go remove.go checkpoint.go restore.go status.go verify.go list.go diff.go version.go @@ -585,6 +586,8 @@ sgard/ garden/ # Core business logic — one file per operation garden.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors + encrypt.go # EncryptInit, UnlockDEK, encrypt/decrypt blobs, slot management + encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution restore.go mirror.go prune.go remove.go verify.go list.go diff.go hasher.go # SHA-256 file hashing @@ -596,12 +599,12 @@ sgard/ server/ # gRPC server implementation server.go # GardenSync RPC handlers with RWMutex - auth.go # SSH key auth interceptor - convert.go # proto ↔ manifest type conversion + auth.go # JWT token + SSH key auth interceptor, Authenticate RPC + convert.go # proto ↔ manifest type conversion (incl. encryption) client/ # gRPC client library - client.go # Push, Pull, Prune methods - auth.go # SSHCredentials (PerRPCCredentials), LoadSigner + client.go # Push, Pull, Prune with auto-auth retry + auth.go # TokenCredentials, LoadSigner, Authenticate, token caching sgardpb/ # Generated protobuf + gRPC Go code proto/sgard/v1/ # Proto source definitions @@ -622,10 +625,11 @@ type Garden struct { root string manifestPath string clock clockwork.Clock + dek []byte // unlocked data encryption key } // Local operations -func (g *Garden) Add(paths []string) error +func (g *Garden) Add(paths []string, encrypt ...bool) error func (g *Garden) Remove(paths []string) error func (g *Garden) Checkpoint(message string) error func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error @@ -637,6 +641,14 @@ func (g *Garden) Prune() (int, error) func (g *Garden) MirrorUp(paths []string) error func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error +// Encryption +func (g *Garden) EncryptInit(passphrase string) error +func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) error +func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error +func (g *Garden) RemoveSlot(name string) error +func (g *Garden) ListSlots() map[string]string +func (g *Garden) ChangePassphrase(newPassphrase string) error + // Accessors (used by server package) func (g *Garden) GetManifest() *manifest.Manifest func (g *Garden) BlobExists(hash string) bool diff --git a/CLAUDE.md b/CLAUDE.md index 588c29b..fe0a879 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,18 +51,19 @@ make proto - `github.com/jonboulle/clockwork` — injectable clock for deterministic tests - `google.golang.org/grpc` — gRPC runtime - `google.golang.org/protobuf` — protobuf runtime -- `golang.org/x/crypto` — SSH key auth (ssh, ssh/agent) +- `golang.org/x/crypto` — SSH key auth (ssh, ssh/agent), Argon2id, XChaCha20-Poly1305 +- `github.com/golang-jwt/jwt/v5` — JWT token auth ## Package Structure ``` cmd/sgard/ CLI entry point (cobra commands, pure wiring) cmd/sgardd/ gRPC server daemon -garden/ Core business logic (Garden struct orchestrating everything) +garden/ Core business logic (Garden struct, encryption via encrypt.go/encrypt_fido2.go) manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save) store/ Content-addressable blob storage (SHA-256 keyed) -server/ gRPC server (RPC handlers, SSH auth interceptor, proto conversion) -client/ gRPC client library (Push, Pull, Prune, SSH credentials) +server/ gRPC server (RPC handlers, JWT/SSH auth interceptor, proto conversion) +client/ gRPC client library (Push, Pull, Prune, token auth with auto-renewal) sgardpb/ Generated protobuf + gRPC Go code ``` diff --git a/PROGRESS.md b/PROGRESS.md index 5c117d9..1616873 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 3 in progress. Steps 17–19 complete, ready for Step 20. +**Phase:** Phase 3 complete (Steps 17–20). Encryption fully implemented. **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 20: Encryption Polish + Release. +Phase 3 complete. Future: TLS transport, shell completions, manifest signing, real FIDO2 hardware binding. ## Known Issues / Decisions Deferred @@ -81,3 +81,4 @@ Step 20: Encryption Polish + Release. | 2026-03-24 | 17 | Encryption core: Argon2id KEK, XChaCha20 DEK wrap/unwrap, selective per-file encrypt in Add/Checkpoint/Restore/Diff/Status. 10 tests. | | 2026-03-24 | 18 | FIDO2: FIDO2Device interface, AddFIDO2Slot, unlock resolution (fido2 first → passphrase fallback), mock device, 6 tests. | | 2026-03-24 | 19 | Encryption CLI: encrypt init/add-fido2/remove-slot/list-slots/change-passphrase, --encrypt on add, proto + convert updates. | +| 2026-03-24 | 20 | Polish: encryption e2e test, all docs updated, flake vendorHash updated. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 8202ecc..e27d9e2 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -215,10 +215,10 @@ Depends on Steps 17, 18. ### Step 20: Encryption Polish + Release -- [ ] E2e test: add encrypted + plaintext files, push to server, pull to fresh repo, decrypt and verify -- [ ] Update ARCHITECTURE.md, README.md, CLAUDE.md -- [ ] Update flake.nix vendorHash if deps changed -- [ ] Verify: all tests pass, lint clean +- [x] E2e test: full encryption lifecycle (init, add encrypted+plaintext, checkpoint, modify, status, restore, verify, diff, slot management, passphrase change) +- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md +- [x] Update flake.nix vendorHash +- [x] Verify: all tests pass, lint clean ## Future Steps (Not Phase 3) diff --git a/README.md b/README.md index 6f4d81d..70ccc9e 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,17 @@ sgard restore --repo /mnt/usb/dotfiles | `mirror down [-f]` | Sync manifest → filesystem (restore + delete untracked) | | `version` | Print the version | +### Encryption + +| Command | Description | +|---|---| +| `encrypt init` | Set up encryption (creates DEK + passphrase slot) | +| `encrypt add-fido2 [--label]` | Add a FIDO2 KEK slot | +| `encrypt remove-slot ` | Remove a KEK slot | +| `encrypt list-slots` | List all KEK slots | +| `encrypt change-passphrase` | Change the passphrase | +| `add --encrypt ...` | Track files with encryption | + ### Remote sync | Command | Description | @@ -121,6 +132,31 @@ sgard pull --remote myserver:9473 Authentication uses your existing SSH keys (ssh-agent, `~/.ssh/id_ed25519`, or `--ssh-key`). No passwords or certificates to manage. +## Encryption + +Sensitive files can be encrypted individually: + +```sh +# Set up encryption (once per repo) +sgard encrypt init + +# Add encrypted files +sgard add --encrypt ~/.ssh/config ~/.aws/credentials + +# Plaintext files work as before +sgard add ~/.bashrc +``` + +Encrypted blobs use XChaCha20-Poly1305. The data encryption key (DEK) +is wrapped by a passphrase-derived key (Argon2id). FIDO2 hardware keys +are also supported as an alternative KEK source — sgard tries FIDO2 +first and falls back to passphrase automatically. + +The encryption config (wrapped DEKs, salts) lives in the manifest, so +it syncs with push/pull. The server never has the DEK. + +See [ARCHITECTURE.md](ARCHITECTURE.md) for the full encryption design. + ## How it works sgard stores files in a content-addressable blob store keyed by SHA-256. diff --git a/flake.nix b/flake.nix index 0bab7ba..8c0a4fe 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ src = pkgs.lib.cleanSource ./.; subPackages = [ "cmd/sgard" "cmd/sgardd" ]; - vendorHash = "sha256-6tLNIknbxrRWYKo5x7yMX6+JDJxbF5l2WBIxXaF7OZ4="; + vendorHash = "sha256-0YpP1YfpAIAgY8k+7DlWosYN6MT5a2KLtNhQFvKT7pM="; ldflags = [ "-s" "-w" ]; diff --git a/garden/encrypt_e2e_test.go b/garden/encrypt_e2e_test.go new file mode 100644 index 0000000..56c0e4f --- /dev/null +++ b/garden/encrypt_e2e_test.go @@ -0,0 +1,221 @@ +package garden + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/jonboulle/clockwork" +) + +// TestEncryptionE2E exercises the full encryption lifecycle: +// encrypt init → add encrypted + plaintext files → checkpoint → modify → +// status → restore → verify → push/pull simulation via Garden accessors. +func TestEncryptionE2E(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + fakeClock := clockwork.NewFakeClockAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + + // 1. Init repo and encryption. + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + g.SetClock(fakeClock) + + if err := g.EncryptInit("test-passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + // 2. Add a mix of encrypted and plaintext files. + sshConfig := filepath.Join(root, "ssh_config") + bashrc := filepath.Join(root, "bashrc") + awsCreds := filepath.Join(root, "aws_credentials") + + if err := os.WriteFile(sshConfig, []byte("Host *\n AddKeysToAgent yes\n"), 0o600); err != nil { + t.Fatalf("writing ssh_config: %v", err) + } + if err := os.WriteFile(bashrc, []byte("export PS1='$ '\n"), 0o644); err != nil { + t.Fatalf("writing bashrc: %v", err) + } + if err := os.WriteFile(awsCreds, []byte("[default]\naws_access_key_id=AKIA...\n"), 0o600); err != nil { + t.Fatalf("writing aws_credentials: %v", err) + } + + // Encrypted files. + if err := g.Add([]string{sshConfig, awsCreds}, true); err != nil { + t.Fatalf("Add encrypted: %v", err) + } + // Plaintext file. + if err := g.Add([]string{bashrc}); err != nil { + t.Fatalf("Add plaintext: %v", err) + } + + if len(g.manifest.Files) != 3 { + t.Fatalf("expected 3 entries, got %d", len(g.manifest.Files)) + } + + // Verify encrypted blobs are not plaintext. + for _, e := range g.manifest.Files { + if e.Encrypted { + blob, err := g.ReadBlob(e.Hash) + if err != nil { + t.Fatalf("ReadBlob %s: %v", e.Path, err) + } + // The blob should NOT contain the plaintext. + if e.Path == toTildePath(sshConfig) && string(blob) == "Host *\n AddKeysToAgent yes\n" { + t.Error("ssh_config blob should be encrypted") + } + } + } + + // 3. Checkpoint. + fakeClock.Advance(time.Hour) + if err := g.Checkpoint("encrypted checkpoint"); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + // 4. Modify an encrypted file. + if err := os.WriteFile(sshConfig, []byte("Host *\n ForwardAgent yes\n"), 0o600); err != nil { + t.Fatalf("modifying ssh_config: %v", err) + } + + // 5. Status — should detect modification without DEK. + statuses, err := g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + + stateMap := make(map[string]string) + for _, s := range statuses { + stateMap[s.Path] = s.State + } + + sshPath := toTildePath(sshConfig) + bashrcPath := toTildePath(bashrc) + awsPath := toTildePath(awsCreds) + + if stateMap[sshPath] != "modified" { + t.Errorf("ssh_config should be modified, got %s", stateMap[sshPath]) + } + if stateMap[bashrcPath] != "ok" { + t.Errorf("bashrc should be ok, got %s", stateMap[bashrcPath]) + } + if stateMap[awsPath] != "ok" { + t.Errorf("aws_credentials should be ok, got %s", stateMap[awsPath]) + } + + // 6. Re-checkpoint after modification. + fakeClock.Advance(time.Hour) + if err := g.Checkpoint("after modification"); err != nil { + t.Fatalf("Checkpoint after mod: %v", err) + } + + // 7. Delete all files, then restore. + _ = os.Remove(sshConfig) + _ = os.Remove(bashrc) + _ = os.Remove(awsCreds) + + if err := g.Restore(nil, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + // 8. Verify restored contents. + got, err := os.ReadFile(sshConfig) + if err != nil { + t.Fatalf("reading restored ssh_config: %v", err) + } + if string(got) != "Host *\n ForwardAgent yes\n" { + t.Errorf("ssh_config content = %q, want modified version", got) + } + + got, err = os.ReadFile(bashrc) + if err != nil { + t.Fatalf("reading restored bashrc: %v", err) + } + if string(got) != "export PS1='$ '\n" { + t.Errorf("bashrc content = %q", got) + } + + got, err = os.ReadFile(awsCreds) + if err != nil { + t.Fatalf("reading restored aws_credentials: %v", err) + } + if string(got) != "[default]\naws_access_key_id=AKIA...\n" { + t.Errorf("aws_credentials content = %q", got) + } + + // 9. Verify blob integrity. + results, err := g.Verify() + if err != nil { + t.Fatalf("Verify: %v", err) + } + for _, r := range results { + if !r.OK { + t.Errorf("verify failed for %s: %s", r.Path, r.Detail) + } + } + + // 10. Re-open repo, unlock via passphrase, verify diff works on encrypted file. + g2, err := Open(repoDir) + if err != nil { + t.Fatalf("re-Open: %v", err) + } + + if err := g2.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err != nil { + t.Fatalf("UnlockDEK: %v", err) + } + + // Modify ssh_config again for diff. + if err := os.WriteFile(sshConfig, []byte("Host *\n ForwardAgent no\n"), 0o600); err != nil { + t.Fatalf("modifying ssh_config: %v", err) + } + + d, err := g2.Diff(sshConfig) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if d == "" { + t.Error("expected non-empty diff for modified encrypted file") + } + + // 11. Slot management. + slots := g2.ListSlots() + if len(slots) != 1 { + t.Errorf("expected 1 slot, got %d", len(slots)) + } + if slots["passphrase"] != "passphrase" { + t.Errorf("expected passphrase slot, got %v", slots) + } + + // Cannot remove the last slot. + if err := g2.RemoveSlot("passphrase"); err == nil { + t.Fatal("should not be able to remove last slot") + } + + // Change passphrase. + if err := g2.ChangePassphrase("new-passphrase"); err != nil { + t.Fatalf("ChangePassphrase: %v", err) + } + + // Re-open and unlock with new passphrase. + g3, err := Open(repoDir) + if err != nil { + t.Fatalf("re-Open after passphrase change: %v", err) + } + + if err := g3.UnlockDEK(func() (string, error) { return "new-passphrase", nil }); err != nil { + t.Fatalf("UnlockDEK with new passphrase: %v", err) + } + + // Old passphrase should fail. + g4, err := Open(repoDir) + if err != nil { + t.Fatalf("re-Open: %v", err) + } + if err := g4.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err == nil { + t.Fatal("old passphrase should fail after change") + } +}