From 3fabd861506ee2e280eb25403f209a99564e2538 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 11:57:03 -0700 Subject: [PATCH] Step 23: TLS transport for sgardd and sgard client. Server: --tls-cert/--tls-key flags enable TLS (min TLS 1.2). Client: --tls enables TLS transport, --tls-ca for custom CA certs. Two integration tests: push/pull over TLS, reject untrusted client. Co-Authored-By: Claude Opus 4.6 (1M context) --- ARCHITECTURE.md | 21 ++-- PROGRESS.md | 5 +- PROJECT_PLAN.md | 12 +-- README.md | 18 ++++ cmd/sgard/main.go | 28 +++++- cmd/sgardd/main.go | 27 +++++- server/tls_test.go | 237 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 server/tls_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e136a6d..2cd3e38 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -563,12 +563,19 @@ new machine, the user runs `sgard encrypt add-fido2` which: On next push, the new slot propagates to the server and other machines. Each machine accumulates its own FIDO2 slot over time. -### Planned: TLS Transport (Phase 4) +### TLS Transport -sgardd will support optional TLS via `--tls-cert` and `--tls-key` flags. -When provided, the server uses `credentials.NewTLS()`. Without them, -it runs insecure (current behavior). The client gains `--tls` and -`--tls-ca` flags for connecting to TLS-enabled servers. +sgardd supports optional TLS via `--tls-cert` and `--tls-key` flags. +When provided, the server uses `credentials.NewTLS()` with a minimum +of TLS 1.2. Without them, it runs insecure (for local/trusted networks). + +The client gains `--tls` and `--tls-ca` flags: +- `--tls` — enables TLS transport (uses system CA pool by default) +- `--tls-ca ` — custom CA certificate for self-signed server certs + +Both flags must be specified together on the server side; on the client +side `--tls` alone uses the system trust store, and `--tls-ca` adds a +custom root. ### Planned: DEK Rotation (Phase 4) @@ -595,14 +602,14 @@ 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 + main.go # cobra root command, --repo/--remote/--ssh-key/--tls/--tls-ca 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 cmd/sgardd/ # gRPC server daemon - main.go # --listen, --repo, --authorized-keys flags + main.go # --listen, --repo, --authorized-keys, --tls-cert, --tls-key flags garden/ # Core business logic — one file per operation garden.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors diff --git a/PROGRESS.md b/PROGRESS.md index 6d1fbf7..05d47d2 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–22 complete, ready for Step 23. +**Phase:** Phase 4 in progress. Steps 21–23 complete, ready for Step 24. **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 23: TLS Transport for sgardd. +Step 24: DEK Rotation. ## Known Issues / Decisions Deferred @@ -86,3 +86,4 @@ Step 23: TLS Transport for sgardd. | 2026-03-24 | — | Phase 4 planned (Steps 21–27): lock/unlock, shell completion, TLS, DEK rotation, real FIDO2, test cleanup. | | 2026-03-24 | 21 | Lock/unlock toggle commands. garden/lock.go, cmd/sgard/lock.go, 6 tests. | | 2026-03-24 | 22 | Shell completion: cobra built-in, README docs for bash/zsh/fish. | +| 2026-03-24 | 23 | TLS transport: sgardd --tls-cert/--tls-key, sgard --tls/--tls-ca, 2 integration tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 03de787..3b8a23c 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -237,12 +237,12 @@ Depends on Steps 17, 18. ### Step 23: TLS Transport for sgardd -- [ ] `cmd/sgardd/main.go`: add `--tls-cert`, `--tls-key` flags -- [ ] Server uses `credentials.NewTLS()` when cert/key provided, insecure otherwise -- [ ] Client: add `--tls` flag and `--tls-ca` for custom CA -- [ ] Update `cmd/sgard/main.go` and `dialRemote()` for TLS -- [ ] Tests: TLS connection with self-signed cert -- [ ] Update ARCHITECTURE.md and README.md +- [x] `cmd/sgardd/main.go`: add `--tls-cert`, `--tls-key` flags +- [x] Server uses `credentials.NewTLS()` when cert/key provided, insecure otherwise +- [x] Client: add `--tls` flag and `--tls-ca` for custom CA +- [x] Update `cmd/sgard/main.go` and `dialRemote()` for TLS +- [x] Tests: TLS connection with self-signed cert (push/pull cycle, reject untrusted client) +- [x] Update ARCHITECTURE.md and README.md ### Step 24: DEK Rotation diff --git a/README.md b/README.md index f79a3df..7aec7ec 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,24 @@ 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. +### TLS + +To encrypt the connection with TLS: + +```sh +# Server: provide cert and key +sgardd --tls-cert server.crt --tls-key server.key --authorized-keys ~/.ssh/authorized_keys + +# Client: enable TLS (uses system CA pool) +sgard push --remote myserver:9473 --tls + +# Client: with a custom/self-signed CA +sgard push --remote myserver:9473 --tls --tls-ca ca.crt +``` + +Without `--tls-cert`/`--tls-key`, sgardd runs without TLS (suitable for +localhost or trusted networks). + ## Encryption Sensitive files can be encrypted individually: diff --git a/cmd/sgard/main.go b/cmd/sgard/main.go index 311d50d..6969907 100644 --- a/cmd/sgard/main.go +++ b/cmd/sgard/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "os" "path/filepath" @@ -10,6 +12,7 @@ import ( "github.com/kisom/sgard/client" "github.com/spf13/cobra" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) @@ -17,6 +20,8 @@ var ( repoFlag string remoteFlag string sshKeyFlag string + tlsFlag bool + tlsCAFlag string ) var rootCmd = &cobra.Command{ @@ -66,8 +71,27 @@ func dialRemote(ctx context.Context) (*client.Client, func(), error) { cachedToken := client.LoadCachedToken() creds := client.NewTokenCredentials(cachedToken) + var transportCreds grpc.DialOption + if tlsFlag { + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + if tlsCAFlag != "" { + caPEM, err := os.ReadFile(tlsCAFlag) + if err != nil { + return nil, nil, fmt.Errorf("reading CA cert: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return nil, nil, fmt.Errorf("failed to parse CA cert %s", tlsCAFlag) + } + tlsCfg.RootCAs = pool + } + transportCreds = grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)) + } else { + transportCreds = grpc.WithTransportCredentials(insecure.NewCredentials()) + } + conn, err := grpc.NewClient(addr, - grpc.WithTransportCredentials(insecure.NewCredentials()), + transportCreds, grpc.WithPerRPCCredentials(creds), ) if err != nil { @@ -90,6 +114,8 @@ func main() { rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository") rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "gRPC server address (host:port)") rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key") + rootCmd.PersistentFlags().BoolVar(&tlsFlag, "tls", false, "use TLS for remote connection") + rootCmd.PersistentFlags().StringVar(&tlsCAFlag, "tls-ca", "", "path to CA certificate for TLS verification") if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/cmd/sgardd/main.go b/cmd/sgardd/main.go index 804420f..eeace5d 100644 --- a/cmd/sgardd/main.go +++ b/cmd/sgardd/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/tls" "fmt" "net" "os" @@ -10,12 +11,15 @@ import ( "github.com/kisom/sgard/sgardpb" "github.com/spf13/cobra" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" ) var ( - listenAddr string - repoPath string - authKeysPath string + listenAddr string + repoPath string + authKeysPath string + tlsCertPath string + tlsKeyPath string ) var rootCmd = &cobra.Command{ @@ -28,6 +32,21 @@ var rootCmd = &cobra.Command{ } var opts []grpc.ServerOption + + if tlsCertPath != "" && tlsKeyPath != "" { + cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath) + if err != nil { + return fmt.Errorf("loading TLS cert/key: %w", err) + } + opts = append(opts, grpc.Creds(credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }))) + fmt.Println("TLS enabled") + } else if tlsCertPath != "" || tlsKeyPath != "" { + return fmt.Errorf("both --tls-cert and --tls-key must be specified together") + } + var srvInstance *server.Server if authKeysPath != "" { @@ -63,6 +82,8 @@ func main() { rootCmd.Flags().StringVar(&listenAddr, "listen", ":9473", "gRPC listen address") rootCmd.Flags().StringVar(&repoPath, "repo", "/srv/sgard", "path to sgard repository") rootCmd.Flags().StringVar(&authKeysPath, "authorized-keys", "", "path to authorized SSH public keys file") + rootCmd.Flags().StringVar(&tlsCertPath, "tls-cert", "", "path to TLS certificate file") + rootCmd.Flags().StringVar(&tlsKeyPath, "tls-key", "", "path to TLS private key file") if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/server/tls_test.go b/server/tls_test.go new file mode 100644 index 0000000..c747c74 --- /dev/null +++ b/server/tls_test.go @@ -0,0 +1,237 @@ +package server + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "testing" + "time" + + "github.com/kisom/sgard/garden" + "github.com/kisom/sgard/manifest" + "github.com/kisom/sgard/sgardpb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// generateSelfSignedCert creates a self-signed TLS certificate for testing. +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-test"}, + 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 +} + +// setupTLSTest creates a TLS-secured client-server pair. +func setupTLSTest(t *testing.T) (sgardpb.GardenSyncClient, *garden.Garden, *garden.Garden) { + t.Helper() + + serverDir := t.TempDir() + serverGarden, err := garden.Init(serverDir) + if err != nil { + t.Fatalf("init server garden: %v", err) + } + + clientDir := t.TempDir() + clientGarden, err := garden.Init(clientDir) + if err != nil { + t.Fatalf("init client garden: %v", err) + } + + cert, caPool := generateSelfSignedCert(t) + + // Server with TLS. + serverCreds := credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }) + srv := grpc.NewServer(grpc.Creds(serverCreds)) + sgardpb.RegisterGardenSyncServer(srv, 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) + }() + + // Client with TLS, trusting the self-signed CA. + clientCreds := credentials.NewTLS(&tls.Config{ + RootCAs: caPool, + MinVersion: tls.VersionTLS12, + }) + conn, err := grpc.NewClient(lis.Addr().String(), + grpc.WithTransportCredentials(clientCreds), + ) + if err != nil { + t.Fatalf("dial TLS: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + client := sgardpb.NewGardenSyncClient(conn) + return client, serverGarden, clientGarden +} + +func TestTLS_PushPullCycle(t *testing.T) { + client, serverGarden, _ := setupTLSTest(t) + ctx := context.Background() + + // Write test blobs to get real hashes. + tmpDir := t.TempDir() + tmpGarden, err := garden.Init(tmpDir) + if err != nil { + t.Fatalf("init tmp garden: %v", err) + } + blobData := []byte("TLS test blob content") + hash, err := tmpGarden.WriteBlob(blobData) + if err != nil { + t.Fatalf("WriteBlob: %v", err) + } + + now := time.Now().UTC().Add(time.Hour) + clientManifest := &manifest.Manifest{ + Version: 1, + Created: now, + Updated: now, + Files: []manifest.Entry{ + {Path: "~/.tlstest", Hash: hash, Type: "file", Mode: "0644", Updated: now}, + }, + } + + // Push manifest over TLS. + pushResp, err := client.PushManifest(ctx, &sgardpb.PushManifestRequest{ + Manifest: ManifestToProto(clientManifest), + }) + if err != nil { + t.Fatalf("PushManifest over TLS: %v", err) + } + if pushResp.Decision != sgardpb.PushManifestResponse_ACCEPTED { + t.Fatalf("decision: got %v, want ACCEPTED", pushResp.Decision) + } + + // Push blob over TLS. + stream, err := client.PushBlobs(ctx) + if err != nil { + t.Fatalf("PushBlobs over TLS: %v", err) + } + if err := stream.Send(&sgardpb.PushBlobsRequest{ + Chunk: &sgardpb.BlobChunk{Hash: hash, Data: blobData}, + }); err != nil { + t.Fatalf("Send blob: %v", err) + } + blobResp, err := stream.CloseAndRecv() + if err != nil { + t.Fatalf("CloseAndRecv: %v", err) + } + if blobResp.BlobsReceived != 1 { + t.Errorf("blobs_received: got %d, want 1", blobResp.BlobsReceived) + } + + // Verify blob arrived on server. + if !serverGarden.BlobExists(hash) { + t.Error("blob not found on server after TLS push") + } + + // Pull manifest back over TLS. + pullResp, err := client.PullManifest(ctx, &sgardpb.PullManifestRequest{}) + if err != nil { + t.Fatalf("PullManifest over TLS: %v", err) + } + pulledManifest := ProtoToManifest(pullResp.GetManifest()) + if len(pulledManifest.Files) != 1 { + t.Fatalf("pulled manifest files: got %d, want 1", len(pulledManifest.Files)) + } + if pulledManifest.Files[0].Path != "~/.tlstest" { + t.Errorf("pulled path: got %q, want %q", pulledManifest.Files[0].Path, "~/.tlstest") + } +} + +func TestTLS_RejectsPlaintextClient(t *testing.T) { + cert, _ := 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, 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) + }() + + // Try to connect without TLS — should fail. + conn, err := grpc.NewClient(lis.Addr().String(), + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + // No RootCAs — won't trust the self-signed cert. + MinVersion: tls.VersionTLS12, + })), + ) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + + client := sgardpb.NewGardenSyncClient(conn) + _, err = client.PullManifest(context.Background(), &sgardpb.PullManifestRequest{}) + if err == nil { + t.Fatal("expected error when connecting without trusted CA to TLS server") + } +}