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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 11:57:03 -07:00
parent c00d9c65c3
commit 3fabd86150
7 changed files with 329 additions and 19 deletions

View File

@@ -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. On next push, the new slot propagates to the server and other machines.
Each machine accumulates its own FIDO2 slot over time. 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. sgardd supports optional TLS via `--tls-cert` and `--tls-key` flags.
When provided, the server uses `credentials.NewTLS()`. Without them, When provided, the server uses `credentials.NewTLS()` with a minimum
it runs insecure (current behavior). The client gains `--tls` and of TLS 1.2. Without them, it runs insecure (for local/trusted networks).
`--tls-ca` flags for connecting to TLS-enabled servers.
The client gains `--tls` and `--tls-ca` flags:
- `--tls` — enables TLS transport (uses system CA pool by default)
- `--tls-ca <path>` — 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) ### Planned: DEK Rotation (Phase 4)
@@ -595,14 +602,14 @@ 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/--tls/--tls-ca flags
encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase 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
cmd/sgardd/ # gRPC server daemon 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/ # 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

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status ## Current Status
**Phase:** Phase 4 in progress. Steps 2122 complete, ready for Step 23. **Phase:** Phase 4 in progress. Steps 2123 complete, ready for Step 24.
**Last updated:** 2026-03-24 **Last updated:** 2026-03-24
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
## Up Next ## Up Next
Step 23: TLS Transport for sgardd. Step 24: DEK Rotation.
## Known Issues / Decisions Deferred ## Known Issues / Decisions Deferred
@@ -86,3 +86,4 @@ Step 23: TLS Transport for sgardd.
| 2026-03-24 | — | Phase 4 planned (Steps 2127): lock/unlock, shell completion, TLS, DEK rotation, real FIDO2, test cleanup. | | 2026-03-24 | — | Phase 4 planned (Steps 2127): 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 | 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 | 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. |

View File

@@ -237,12 +237,12 @@ Depends on Steps 17, 18.
### Step 23: TLS Transport for sgardd ### Step 23: TLS Transport for sgardd
- [ ] `cmd/sgardd/main.go`: add `--tls-cert`, `--tls-key` flags - [x] `cmd/sgardd/main.go`: add `--tls-cert`, `--tls-key` flags
- [ ] Server uses `credentials.NewTLS()` when cert/key provided, insecure otherwise - [x] Server uses `credentials.NewTLS()` when cert/key provided, insecure otherwise
- [ ] Client: add `--tls` flag and `--tls-ca` for custom CA - [x] Client: add `--tls` flag and `--tls-ca` for custom CA
- [ ] Update `cmd/sgard/main.go` and `dialRemote()` for TLS - [x] Update `cmd/sgard/main.go` and `dialRemote()` for TLS
- [ ] Tests: TLS connection with self-signed cert - [x] Tests: TLS connection with self-signed cert (push/pull cycle, reject untrusted client)
- [ ] Update ARCHITECTURE.md and README.md - [x] Update ARCHITECTURE.md and README.md
### Step 24: DEK Rotation ### Step 24: DEK Rotation

View File

@@ -185,6 +185,24 @@ 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.
### 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 ## Encryption
Sensitive files can be encrypted individually: Sensitive files can be encrypted individually:

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"crypto/tls"
"crypto/x509"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -10,6 +12,7 @@ import (
"github.com/kisom/sgard/client" "github.com/kisom/sgard/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
) )
@@ -17,6 +20,8 @@ var (
repoFlag string repoFlag string
remoteFlag string remoteFlag string
sshKeyFlag string sshKeyFlag string
tlsFlag bool
tlsCAFlag string
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -66,8 +71,27 @@ func dialRemote(ctx context.Context) (*client.Client, func(), error) {
cachedToken := client.LoadCachedToken() cachedToken := client.LoadCachedToken()
creds := client.NewTokenCredentials(cachedToken) 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, conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()), transportCreds,
grpc.WithPerRPCCredentials(creds), grpc.WithPerRPCCredentials(creds),
) )
if err != nil { if err != nil {
@@ -90,6 +114,8 @@ func main() {
rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository") rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository")
rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "gRPC server address (host:port)") rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "gRPC server address (host:port)")
rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key") 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 { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"crypto/tls"
"fmt" "fmt"
"net" "net"
"os" "os"
@@ -10,12 +11,15 @@ import (
"github.com/kisom/sgard/sgardpb" "github.com/kisom/sgard/sgardpb"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials"
) )
var ( var (
listenAddr string listenAddr string
repoPath string repoPath string
authKeysPath string authKeysPath string
tlsCertPath string
tlsKeyPath string
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -28,6 +32,21 @@ var rootCmd = &cobra.Command{
} }
var opts []grpc.ServerOption 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 var srvInstance *server.Server
if authKeysPath != "" { if authKeysPath != "" {
@@ -63,6 +82,8 @@ func main() {
rootCmd.Flags().StringVar(&listenAddr, "listen", ":9473", "gRPC listen address") rootCmd.Flags().StringVar(&listenAddr, "listen", ":9473", "gRPC listen address")
rootCmd.Flags().StringVar(&repoPath, "repo", "/srv/sgard", "path to sgard repository") 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(&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 { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)

237
server/tls_test.go Normal file
View File

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