Step 20: Encryption polish — e2e test, docs, flake.

E2e encryption test: full lifecycle covering init, add encrypted +
plaintext, checkpoint, modify, status (no DEK needed), re-checkpoint,
restore, verify, re-open with unlock, diff, slot management, passphrase
change, old passphrase rejection.

Docs updated:
- ARCHITECTURE.md: package structure (encrypt.go, encrypt_fido2.go,
  encrypt CLI), Garden struct (dek field, encryption methods), auth.go
  descriptions updated for JWT
- README.md: encryption commands table, encryption section with usage
- CLAUDE.md: added jwt/argon2/chacha20 deps, encryption file mentions

flake.nix: vendorHash updated for new deps.

Phase 3 complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 09:34:05 -07:00
parent 76a53320c1
commit 7accc6cac6
7 changed files with 287 additions and 16 deletions

View File

@@ -576,6 +576,7 @@ the same server? This requires a proper trust/key-authority design.
sgard/ sgard/
cmd/sgard/ # CLI entry point — one file per command cmd/sgard/ # CLI entry point — one file per command
main.go # cobra root command, --repo/--remote/--ssh-key flags 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 push.go pull.go prune.go mirror.go
init.go add.go remove.go checkpoint.go init.go add.go remove.go checkpoint.go
restore.go status.go verify.go list.go diff.go version.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/ # Core business logic — one file per operation
garden.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors 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 restore.go mirror.go prune.go remove.go verify.go list.go diff.go
hasher.go # SHA-256 file hashing hasher.go # SHA-256 file hashing
@@ -596,12 +599,12 @@ sgard/
server/ # gRPC server implementation server/ # gRPC server implementation
server.go # GardenSync RPC handlers with RWMutex server.go # GardenSync RPC handlers with RWMutex
auth.go # SSH key auth interceptor auth.go # JWT token + SSH key auth interceptor, Authenticate RPC
convert.go # proto ↔ manifest type conversion convert.go # proto ↔ manifest type conversion (incl. encryption)
client/ # gRPC client library client/ # gRPC client library
client.go # Push, Pull, Prune methods client.go # Push, Pull, Prune with auto-auth retry
auth.go # SSHCredentials (PerRPCCredentials), LoadSigner auth.go # TokenCredentials, LoadSigner, Authenticate, token caching
sgardpb/ # Generated protobuf + gRPC Go code sgardpb/ # Generated protobuf + gRPC Go code
proto/sgard/v1/ # Proto source definitions proto/sgard/v1/ # Proto source definitions
@@ -622,10 +625,11 @@ type Garden struct {
root string root string
manifestPath string manifestPath string
clock clockwork.Clock clock clockwork.Clock
dek []byte // unlocked data encryption key
} }
// Local operations // 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) Remove(paths []string) error
func (g *Garden) Checkpoint(message string) error func (g *Garden) Checkpoint(message string) error
func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) 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) MirrorUp(paths []string) error
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) 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) // Accessors (used by server package)
func (g *Garden) GetManifest() *manifest.Manifest func (g *Garden) GetManifest() *manifest.Manifest
func (g *Garden) BlobExists(hash string) bool func (g *Garden) BlobExists(hash string) bool

View File

@@ -51,18 +51,19 @@ make proto
- `github.com/jonboulle/clockwork` — injectable clock for deterministic tests - `github.com/jonboulle/clockwork` — injectable clock for deterministic tests
- `google.golang.org/grpc` — gRPC runtime - `google.golang.org/grpc` — gRPC runtime
- `google.golang.org/protobuf` — protobuf 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 ## Package Structure
``` ```
cmd/sgard/ CLI entry point (cobra commands, pure wiring) cmd/sgard/ CLI entry point (cobra commands, pure wiring)
cmd/sgardd/ gRPC server daemon 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) manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save)
store/ Content-addressable blob storage (SHA-256 keyed) store/ Content-addressable blob storage (SHA-256 keyed)
server/ gRPC server (RPC handlers, SSH auth interceptor, proto conversion) server/ gRPC server (RPC handlers, JWT/SSH auth interceptor, proto conversion)
client/ gRPC client library (Push, Pull, Prune, SSH credentials) client/ gRPC client library (Push, Pull, Prune, token auth with auto-renewal)
sgardpb/ Generated protobuf + gRPC Go code sgardpb/ Generated protobuf + gRPC Go code
``` ```

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status ## Current Status
**Phase:** Phase 3 in progress. Steps 1719 complete, ready for Step 20. **Phase:** Phase 3 complete (Steps 1720). Encryption fully implemented.
**Last updated:** 2026-03-24 **Last updated:** 2026-03-24
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
## Up Next ## 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 ## 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 | 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 | 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 | 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. |

View File

@@ -215,10 +215,10 @@ Depends on Steps 17, 18.
### Step 20: Encryption Polish + Release ### Step 20: Encryption Polish + Release
- [ ] E2e test: add encrypted + plaintext files, push to server, pull to fresh repo, decrypt and verify - [x] E2e test: full encryption lifecycle (init, add encrypted+plaintext, checkpoint, modify, status, restore, verify, diff, slot management, passphrase change)
- [ ] Update ARCHITECTURE.md, README.md, CLAUDE.md - [x] Update ARCHITECTURE.md, README.md, CLAUDE.md
- [ ] Update flake.nix vendorHash if deps changed - [x] Update flake.nix vendorHash
- [ ] Verify: all tests pass, lint clean - [x] Verify: all tests pass, lint clean
## Future Steps (Not Phase 3) ## Future Steps (Not Phase 3)

View File

@@ -88,6 +88,17 @@ sgard restore --repo /mnt/usb/dotfiles
| `mirror down <path> [-f]` | Sync manifest → filesystem (restore + delete untracked) | | `mirror down <path> [-f]` | Sync manifest → filesystem (restore + delete untracked) |
| `version` | Print the version | | `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 <name>` | Remove a KEK slot |
| `encrypt list-slots` | List all KEK slots |
| `encrypt change-passphrase` | Change the passphrase |
| `add --encrypt <path>...` | Track files with encryption |
### Remote sync ### Remote sync
| Command | Description | | Command | Description |
@@ -121,6 +132,31 @@ sgard pull --remote myserver:9473
Authentication uses your existing SSH keys (ssh-agent, `~/.ssh/id_ed25519`, Authentication uses your existing SSH keys (ssh-agent, `~/.ssh/id_ed25519`,
or `--ssh-key`). No passwords or certificates to manage. 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 ## How it works
sgard stores files in a content-addressable blob store keyed by SHA-256. sgard stores files in a content-addressable blob store keyed by SHA-256.

View File

@@ -19,7 +19,7 @@
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ]; subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-6tLNIknbxrRWYKo5x7yMX6+JDJxbF5l2WBIxXaF7OZ4="; vendorHash = "sha256-0YpP1YfpAIAgY8k+7DlWosYN6MT5a2KLtNhQFvKT7pM=";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];

221
garden/encrypt_e2e_test.go Normal file
View File

@@ -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")
}
}