Step 27: Phase 4 polish.

E2e integration test covering TLS + encryption + locked files in a
push/pull cycle (integration/phase4_test.go). Final doc updates.
Phase 4 complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 16:18:42 -07:00
parent 3cac9a3530
commit 9aad737c49
3 changed files with 273 additions and 7 deletions

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status
**Phase:** Phase 4 in progress. Steps 2126 complete, ready for Step 27.
**Phase:** Phase 4 complete. All 7 steps done (2127).
**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. |

View File

@@ -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

265
integration/phase4_test.go Normal file
View File

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