Skip to content
5 changes: 4 additions & 1 deletion cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,10 @@ func (cmd *UpCmd) configureWorkspace(

// Run after dotfiles so the signing config isn't overwritten by a
// dotfiles installer that replaces .gitconfig.
if cmd.GitSSHSigningKey != "" {
gitSSHSignatureEnabled := devPodConfig.ContextOption(
config.ContextOptionGitSSHSignatureForwarding,
) == "true"
if cmd.GitSSHSigningKey != "" && gitSSHSignatureEnabled {
if err := setupGitSSHSignature(cmd.GitSSHSigningKey, client); err != nil {
return err
}
Expand Down
130 changes: 127 additions & 3 deletions e2e/tests/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"github.com/skevetter/devpod/e2e/framework"
)

const osWindows = "windows"

var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ordered, func() {
var initialDir string

Expand Down Expand Up @@ -59,7 +61,7 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord

// ginkgo.It("should start a new workspace with a docker provider (default) and forward gpg agent into it", func() {
// // skip windows for now
// if runtime.GOOS == "windows" {
// if runtime.GOOS == osWindows {
// return
// }
//
Expand Down Expand Up @@ -109,7 +111,7 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
"should set up git SSH signature helper and sign a commit",
ginkgo.SpecTimeout(7*time.Minute),
func(ctx ginkgo.SpecContext) {
if runtime.GOOS == "windows" {
if runtime.GOOS == osWindows {
ginkgo.Skip("skipping on windows")
}

Expand Down Expand Up @@ -253,10 +255,132 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
},
)

ginkgo.It(
"should not install git SSH signature helper when signing key is not provided",
ginkgo.SpecTimeout(5*time.Minute),
func(ctx ginkgo.SpecContext) {
if runtime.GOOS == osWindows {
ginkgo.Skip("skipping on windows")
}

tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/ssh-signing")
framework.ExpectNoError(err)

f := framework.NewDefaultFramework(initialDir + "/bin")
_ = f.DevPodProviderAdd(ctx, "docker")
err = f.DevPodProviderUse(ctx, "docker")
framework.ExpectNoError(err)

ginkgo.DeferCleanup(func(cleanupCtx context.Context) {
_ = f.DevPodWorkspaceDelete(cleanupCtx, tempDir)
framework.CleanupTempDir(initialDir, tempDir)
})

// Start workspace WITHOUT --git-ssh-signing-key
err = f.DevPodUp(ctx, tempDir)
framework.ExpectNoError(err)

// Verify the helper script was NOT installed
out, err := f.DevPodSSH(ctx, tempDir,
"test -x /usr/local/bin/devpod-ssh-signature && echo EXISTS || echo MISSING",
)
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(
gomega.Equal("MISSING"),
"devpod-ssh-signature helper should not be installed without --git-ssh-signing-key",
)

// Verify git config was NOT set for SSH signing
out, err = f.DevPodSSH(ctx, tempDir,
"git config --global gpg.ssh.program || echo UNSET",
)
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(
gomega.Equal("UNSET"),
"gpg.ssh.program should not be configured without --git-ssh-signing-key",
)
},
)

ginkgo.It(
"should surface clear error when SSH signing fails",
ginkgo.SpecTimeout(7*time.Minute),
func(ctx ginkgo.SpecContext) {
if runtime.GOOS == osWindows {
ginkgo.Skip("skipping on windows")
}

tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/ssh-signing")
framework.ExpectNoError(err)

f := framework.NewDefaultFramework(initialDir + "/bin")
_ = f.DevPodProviderAdd(ctx, "docker")
err = f.DevPodProviderUse(ctx, "docker")
framework.ExpectNoError(err)

ginkgo.DeferCleanup(func(cleanupCtx context.Context) {
_ = f.DevPodWorkspaceDelete(cleanupCtx, tempDir)
framework.CleanupTempDir(initialDir, tempDir)
})

// Generate a key but do NOT add it to the ssh-agent so signing will fail
sshKeyDir, err := os.MkdirTemp("", "devpod-ssh-signing-err-test")
framework.ExpectNoError(err)
defer func() { _ = os.RemoveAll(sshKeyDir) }()

keyPath := filepath.Join(sshKeyDir, "id_ed25519")
// #nosec G204 -- test command with controlled arguments
err = exec.Command(
"ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q",
).Run()
framework.ExpectNoError(err)

// Start workspace with signing key
err = f.DevPodUp(ctx, tempDir, "--git-ssh-signing-key", keyPath+".pub")
framework.ExpectNoError(err)

// Attempt a signed commit — this should fail because the key
// is not in the agent, but the error must be human-readable.
commitCmd := strings.Join([]string{
"cd /tmp",
"git init test-sign-err-repo",
"cd test-sign-err-repo",
"git config user.name 'Test User'",
"git config user.email 'test@example.com'",
"git config commit.gpgsign true",
"echo test > testfile",
"git add testfile",
"git commit -m 'signed test commit' 2>&1",
}, " && ")

stdout, stderr, err := f.ExecCommandCapture(ctx, []string{
"ssh",
"--agent-forwarding",
"--start-services",
tempDir,
"--command", commitCmd,
})
ginkgo.GinkgoWriter.Printf("error commit stdout: %s\n", stdout)
ginkgo.GinkgoWriter.Printf("error commit stderr: %s\n", stderr)

// The commit should fail
combined := stdout + stderr
if err != nil {
combined += err.Error()
}

// The error must NOT contain JSON decode artifacts
gomega.Expect(combined).NotTo(
gomega.ContainSubstring("invalid character"),
"error should not contain JSON parse errors — error messages must be human-readable",
)
},
)

ginkgo.It(
"should start a new workspace with a docker provider (default) and forward a port into it",
func(ctx context.Context) {
if runtime.GOOS == "windows" {
if runtime.GOOS == osWindows {
ginkgo.Skip("skipping on windows")
}

Expand Down
94 changes: 94 additions & 0 deletions pkg/credentials/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package credentials

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/skevetter/devpod/pkg/agent/tunnel"
"github.com/skevetter/devpod/pkg/gitsshsigning"
"github.com/skevetter/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestIntegration_SigningFailure_SurfacesServerError(t *testing.T) {
mock := &mockTunnelClient{
gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) {
return nil, fmt.Errorf(
"failed to sign commit: exit status 1, stderr: Permission denied (publickey)",
)
},
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := handleGitSSHSignatureRequest(context.Background(), w, r, mock, log.Discard)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}))
defer server.Close()

gitsshsigning.SetSignatureServerURL(server.URL + "/git-ssh-signature")
t.Cleanup(func() { gitsshsigning.SetSignatureServerURL("") })

tmpDir := t.TempDir()
bufferFile := filepath.Join(tmpDir, "buffer")
require.NoError(t, os.WriteFile(bufferFile, []byte("commit content"), 0o600))

err := gitsshsigning.HandleGitSSHProgramCall("/tmp/key.pub", "git", bufferFile, log.Discard)

assert.Error(t, err)
assert.Contains(t, err.Error(), "Permission denied")
assert.NotContains(t, err.Error(), "invalid character")

_, statErr := os.Stat(bufferFile + ".sig")
assert.True(t, os.IsNotExist(statErr), "expected no .sig file to be created")
}

func TestIntegration_SigningSuccess_WritesSigFile(t *testing.T) {
expectedSig := []byte(
"-----BEGIN SSH SIGNATURE-----\ntest-signature\n-----END SSH SIGNATURE-----\n",
)

mock := &mockTunnelClient{
gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) {
response := gitsshsigning.GitSSHSignatureResponse{Signature: expectedSig}
jsonBytes, err := json.Marshal(response)
if err != nil {
return nil, err
}
return &tunnel.Message{Message: string(jsonBytes)}, nil
},
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := handleGitSSHSignatureRequest(context.Background(), w, r, mock, log.Discard)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}))
defer server.Close()

gitsshsigning.SetSignatureServerURL(server.URL + "/git-ssh-signature")
t.Cleanup(func() { gitsshsigning.SetSignatureServerURL("") })

tmpDir := t.TempDir()
bufferFile := filepath.Join(tmpDir, "buffer")
require.NoError(t, os.WriteFile(bufferFile, []byte("commit content"), 0o600))

err := gitsshsigning.HandleGitSSHProgramCall("/tmp/key.pub", "git", bufferFile, log.Discard)

require.NoError(t, err)

sigContent, readErr := os.ReadFile(
bufferFile + ".sig",
) // #nosec G304 -- test file path from t.TempDir
require.NoError(t, readErr)
assert.Equal(t, expectedSig, sigContent)
}
Loading
Loading