Skip to content

Commit 696b944

Browse files
authored
fix: resolve SSH signature key path for container-to-host forwarding (#714)
1 parent ed04623 commit 696b944

File tree

8 files changed

+250
-12
lines changed

8 files changed

+250
-12
lines changed

e2e/tests/ssh/ssh.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,36 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
140140
).Run()
141141
framework.ExpectNoError(err)
142142

143+
agentOut, err := exec.Command("ssh-agent", "-s").Output()
144+
framework.ExpectNoError(err)
145+
t := ginkgo.GinkgoT()
146+
var agentPID string
147+
for line := range strings.SplitSeq(string(agentOut), "\n") {
148+
for _, prefix := range []string{"SSH_AUTH_SOCK=", "SSH_AGENT_PID="} {
149+
if _, after, ok := strings.Cut(line, prefix); ok {
150+
val := after
151+
if semi := strings.Index(val, ";"); semi >= 0 {
152+
val = val[:semi]
153+
}
154+
key := prefix[:len(prefix)-1]
155+
if key == "SSH_AGENT_PID" {
156+
agentPID = val
157+
}
158+
t.Setenv(key, val)
159+
}
160+
}
161+
}
162+
ginkgo.DeferCleanup(func(_ context.Context) {
163+
if agentPID != "" {
164+
// #nosec G204 -- controlled pid from ssh-agent we started
165+
_ = exec.Command("kill", agentPID).Run()
166+
}
167+
})
168+
169+
// #nosec G204 -- test command with controlled arguments
170+
err = exec.Command("ssh-add", keyPath).Run()
171+
framework.ExpectNoError(err)
172+
143173
// Start workspace with git-ssh-signing-key flag
144174
err = f.DevPodUp(ctx, tempDir, "--git-ssh-signing-key", keyPath+".pub")
145175
framework.ExpectNoError(err)
@@ -162,6 +192,11 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
162192
"echo test > testfile",
163193
"git add testfile",
164194
"git commit -m 'signed test commit' 2>&1",
195+
"ssh-add -L | head -1 > /tmp/test_signing_key.pub",
196+
"git config --global user.signingkey /tmp/test_signing_key.pub",
197+
"echo world >> file.txt",
198+
"git add file.txt",
199+
"git commit -m 'signed commit with file path key' 2>&1",
165200
}, " && ")
166201

167202
// The signing key must be passed on each SSH invocation so the
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package tunnelserver
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/skevetter/devpod/pkg/agent/tunnel"
9+
"github.com/skevetter/devpod/pkg/gitsshsigning"
10+
"github.com/skevetter/log"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestGitSSHSignature_DeserializesPublicKey(t *testing.T) {
16+
req := gitsshsigning.GitSSHSignatureRequest{
17+
Content: "tree abc\nauthor Test\n\ncommit",
18+
KeyPath: "/container/tmp/.git_signing_key_tmpXXX",
19+
PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFake test@test.com",
20+
}
21+
reqJSON, err := json.Marshal(req)
22+
require.NoError(t, err)
23+
24+
ts := New(log.Discard)
25+
26+
_, err = ts.GitSSHSignature(context.Background(), &tunnel.Message{
27+
Message: string(reqJSON),
28+
})
29+
require.Error(t, err)
30+
assert.NotContains(t, err.Error(), "git ssh sign request")
31+
assert.NotContains(t, err.Error(), "resolve signing key")
32+
assert.Contains(t, err.Error(), "failed to sign commit")
33+
}

pkg/credentials/integration_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,49 @@ func TestIntegration_SigningSuccess_WritesSigFile(t *testing.T) {
9292
require.NoError(t, readErr)
9393
assert.Equal(t, expectedSig, sigContent)
9494
}
95+
96+
func TestIntegration_SignatureRequest_IncludesPublicKeyContent(t *testing.T) {
97+
var receivedMessage string
98+
mock := &mockTunnelClient{
99+
gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) {
100+
receivedMessage = msg.Message
101+
sig := gitsshsigning.GitSSHSignatureResponse{
102+
Signature: []byte(
103+
"-----BEGIN SSH SIGNATURE-----\ntest\n-----END SSH SIGNATURE-----\n",
104+
),
105+
}
106+
jsonBytes, err := json.Marshal(sig)
107+
if err != nil {
108+
return nil, fmt.Errorf("marshal sig: %w", err)
109+
}
110+
return &tunnel.Message{Message: string(jsonBytes)}, nil
111+
},
112+
}
113+
114+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115+
err := handleGitSSHSignatureRequest(context.Background(), w, r, mock, log.Discard)
116+
if err != nil {
117+
http.Error(w, err.Error(), http.StatusInternalServerError)
118+
}
119+
}))
120+
defer server.Close()
121+
122+
gitsshsigning.SetSignatureServerURL(server.URL + "/git-ssh-signature")
123+
t.Cleanup(func() { gitsshsigning.SetSignatureServerURL("") })
124+
125+
tmpDir := t.TempDir()
126+
certFile := filepath.Join(tmpDir, "test_key.pub")
127+
pubKeyContent := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest test@example.com"
128+
require.NoError(t, os.WriteFile(certFile, []byte(pubKeyContent), 0o600))
129+
130+
bufferFile := filepath.Join(tmpDir, "buffer")
131+
require.NoError(t, os.WriteFile(bufferFile, []byte("commit content"), 0o600))
132+
133+
err := gitsshsigning.HandleGitSSHProgramCall(certFile, "git", bufferFile, log.Discard)
134+
require.NoError(t, err)
135+
136+
var req gitsshsigning.GitSSHSignatureRequest
137+
require.NoError(t, json.Unmarshal([]byte(receivedMessage), &req))
138+
assert.Equal(t, pubKeyContent, req.PublicKey)
139+
assert.Equal(t, "commit content", req.Content)
140+
}

pkg/devcontainer/sshtunnel/sshtunnel.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,15 @@ func establishSSHSession(
349349
}
350350

351351
// setupSSHAgentForwarding configures SSH agent forwarding on the session.
352-
// Errors are returned to the caller rather than sent to a channel directly.
352+
//
353+
// Failures are logged but never fatal. This matches OpenSSH's behavior:
354+
// - clientloop.c: client_request_agent() returns NULL on failure,
355+
// sending SSH2_MSG_CHANNEL_OPEN_FAILURE without terminating the session.
356+
// - ssh_config(5) ExitOnForwardFailure only covers "dynamic, tunnel,
357+
// local, and remote port forwardings" — agent forwarding is excluded.
358+
//
359+
// Stale SSH_AUTH_SOCK is common in practice (tmux, screen, reconnected
360+
// terminals), so a fatal error here would break devpod up for many users.
353361
func setupSSHAgentForwarding(
354362
ts *sshSessionTunnel,
355363
sshClient *ssh.Client,
@@ -368,9 +376,9 @@ func setupSSHAgentForwarding(
368376
}
369377

370378
if err != nil {
371-
ts.opts.Log.Warnf("SSH agent forwarding failed: %v", err)
379+
ts.opts.Log.Warnf("SSH agent forwarding failed (continuing without agent): %v", err)
372380
}
373-
return err
381+
return nil
374382
}
375383

376384
// runCommandInSSHTunnel runs the agent command over the SSH tunnel and returns

pkg/gitsshsigning/client.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,18 @@ func writeSignatureToFile(signature []byte, bufferFile string, log log.Logger) e
8080
}
8181

8282
func createSignatureRequestBody(content []byte, certPath string) ([]byte, error) {
83+
var publicKey string
84+
pubKeyData, readErr := os.ReadFile(
85+
certPath,
86+
) // #nosec G304 -- certPath comes from git config, not user input
87+
if readErr == nil {
88+
publicKey = strings.TrimSpace(string(pubKeyData))
89+
}
90+
8391
request := &GitSSHSignatureRequest{
84-
Content: string(content),
85-
KeyPath: certPath,
92+
Content: string(content),
93+
KeyPath: certPath,
94+
PublicKey: publicKey,
8695
}
8796
return json.Marshal(request)
8897
}

pkg/gitsshsigning/client_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"net/http"
66
"net/http/httptest"
7+
"os"
8+
"path/filepath"
79
"testing"
810

911
"github.com/skevetter/log"
@@ -96,3 +98,27 @@ func TestRequestContentSignature_InvalidJSON(t *testing.T) {
9698
require.Error(t, err)
9799
assert.Contains(t, err.Error(), "not json at all")
98100
}
101+
102+
func TestCreateSignatureRequestBody_IncludesPublicKeyContent(t *testing.T) {
103+
certPath := filepath.Join(t.TempDir(), "test_key.pub")
104+
pubKeyContent := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest test@example.com"
105+
require.NoError(t, os.WriteFile(certPath, []byte(pubKeyContent), 0o600))
106+
107+
body, err := createSignatureRequestBody([]byte("commit content"), certPath)
108+
require.NoError(t, err)
109+
110+
var req GitSSHSignatureRequest
111+
require.NoError(t, json.Unmarshal(body, &req))
112+
assert.Equal(t, "commit content", req.Content)
113+
assert.Equal(t, certPath, req.KeyPath)
114+
assert.Equal(t, pubKeyContent, req.PublicKey)
115+
}
116+
117+
func TestCreateSignatureRequestBody_MissingCertFile_OmitsPublicKey(t *testing.T) {
118+
body, err := createSignatureRequestBody([]byte("commit content"), "/nonexistent/key.pub")
119+
require.NoError(t, err)
120+
121+
var req GitSSHSignatureRequest
122+
require.NoError(t, json.Unmarshal(body, &req))
123+
assert.Empty(t, req.PublicKey)
124+
}

pkg/gitsshsigning/server.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package gitsshsigning
33
import (
44
"bytes"
55
"fmt"
6+
"os"
67
"os/exec"
78
)
89

910
type GitSSHSignatureRequest struct {
10-
Content string
11-
KeyPath string
11+
Content string
12+
KeyPath string
13+
PublicKey string // Public key content; when set, written to a temp file for ssh-keygen
1214
}
1315

1416
type GitSSHSignatureResponse struct {
@@ -18,22 +20,38 @@ type GitSSHSignatureResponse struct {
1820
// Sign signs the content using the private key and returns the signature.
1921
// This is intended to be a drop-in replacement for gpg.ssh.program for git,
2022
// so we simply execute ssh-keygen in the same way as git would do locally.
23+
//
24+
// When PublicKey is set, it is written to a temporary file that ssh-keygen
25+
// can read. This is necessary because the original KeyPath comes from
26+
// inside the container and does not exist on the host where Sign() runs.
2127
func (req *GitSSHSignatureRequest) Sign() (*GitSSHSignatureResponse, error) {
22-
// Create a buffer to store the commit content
28+
keyFile, cleanup, err := req.resolveKeyFile()
29+
if err != nil {
30+
return nil, fmt.Errorf("resolve signing key: %w", err)
31+
}
32+
defer cleanup()
33+
2334
var commitBuffer bytes.Buffer
2435
commitBuffer.WriteString(req.Content)
2536

26-
// Create the command to run ssh-keygen
27-
cmd := exec.Command("ssh-keygen", "-Y", "sign", "-f", req.KeyPath, "-n", "git")
37+
//nolint:gosec // keyFile is a controlled temp path or validated KeyPath
38+
cmd := exec.Command(
39+
"ssh-keygen",
40+
"-Y",
41+
"sign",
42+
"-f",
43+
keyFile,
44+
"-n",
45+
"git",
46+
)
2847
cmd.Stdin = &commitBuffer
2948

30-
// Capture the output of the command
3149
var out bytes.Buffer
3250
var stderr bytes.Buffer
3351
cmd.Stdout = &out
3452
cmd.Stderr = &stderr
3553

36-
err := cmd.Run()
54+
err = cmd.Run()
3755
if err != nil {
3856
return nil, fmt.Errorf("failed to sign commit: %w, stderr: %s", err, stderr.String())
3957
}
@@ -42,3 +60,35 @@ func (req *GitSSHSignatureRequest) Sign() (*GitSSHSignatureResponse, error) {
4260
Signature: out.Bytes(),
4361
}, nil
4462
}
63+
64+
// resolveKeyFile returns the path to use for ssh-keygen -f and a cleanup function.
65+
// When PublicKey content is available, it writes a temp file. Otherwise falls back to KeyPath.
66+
func (req *GitSSHSignatureRequest) resolveKeyFile() (string, func(), error) {
67+
noop := func() {}
68+
69+
if req.PublicKey == "" {
70+
return req.KeyPath, noop, nil
71+
}
72+
73+
// ssh-keygen -Y sign -f <path> reads the public key directly from <path>
74+
// to identify which SSH agent key to use for signing. We write the public
75+
// key content to a temp file and pass that path to -f.
76+
tmpFile, err := os.CreateTemp("", ".git_signing_key_*")
77+
if err != nil {
78+
return "", noop, fmt.Errorf("create temp key file: %w", err)
79+
}
80+
keyPath := tmpFile.Name()
81+
82+
if _, err := tmpFile.WriteString(req.PublicKey); err != nil {
83+
_ = tmpFile.Close()
84+
_ = os.Remove(keyPath)
85+
return "", noop, fmt.Errorf("write public key: %w", err)
86+
}
87+
_ = tmpFile.Close()
88+
89+
cleanup := func() {
90+
_ = os.Remove(keyPath)
91+
}
92+
93+
return keyPath, cleanup, nil
94+
}

pkg/gitsshsigning/server_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package gitsshsigning
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestSign_WithPublicKeyContent_WritesToTempFile(t *testing.T) {
11+
req := &GitSSHSignatureRequest{
12+
Content: "tree abc123\nauthor Test <test@example.com>\n\ntest commit",
13+
KeyPath: "/tmp/.git_signing_key_does_not_exist",
14+
PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyForTesting test@example.com",
15+
}
16+
17+
_, err := req.Sign()
18+
require.Error(t, err)
19+
assert.NotContains(t, err.Error(), "/tmp/.git_signing_key_does_not_exist")
20+
}
21+
22+
func TestSign_NonExistentKeyPath_ReturnsError(t *testing.T) {
23+
req := &GitSSHSignatureRequest{
24+
Content: "tree abc123\nauthor Test <test@example.com>\n\ntest commit",
25+
KeyPath: "/tmp/.git_signing_key_does_not_exist",
26+
}
27+
28+
_, err := req.Sign()
29+
require.Error(t, err)
30+
assert.Contains(t, err.Error(), "failed to sign commit")
31+
}

0 commit comments

Comments
 (0)