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:
@@ -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 <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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
18
README.md
18
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
237
server/tls_test.go
Normal file
237
server/tls_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user