diff --git a/cmd/agent/git_ssh_signature.go b/cmd/agent/git_ssh_signature.go index 30a78f01a..599aa8672 100644 --- a/cmd/agent/git_ssh_signature.go +++ b/cmd/agent/git_ssh_signature.go @@ -16,6 +16,7 @@ type GitSSHSignatureCmd struct { Namespace string BufferFile string Command string + UseAgent bool } // NewGitSSHSignatureCmd creates new git-ssh-signature command @@ -50,13 +51,14 @@ func NewGitSSHSignatureCmd(flags *flags.GlobalFlags) *cobra.Command { cmd.BufferFile = args[len(args)-1] return gitsshsigning.HandleGitSSHProgramCall( - cmd.CertPath, cmd.Namespace, cmd.BufferFile, logger) + cmd.CertPath, cmd.Namespace, cmd.BufferFile, cmd.UseAgent, logger) }, } gitSshSignatureCmd.Flags().StringVarP(&cmd.CertPath, "file", "f", "", "Path to the private key") gitSshSignatureCmd.Flags().StringVarP(&cmd.Namespace, "namespace", "n", "", "Namespace") gitSshSignatureCmd.Flags().StringVarP(&cmd.Command, "command", "Y", "sign", "Command - should be 'sign'") + gitSshSignatureCmd.Flags().BoolVarP(&cmd.UseAgent, "agent", "U", false, "Key resides in ssh-agent") return gitSshSignatureCmd } diff --git a/pkg/gitsshsigning/client.go b/pkg/gitsshsigning/client.go index 5e6c6d549..8044612d4 100644 --- a/pkg/gitsshsigning/client.go +++ b/pkg/gitsshsigning/client.go @@ -13,13 +13,13 @@ import ( ) // HandleGitSSHProgramCall implements logic handling call from git when signing a commit -func HandleGitSSHProgramCall(certPath, namespace, bufferFile string, log log.Logger) error { +func HandleGitSSHProgramCall(certPath, namespace, bufferFile string, useAgent bool, log log.Logger) error { content, err := extractContentFromGitBuffer(bufferFile) if err != nil { return err } - signature, err := requestContentSignature(content, certPath, log) + signature, err := requestContentSignature(content, certPath, useAgent, log) if err != nil { return err } @@ -37,8 +37,8 @@ func extractContentFromGitBuffer(bufferFile string) ([]byte, error) { } // requestContentSignature sends an HTTP request to the credentials server to sign the content -func requestContentSignature(content []byte, certPath string, log log.Logger) ([]byte, error) { - requestBody, err := createSignatureRequestBody(content, certPath) +func requestContentSignature(content []byte, certPath string, useAgent bool, log log.Logger) ([]byte, error) { + requestBody, err := createSignatureRequestBody(content, certPath, useAgent) if err != nil { return nil, err } @@ -61,10 +61,11 @@ func writeSignatureToFile(signature []byte, bufferFile string, log log.Logger) e return nil } -func createSignatureRequestBody(content []byte, certPath string) ([]byte, error) { +func createSignatureRequestBody(content []byte, certPath string, useAgent bool) ([]byte, error) { request := &GitSSHSignatureRequest{ - Content: string(content), - KeyPath: certPath, + Content: string(content), + KeyPath: certPath, + UseAgent: useAgent, } return json.Marshal(request) } diff --git a/pkg/gitsshsigning/server.go b/pkg/gitsshsigning/server.go index 9c4fc6982..d80b6e057 100644 --- a/pkg/gitsshsigning/server.go +++ b/pkg/gitsshsigning/server.go @@ -7,8 +7,9 @@ import ( ) type GitSSHSignatureRequest struct { - Content string - KeyPath string + Content string + KeyPath string + UseAgent bool `json:"UseAgent,omitempty"` } type GitSSHSignatureResponse struct { @@ -23,8 +24,14 @@ func (req *GitSSHSignatureRequest) Sign() (*GitSSHSignatureResponse, error) { var commitBuffer bytes.Buffer commitBuffer.WriteString(req.Content) + // Build ssh-keygen arguments + args := []string{"-Y", "sign", "-f", req.KeyPath, "-n", "git"} + if req.UseAgent { + args = append(args, "-U") + } + // Create the command to run ssh-keygen - cmd := exec.Command("ssh-keygen", "-Y", "sign", "-f", req.KeyPath, "-n", "git") + cmd := exec.Command("ssh-keygen", args...) cmd.Stdin = &commitBuffer // Capture the output of the command diff --git a/pkg/gitsshsigning/server_test.go b/pkg/gitsshsigning/server_test.go new file mode 100644 index 000000000..8e118cafa --- /dev/null +++ b/pkg/gitsshsigning/server_test.go @@ -0,0 +1,137 @@ +package gitsshsigning + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestSign_WithoutAgent(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "gitsshsigning-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + // Generate a test key pair + keyPath := filepath.Join(tmpDir, "test_key") + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q") + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to generate test key: %v", err) + } + + // Test signing without UseAgent (direct private key) + req := &GitSSHSignatureRequest{ + Content: "test commit content\n", + KeyPath: keyPath, + UseAgent: false, + } + + resp, err := req.Sign() + if err != nil { + t.Fatalf("Sign() failed: %v", err) + } + + if len(resp.Signature) == 0 { + t.Error("Expected non-empty signature") + } + + // Verify signature looks like an SSH signature + sigStr := string(resp.Signature) + if !strings.Contains(sigStr, "-----BEGIN SSH SIGNATURE-----") { + t.Errorf("Signature doesn't look like SSH signature: %s", sigStr) + } +} + +func TestSign_WithAgent(t *testing.T) { + // Skip if SSH_AUTH_SOCK is not set (no agent running) + if os.Getenv("SSH_AUTH_SOCK") == "" { + t.Skip("Skipping agent test: SSH_AUTH_SOCK not set") + } + + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "gitsshsigning-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + // Generate a test key pair + keyPath := filepath.Join(tmpDir, "test_key") + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q") + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to generate test key: %v", err) + } + + // Add key to agent + cmd = exec.Command("ssh-add", keyPath) + if err := cmd.Run(); err != nil { + t.Skipf("Failed to add key to agent (agent may not be running): %v", err) + } + + // Remove key from agent when done + defer func() { + _ = exec.Command("ssh-add", "-d", keyPath).Run() + }() + + // Test signing with UseAgent (public key + agent lookup) + req := &GitSSHSignatureRequest{ + Content: "test commit content\n", + KeyPath: keyPath + ".pub", // Use public key path + UseAgent: true, + } + + resp, err := req.Sign() + if err != nil { + t.Fatalf("Sign() with UseAgent=true failed: %v", err) + } + + if len(resp.Signature) == 0 { + t.Error("Expected non-empty signature") + } + + // Verify signature looks like an SSH signature + sigStr := string(resp.Signature) + if !strings.Contains(sigStr, "-----BEGIN SSH SIGNATURE-----") { + t.Errorf("Signature doesn't look like SSH signature: %s", sigStr) + } +} + +func TestSign_WithAgent_NoAgentFails(t *testing.T) { + // This test verifies that using UseAgent=true with a public key + // fails when no agent has the corresponding private key + + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "gitsshsigning-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + // Generate a test key pair (not added to any agent) + keyPath := filepath.Join(tmpDir, "test_key") + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q") + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to generate test key: %v", err) + } + + // Temporarily unset SSH_AUTH_SOCK to ensure no agent + oldSock := os.Getenv("SSH_AUTH_SOCK") + _ = os.Unsetenv("SSH_AUTH_SOCK") + defer func() { _ = os.Setenv("SSH_AUTH_SOCK", oldSock) }() + + // Test signing with UseAgent but no agent available + req := &GitSSHSignatureRequest{ + Content: "test commit content\n", + KeyPath: keyPath + ".pub", + UseAgent: true, + } + + _, err = req.Sign() + if err == nil { + t.Error("Expected Sign() to fail when UseAgent=true but no agent available") + } +}