From 4b841cdd822ede59119ca5ee0af5d66205ab9af4 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 23 Mar 2026 23:58:09 -0700 Subject: [PATCH] Step 14: SSH key auth for gRPC. Server: AuthInterceptor parses authorized_keys, extracts SSH signature from gRPC metadata (nonce + timestamp signed by client's SSH key), verifies against authorized public keys with 5-minute timestamp window. Client: SSHCredentials implements PerRPCCredentials, signs nonce+timestamp per request. LoadSigner resolves key from flag, ssh-agent, or default paths. 8 tests: valid auth, reject unauthenticated, reject unauthorized key, reject expired timestamp, metadata generation, plus 2 integration tests (authenticated succeeds, unauthenticated rejected). Co-Authored-By: Claude Opus 4.6 (1M context) --- PROGRESS.md | 5 +- PROJECT_PLAN.md | 9 +- client/auth.go | 124 ++++++++++++++++++++++++ client/auth_test.go | 57 +++++++++++ client/client_test.go | 100 ++++++++++++++++++++ server/auth.go | 213 ++++++++++++++++++++++++++++++++++++++++++ server/auth_test.go | 119 +++++++++++++++++++++++ 7 files changed, 621 insertions(+), 6 deletions(-) create mode 100644 client/auth.go create mode 100644 client/auth_test.go create mode 100644 server/auth.go create mode 100644 server/auth_test.go diff --git a/PROGRESS.md b/PROGRESS.md index 545b3f9..5557c2e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 2 in progress. Steps 9–13 complete, ready for Step 14. +**Phase:** Phase 2 in progress. Steps 9–14 complete, ready for Step 15. **Last updated:** 2026-03-23 @@ -42,7 +42,7 @@ Phase 2: gRPC Remote Sync. ## Up Next -Step 14: SSH Key Auth. +Step 15: CLI Wiring + Prune. ## Known Issues / Decisions Deferred @@ -74,3 +74,4 @@ Step 14: SSH Key Auth. | 2026-03-23 | 12 | gRPC server: 5 RPC handlers (push/pull manifest+blobs, prune), bufconn tests, store.List. | | 2026-03-23 | 12b | Directory recursion in Add, mirror up/down commands, 7 tests. | | 2026-03-23 | 13 | Client library: Push, Pull, Prune with chunked blob streaming. 6 integration tests. | +| 2026-03-23 | 14 | SSH key auth: server interceptor (authorized_keys, signature verification), client PerRPCCredentials (ssh-agent/key file). 8 tests including auth integration. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index f2fab8c..63d80ee 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -148,10 +148,11 @@ Depends on Step 12. ### Step 14: SSH Key Auth -- [ ] `server/auth.go`: AuthInterceptor, parse authorized_keys, verify SSH signatures -- [ ] `client/auth.go`: LoadSigner (ssh-agent or key file), PerRPCCredentials -- [ ] `server/auth_test.go`: in-memory ed25519 key pair, reject unauthenticated -- [ ] `client/auth_test.go`: metadata generation test +- [x] `server/auth.go`: AuthInterceptor, parse authorized_keys, verify SSH signatures +- [x] `client/auth.go`: LoadSigner (ssh-agent or key file), SSHCredentials (PerRPCCredentials) +- [x] `server/auth_test.go`: valid key, reject unauthenticated, reject unauthorized key, reject expired timestamp +- [x] `client/auth_test.go`: metadata generation, no-transport-security +- [x] Integration tests: authenticated push/pull succeeds, unauthenticated is rejected ### Step 15: CLI Wiring + Prune diff --git a/client/auth.go b/client/auth.go new file mode 100644 index 0000000..9a43ca5 --- /dev/null +++ b/client/auth.go @@ -0,0 +1,124 @@ +package client + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "google.golang.org/grpc/credentials" +) + +// SSHCredentials implements grpc.PerRPCCredentials using an SSH signer. +type SSHCredentials struct { + signer ssh.Signer +} + +// NewSSHCredentials creates credentials from an SSH signer. +func NewSSHCredentials(signer ssh.Signer) *SSHCredentials { + return &SSHCredentials{signer: signer} +} + +// GetRequestMetadata signs a nonce+timestamp and returns auth metadata. +func (c *SSHCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("generating nonce: %w", err) + } + + tsUnix := time.Now().Unix() + payload := buildPayload(nonce, tsUnix) + + sig, err := c.signer.Sign(rand.Reader, payload) + if err != nil { + return nil, fmt.Errorf("signing payload: %w", err) + } + + pubkey := c.signer.PublicKey() + pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubkey))) + + return map[string]string{ + "x-sgard-auth-nonce": base64.StdEncoding.EncodeToString(nonce), + "x-sgard-auth-timestamp": strconv.FormatInt(tsUnix, 10), + "x-sgard-auth-signature": base64.StdEncoding.EncodeToString(ssh.Marshal(sig)), + "x-sgard-auth-pubkey": pubkeyStr, + }, nil +} + +// RequireTransportSecurity returns false — auth is via SSH signatures, +// not TLS. Transport security can be added separately. +func (c *SSHCredentials) RequireTransportSecurity() bool { + return false +} + +// Verify that SSHCredentials implements the interface. +var _ credentials.PerRPCCredentials = (*SSHCredentials)(nil) + +// buildPayload constructs nonce || timestamp (big-endian int64). +func buildPayload(nonce []byte, tsUnix int64) []byte { + payload := make([]byte, len(nonce)+8) + copy(payload, nonce) + for i := 7; i >= 0; i-- { + payload[len(nonce)+i] = byte(tsUnix & 0xff) + tsUnix >>= 8 + } + return payload +} + +// LoadSigner loads an SSH signer. Resolution order: +// 1. keyPath (if non-empty) +// 2. SSH agent (if SSH_AUTH_SOCK is set) +// 3. Default key paths: ~/.ssh/id_ed25519, ~/.ssh/id_rsa +func LoadSigner(keyPath string) (ssh.Signer, error) { + if keyPath != "" { + return loadSignerFromFile(keyPath) + } + + // Try ssh-agent. + if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { + conn, err := net.Dial("unix", sock) + if err == nil { + ag := agent.NewClient(conn) + signers, err := ag.Signers() + if err == nil && len(signers) > 0 { + return signers[0], nil + } + _ = conn.Close() + } + } + + // Try default key paths. + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("no SSH key found: %w", err) + } + + for _, name := range []string{"id_ed25519", "id_rsa"} { + path := home + "/.ssh/" + name + signer, err := loadSignerFromFile(path) + if err == nil { + return signer, nil + } + } + + return nil, fmt.Errorf("no SSH key found (tried --ssh-key, agent, ~/.ssh/id_ed25519, ~/.ssh/id_rsa)") +} + +func loadSignerFromFile(path string) (ssh.Signer, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading key %s: %w", path, err) + } + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + return nil, fmt.Errorf("parsing key %s: %w", path, err) + } + return signer, nil +} diff --git a/client/auth_test.go b/client/auth_test.go new file mode 100644 index 0000000..d926f6a --- /dev/null +++ b/client/auth_test.go @@ -0,0 +1,57 @@ +package client + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestSSHCredentialsMetadata(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("creating signer: %v", err) + } + + creds := NewSSHCredentials(signer) + + md, err := creds.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("GetRequestMetadata: %v", err) + } + + // Verify all required fields are present and non-empty. + for _, key := range []string{ + "x-sgard-auth-nonce", + "x-sgard-auth-timestamp", + "x-sgard-auth-signature", + "x-sgard-auth-pubkey", + } { + val, ok := md[key] + if !ok || val == "" { + t.Errorf("missing or empty metadata key %s", key) + } + } +} + +func TestSSHCredentialsNoTransportSecurity(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("creating signer: %v", err) + } + + creds := NewSSHCredentials(signer) + if creds.RequireTransportSecurity() { + t.Error("RequireTransportSecurity should be false") + } +} diff --git a/client/client_test.go b/client/client_test.go index ec7a1c6..d945ca7 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2,6 +2,8 @@ package client import ( "context" + "crypto/ed25519" + "crypto/rand" "errors" "net" "os" @@ -12,6 +14,7 @@ import ( "github.com/kisom/sgard/garden" "github.com/kisom/sgard/server" "github.com/kisom/sgard/sgardpb" + "golang.org/x/crypto/ssh" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" @@ -206,3 +209,100 @@ func TestPrune(t *testing.T) { t.Errorf("removed %d blobs, want 1", removed) } } + +func TestAuthIntegration(t *testing.T) { + // Generate an ed25519 key pair. + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("creating signer: %v", err) + } + + serverDir := t.TempDir() + serverGarden, err := garden.Init(serverDir) + if err != nil { + t.Fatalf("init server garden: %v", err) + } + + // Set up server with auth interceptor. + auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()}) + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer( + grpc.UnaryInterceptor(auth.UnaryInterceptor()), + grpc.StreamInterceptor(auth.StreamInterceptor()), + ) + sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden)) + t.Cleanup(func() { srv.Stop() }) + go func() { _ = srv.Serve(lis) }() + + // Client with SSH credentials. + creds := NewSSHCredentials(signer) + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithPerRPCCredentials(creds), + ) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + c := New(conn) + + // Authenticated request should succeed. + _, err = c.Pull(context.Background(), serverGarden) + if err != nil { + t.Fatalf("authenticated Pull should succeed: %v", err) + } +} + +func TestAuthIntegrationRejectsUnauthenticated(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("creating signer: %v", err) + } + + serverDir := t.TempDir() + serverGarden, err := garden.Init(serverDir) + if err != nil { + t.Fatalf("init server garden: %v", err) + } + + auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()}) + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer( + grpc.UnaryInterceptor(auth.UnaryInterceptor()), + grpc.StreamInterceptor(auth.StreamInterceptor()), + ) + sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden)) + t.Cleanup(func() { srv.Stop() }) + go func() { _ = srv.Serve(lis) }() + + // Client WITHOUT credentials. + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + c := New(conn) + + _, err = c.Pull(context.Background(), serverGarden) + if err == nil { + t.Fatal("unauthenticated Pull should fail") + } +} diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 0000000..c3db067 --- /dev/null +++ b/server/auth.go @@ -0,0 +1,213 @@ +package server + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const ( + // Metadata keys for auth. + metaNonce = "x-sgard-auth-nonce" + metaTimestamp = "x-sgard-auth-timestamp" + metaSignature = "x-sgard-auth-signature" + metaPubkey = "x-sgard-auth-pubkey" + + // authWindow is how far the timestamp can deviate from server time. + authWindow = 5 * time.Minute +) + +// AuthInterceptor verifies SSH key signatures on gRPC requests. +type AuthInterceptor struct { + authorizedKeys map[string]ssh.PublicKey // keyed by fingerprint +} + +// NewAuthInterceptor creates an interceptor from an authorized_keys file. +// The file uses the same format as ~/.ssh/authorized_keys. +func NewAuthInterceptor(path string) (*AuthInterceptor, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading authorized keys: %w", err) + } + + keys := make(map[string]ssh.PublicKey) + rest := data + for len(rest) > 0 { + var key ssh.PublicKey + key, _, _, rest, err = ssh.ParseAuthorizedKey(rest) + if err != nil { + break + } + fp := ssh.FingerprintSHA256(key) + keys[fp] = key + } + + if len(keys) == 0 { + return nil, fmt.Errorf("no valid keys found in %s", path) + } + + return &AuthInterceptor{authorizedKeys: keys}, nil +} + +// NewAuthInterceptorFromKeys creates an interceptor from pre-parsed keys. +// Intended for testing. +func NewAuthInterceptorFromKeys(keys []ssh.PublicKey) *AuthInterceptor { + m := make(map[string]ssh.PublicKey, len(keys)) + for _, k := range keys { + m[ssh.FingerprintSHA256(k)] = k + } + return &AuthInterceptor{authorizedKeys: m} +} + +// UnaryInterceptor returns a gRPC unary server interceptor. +func (a *AuthInterceptor) UnaryInterceptor() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if err := a.verify(ctx); err != nil { + return nil, err + } + return handler(ctx, req) + } +} + +// StreamInterceptor returns a gRPC stream server interceptor. +func (a *AuthInterceptor) StreamInterceptor() grpc.StreamServerInterceptor { + return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if err := a.verify(ss.Context()); err != nil { + return err + } + return handler(srv, ss) + } +} + +func (a *AuthInterceptor) verify(ctx context.Context) error { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return status.Error(codes.Unauthenticated, "missing metadata") + } + + nonceB64 := mdFirst(md, metaNonce) + tsStr := mdFirst(md, metaTimestamp) + sigB64 := mdFirst(md, metaSignature) + pubkeyStr := mdFirst(md, metaPubkey) + + if nonceB64 == "" || tsStr == "" || sigB64 == "" || pubkeyStr == "" { + return status.Error(codes.Unauthenticated, "missing auth metadata fields") + } + + // Parse timestamp and check window. + tsUnix, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + return status.Error(codes.Unauthenticated, "invalid timestamp") + } + ts := time.Unix(tsUnix, 0) + if time.Since(ts).Abs() > authWindow { + return status.Error(codes.Unauthenticated, "timestamp outside allowed window") + } + + // Parse public key and check authorization. + pubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkeyStr)) + if err != nil { + return status.Error(codes.Unauthenticated, "invalid public key") + } + fp := ssh.FingerprintSHA256(pubkey) + authorized, ok := a.authorizedKeys[fp] + if !ok { + return status.Errorf(codes.PermissionDenied, "key %s not authorized", fp) + } + + // Decode nonce and signature. + nonce, err := base64.StdEncoding.DecodeString(nonceB64) + if err != nil { + return status.Error(codes.Unauthenticated, "invalid nonce encoding") + } + + sigBytes, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return status.Error(codes.Unauthenticated, "invalid signature encoding") + } + + sig, err := parseSSHSignature(sigBytes) + if err != nil { + return status.Error(codes.Unauthenticated, "invalid signature format") + } + + // Build the signed payload: nonce + timestamp bytes. + payload := buildPayload(nonce, tsUnix) + + // Verify. + if err := authorized.Verify(payload, sig); err != nil { + return status.Error(codes.Unauthenticated, "signature verification failed") + } + + return nil +} + +// buildPayload constructs the message that is signed: nonce || timestamp (big-endian int64). +func buildPayload(nonce []byte, tsUnix int64) []byte { + payload := make([]byte, len(nonce)+8) + copy(payload, nonce) + for i := 7; i >= 0; i-- { + payload[len(nonce)+i] = byte(tsUnix & 0xff) + tsUnix >>= 8 + } + return payload +} + +// GenerateNonce creates a 32-byte random nonce. +func GenerateNonce() ([]byte, error) { + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("generating nonce: %w", err) + } + return nonce, nil +} + +func mdFirst(md metadata.MD, key string) string { + vals := md.Get(key) + if len(vals) == 0 { + return "" + } + return vals[0] +} + +// parseSSHSignature deserializes an SSH signature from its wire format. +func parseSSHSignature(data []byte) (*ssh.Signature, error) { + if len(data) < 4 { + return nil, fmt.Errorf("signature too short") + } + + // SSH signature wire format: string format, string blob + formatLen := int(data[0])<<24 | int(data[1])<<16 | int(data[2])<<8 | int(data[3]) + if 4+formatLen > len(data) { + return nil, fmt.Errorf("invalid format length") + } + format := string(data[4 : 4+formatLen]) + + rest := data[4+formatLen:] + if len(rest) < 4 { + return nil, fmt.Errorf("missing blob length") + } + blobLen := int(rest[0])<<24 | int(rest[1])<<16 | int(rest[2])<<8 | int(rest[3]) + if 4+blobLen > len(rest) { + return nil, fmt.Errorf("invalid blob length") + } + blob := rest[4 : 4+blobLen] + + _ = strings.TrimSpace(format) // ensure format is clean + + return &ssh.Signature{ + Format: format, + Blob: blob, + }, nil +} diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 0000000..bb08628 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "strconv" + "testing" + "time" + + "golang.org/x/crypto/ssh" + "google.golang.org/grpc/metadata" +) + +func generateTestKey(t *testing.T) (ssh.Signer, ssh.PublicKey) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("creating signer: %v", err) + } + return signer, signer.PublicKey() +} + +func signedContext(t *testing.T, signer ssh.Signer) context.Context { + t.Helper() + + nonce, err := GenerateNonce() + if err != nil { + t.Fatalf("generating nonce: %v", err) + } + tsUnix := time.Now().Unix() + payload := buildPayload(nonce, tsUnix) + + sig, err := signer.Sign(rand.Reader, payload) + if err != nil { + t.Fatalf("signing: %v", err) + } + + pubkeyStr := string(ssh.MarshalAuthorizedKey(signer.PublicKey())) + + md := metadata.New(map[string]string{ + metaNonce: base64.StdEncoding.EncodeToString(nonce), + metaTimestamp: strconv.FormatInt(tsUnix, 10), + metaSignature: base64.StdEncoding.EncodeToString(ssh.Marshal(sig)), + metaPubkey: pubkeyStr, + }) + return metadata.NewIncomingContext(context.Background(), md) +} + +func TestAuthVerifyValid(t *testing.T) { + signer, pubkey := generateTestKey(t) + interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}) + + ctx := signedContext(t, signer) + if err := interceptor.verify(ctx); err != nil { + t.Fatalf("verify should succeed: %v", err) + } +} + +func TestAuthRejectUnauthenticated(t *testing.T) { + _, pubkey := generateTestKey(t) + interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}) + + // No metadata at all. + ctx := context.Background() + if err := interceptor.verify(ctx); err == nil { + t.Fatal("verify should reject missing metadata") + } +} + +func TestAuthRejectUnauthorizedKey(t *testing.T) { + signer1, _ := generateTestKey(t) + _, pubkey2 := generateTestKey(t) + + // Interceptor knows key2 but request is signed by key1. + interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey2}) + + ctx := signedContext(t, signer1) + if err := interceptor.verify(ctx); err == nil { + t.Fatal("verify should reject unauthorized key") + } +} + +func TestAuthRejectExpiredTimestamp(t *testing.T) { + signer, pubkey := generateTestKey(t) + interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}) + + nonce, err := GenerateNonce() + if err != nil { + t.Fatalf("generating nonce: %v", err) + } + // Timestamp 10 minutes ago — outside the 5-minute window. + tsUnix := time.Now().Add(-10 * time.Minute).Unix() + payload := buildPayload(nonce, tsUnix) + + sig, err := signer.Sign(rand.Reader, payload) + if err != nil { + t.Fatalf("signing: %v", err) + } + + pubkeyStr := string(ssh.MarshalAuthorizedKey(signer.PublicKey())) + + md := metadata.New(map[string]string{ + metaNonce: base64.StdEncoding.EncodeToString(nonce), + metaTimestamp: strconv.FormatInt(tsUnix, 10), + metaSignature: base64.StdEncoding.EncodeToString(ssh.Marshal(sig)), + metaPubkey: pubkeyStr, + }) + ctx := metadata.NewIncomingContext(context.Background(), md) + + if err := interceptor.verify(ctx); err == nil { + t.Fatal("verify should reject expired timestamp") + } +}