diff --git a/PROGRESS.md b/PROGRESS.md index 85374cc..e0f643a 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–26 complete, ready for Step 27. +**Phase:** Phase 4 complete. All 7 steps done (21–27). **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 27: Phase 4 Polish + Release. +Phase 5: Multi-Repo + Per-Machine Inclusion (to be planned). ## Known Issues / Decisions Deferred @@ -90,3 +90,4 @@ Step 27: Phase 4 Polish + Release. | 2026-03-24 | 24 | DEK rotation: RotateDEK re-encrypts all blobs, re-wraps all slots, CLI command, 4 tests. | | 2026-03-24 | 25 | Real FIDO2: go-libfido2 bindings, build tag gating, CLI wiring, nix sgard-fido2 package. | | 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. | +| 2026-03-24 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 3873746..d599553 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -270,11 +270,11 @@ Depends on Steps 17, 18. ### Step 27: Phase 4 Polish + Release -- [ ] Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md) -- [ ] Update flake.nix vendorHash if deps changed -- [ ] Update .goreleaser.yaml if needed -- [ ] E2e test covering TLS + encryption + locked files -- [ ] Verify: all tests pass, lint clean, both binaries compile +- [x] Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md) +- [x] Update flake.nix vendorHash (done in Step 25) +- [x] .goreleaser.yaml — no changes needed (CGO_ENABLED=0 is correct for release binaries) +- [x] E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull +- [x] Verify: all tests pass, lint clean, both binaries compile ## Phase 5: Multi-Repo + Per-Machine Inclusion diff --git a/integration/phase4_test.go b/integration/phase4_test.go new file mode 100644 index 0000000..e327d23 --- /dev/null +++ b/integration/phase4_test.go @@ -0,0 +1,265 @@ +package integration + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kisom/sgard/client" + "github.com/kisom/sgard/garden" + "github.com/kisom/sgard/server" + "github.com/kisom/sgard/sgardpb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +func generateSelfSignedCert(t *testing.T) (tls.Certificate, *x509.CertPool) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "sgard-e2e"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("creating certificate: %v", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshaling key: %v", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatalf("loading key pair: %v", err) + } + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(certPEM) + + return cert, pool +} + +// TestE2E_Phase4 exercises TLS + encryption + locked files in a push/pull cycle. +func TestE2E_Phase4(t *testing.T) { + // --- Setup TLS server --- + cert, caPool := generateSelfSignedCert(t) + + serverDir := t.TempDir() + serverGarden, err := garden.Init(serverDir) + if err != nil { + t.Fatalf("init server garden: %v", err) + } + + serverCreds := credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }) + srv := grpc.NewServer(grpc.Creds(serverCreds)) + sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden)) + t.Cleanup(func() { srv.Stop() }) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go func() { _ = srv.Serve(lis) }() + + clientCreds := credentials.NewTLS(&tls.Config{ + RootCAs: caPool, + MinVersion: tls.VersionTLS12, + }) + + // --- Build source garden with encryption + locked files --- + srcRoot := t.TempDir() + srcRepoDir := filepath.Join(srcRoot, "repo") + srcGarden, err := garden.Init(srcRepoDir) + if err != nil { + t.Fatalf("init source garden: %v", err) + } + + if err := srcGarden.EncryptInit("test-passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + plainFile := filepath.Join(srcRoot, "plain") + secretFile := filepath.Join(srcRoot, "secret") + lockedFile := filepath.Join(srcRoot, "locked") + encLockedFile := filepath.Join(srcRoot, "enc-locked") + + if err := os.WriteFile(plainFile, []byte("plain data"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(secretFile, []byte("secret data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(lockedFile, []byte("locked data"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(encLockedFile, []byte("enc+locked data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + if err := srcGarden.Add([]string{plainFile}); err != nil { + t.Fatalf("Add plain: %v", err) + } + if err := srcGarden.Add([]string{secretFile}, garden.AddOptions{Encrypt: true}); err != nil { + t.Fatalf("Add encrypted: %v", err) + } + if err := srcGarden.Add([]string{lockedFile}, garden.AddOptions{Lock: true}); err != nil { + t.Fatalf("Add locked: %v", err) + } + if err := srcGarden.Add([]string{encLockedFile}, garden.AddOptions{Encrypt: true, Lock: true}); err != nil { + t.Fatalf("Add encrypted+locked: %v", err) + } + + // Bump timestamp so push wins. + srcManifest := srcGarden.GetManifest() + srcManifest.Updated = time.Now().UTC().Add(time.Hour) + if err := srcGarden.ReplaceManifest(srcManifest); err != nil { + t.Fatalf("ReplaceManifest: %v", err) + } + + // --- Push over TLS --- + ctx := context.Background() + + pushConn, err := grpc.NewClient(lis.Addr().String(), + grpc.WithTransportCredentials(clientCreds), + ) + if err != nil { + t.Fatalf("dial for push: %v", err) + } + defer func() { _ = pushConn.Close() }() + + pushClient := client.New(pushConn) + pushed, err := pushClient.Push(ctx, srcGarden) + if err != nil { + t.Fatalf("Push: %v", err) + } + if pushed < 2 { + t.Errorf("expected at least 2 blobs pushed, got %d", pushed) + } + + // --- Pull to a fresh garden over TLS --- + dstRoot := t.TempDir() + dstRepoDir := filepath.Join(dstRoot, "repo") + dstGarden, err := garden.Init(dstRepoDir) + if err != nil { + t.Fatalf("init dest garden: %v", err) + } + + pullConn, err := grpc.NewClient(lis.Addr().String(), + grpc.WithTransportCredentials(clientCreds), + ) + if err != nil { + t.Fatalf("dial for pull: %v", err) + } + defer func() { _ = pullConn.Close() }() + + pullClient := client.New(pullConn) + pulled, err := pullClient.Pull(ctx, dstGarden) + if err != nil { + t.Fatalf("Pull: %v", err) + } + if pulled < 2 { + t.Errorf("expected at least 2 blobs pulled, got %d", pulled) + } + + // --- Verify the pulled manifest --- + dstManifest := dstGarden.GetManifest() + if len(dstManifest.Files) != 4 { + t.Fatalf("expected 4 entries, got %d", len(dstManifest.Files)) + } + + type entryInfo struct { + encrypted bool + locked bool + } + entryMap := make(map[string]entryInfo) + for _, e := range dstManifest.Files { + entryMap[e.Path] = entryInfo{e.Encrypted, e.Locked} + } + + // Verify flags survived round trip. + for path, info := range entryMap { + switch { + case path == toTilde(secretFile): + if !info.encrypted { + t.Errorf("%s should be encrypted", path) + } + case path == toTilde(lockedFile): + if !info.locked { + t.Errorf("%s should be locked", path) + } + case path == toTilde(encLockedFile): + if !info.encrypted || !info.locked { + t.Errorf("%s should be encrypted+locked", path) + } + case path == toTilde(plainFile): + if info.encrypted || info.locked { + t.Errorf("%s should be plain", path) + } + } + } + + // Verify encryption config survived. + if dstManifest.Encryption == nil { + t.Fatal("encryption config should survive push/pull") + } + if dstManifest.Encryption.Algorithm != "xchacha20-poly1305" { + t.Errorf("algorithm = %s, want xchacha20-poly1305", dstManifest.Encryption.Algorithm) + } + if _, ok := dstManifest.Encryption.KekSlots["passphrase"]; !ok { + t.Error("passphrase slot should survive push/pull") + } + + // Verify all blobs arrived. + for _, e := range dstManifest.Files { + if e.Hash != "" && !dstGarden.BlobExists(e.Hash) { + t.Errorf("blob missing for %s (hash %s)", e.Path, e.Hash) + } + } + + // Unlock on dest and verify DEK works. + if err := dstGarden.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err != nil { + t.Fatalf("UnlockDEK on dest: %v", err) + } +} + +func toTilde(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + rel, err := filepath.Rel(home, path) + if err != nil || len(rel) > 0 && rel[0] == '.' { + return path + } + return "~/" + rel +}