Skip to content
Merged
49 changes: 42 additions & 7 deletions cmd/agent/git_ssh_signature.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package agent

import (
"errors"
"fmt"
"os"
"os/exec"
"strings"

"github.com/skevetter/devpod/cmd/flags"
"github.com/skevetter/devpod/pkg/gitsshsigning"
Expand Down Expand Up @@ -39,16 +42,21 @@ func NewGitSSHSignatureCmd(flags *flags.GlobalFlags) *cobra.Command {
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: true,
},
RunE: func(_ *cobra.Command, args []string) error {
RunE: func(cobraCmd *cobra.Command, args []string) error {
logger := log.GetInstance()

if len(args) < 1 {
logger.Fatalf("Buffer file is required")
// For non-sign operations (verify, find-principals, check-novalidate),
// delegate command to system ssh-keygen since op does not require the tunnel.
if cmd.Command != "sign" {
return delegateToSSHKeygen(logger)
}

// Check if the required -Y sign flags are present
if cmd.Command != "sign" {
return errors.New("must include '-Y sign' arguments")
// Sign operation requires a buffer file
if len(args) < 1 {
return fmt.Errorf(
"buffer file is required (received %d positional args: %v, flags: %v)",
len(args), args, os.Args[1:],
)
}

// The last argument is the buffer file
Expand All @@ -66,3 +74,30 @@ func NewGitSSHSignatureCmd(flags *flags.GlobalFlags) *cobra.Command {

return gitSSHSignatureCmd
}

// delegateToSSHKeygen forwards the original arguments to the system ssh-keygen binary.
func delegateToSSHKeygen(logger log.Logger) error {
sshKeygen, err := exec.LookPath("ssh-keygen")
if err != nil {
return fmt.Errorf("find ssh-keygen: %w", err)
}

// Extract the arguments that were originally passed to this command.
// Find "git-ssh-signature" in os.Args and take everything after it.
var sshArgs []string
for i, arg := range os.Args {
if strings.HasSuffix(arg, "git-ssh-signature") {
sshArgs = os.Args[i+1:]
break
}
}

logger.Debugf("delegating to ssh-keygen: %s %v", sshKeygen, sshArgs)

c := exec.Command(sshKeygen, sshArgs...) // #nosec G204,G304,G702
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr

return c.Run()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
2 changes: 0 additions & 2 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -1565,8 +1565,6 @@ func setupGitSSHSignature(
out, err := exec.Command(
execPath,
"ssh",
"--agent-forwarding=true",
"--start-services=true",
"--user",
remoteUser,
"--context",
Expand Down
123 changes: 98 additions & 25 deletions e2e/tests/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
// })

ginkgo.It(
"should set up git SSH signature helper in workspace",
func(ctx context.Context) {
"should set up git SSH signature helper and sign a commit",
ginkgo.SpecTimeout(7*time.Minute),
func(ctx ginkgo.SpecContext) {
if runtime.GOOS == "windows" {
ginkgo.Skip("skipping on windows")
}
Expand Down Expand Up @@ -138,18 +139,11 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
framework.ExpectNoError(err)

// Start workspace with git-ssh-signing-key flag
devpodUpCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
err = f.DevPodUp(devpodUpCtx, tempDir,
"--git-ssh-signing-key", keyPath+".pub",
)
err = f.DevPodUp(ctx, tempDir, "--git-ssh-signing-key", keyPath+".pub")
framework.ExpectNoError(err)

sshCtx, cancelSSH := context.WithTimeout(ctx, 20*time.Second)
defer cancelSSH()

// Verify the helper script was installed
out, err := f.DevPodSSH(sshCtx, tempDir,
// Step 1: Verify the helper script was installed and executable
out, err := f.DevPodSSH(ctx, tempDir,
"test -x /usr/local/bin/devpod-ssh-signature && echo EXISTS",
)
framework.ExpectNoError(err)
Expand All @@ -158,24 +152,103 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
"devpod-ssh-signature helper script should be installed and executable",
)

// Verify git config has the SSH signing program set
out, err = f.DevPodSSH(sshCtx, tempDir,
"git config --global gpg.ssh.program",
)
// Step 2: Verify git config was written correctly
out, err = f.DevPodSSH(ctx, tempDir, "git config --global gpg.ssh.program")
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(
gomega.Equal("devpod-ssh-signature"),
"git gpg.ssh.program should be set to devpod-ssh-signature",
gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("devpod-ssh-signature"))

out, err = f.DevPodSSH(ctx, tempDir, "git config --global gpg.format")
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("ssh"))

// Step 3: Attempt a signed commit with the credentials server
// tunnel active. The signing request is forwarded over the tunnel
// to the host where ssh-keygen performs the actual signing.
commitCmd := strings.Join([]string{
"cd /tmp",
"git init test-sign-repo",
"cd test-sign-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("commit stdout: %s\n", stdout)
ginkgo.GinkgoWriter.Printf("commit stderr: %s\n", stderr)
framework.ExpectNoError(err)

gomega.Expect(stdout).To(
gomega.ContainSubstring("signed test commit"),
"git commit should succeed with the signed test commit message",
)

// Verify git config has gpg format set to ssh
out, err = f.DevPodSSH(sshCtx, tempDir,
"git config --global gpg.format",
// Step 4: Verify the commit is actually signed with a valid SSH signature.
// Read the public key that was used for signing so we can build
// an allowed-signers file inside the workspace for verification.
pubKeyBytes, err := os.ReadFile(
keyPath + ".pub",
) // #nosec G304 -- test file with controlled path
framework.ExpectNoError(err)
pubKey := strings.TrimSpace(string(pubKeyBytes))

verifyCmd := strings.Join([]string{
"cd /tmp/test-sign-repo",
// Create allowed signers file mapping the test email to our public key
"echo 'test@example.com " + pubKey + "' > /tmp/allowed_signers",
"git config gpg.ssh.allowedSignersFile /tmp/allowed_signers",
// Verify the commit signature is valid
"git verify-commit HEAD 2>&1",
}, " && ")

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

// git verify-commit writes signature details to stderr
combined := stdout + stderr
gomega.Expect(combined).To(
gomega.ContainSubstring("Good"),
"git verify-commit should report a good SSH signature",
)

// And confirm the signature log shows the correct principal
logCmd := "cd /tmp/test-sign-repo && git log --show-signature -1 2>&1"
stdout, stderr, err = f.ExecCommandCapture(ctx, []string{
"ssh",
"--agent-forwarding",
"--start-services",
tempDir,
"--command", logCmd,
})
ginkgo.GinkgoWriter.Printf("log stdout: %s\n", stdout)
ginkgo.GinkgoWriter.Printf("log stderr: %s\n", stderr)
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(
gomega.Equal("ssh"),
"git gpg.format should be set to ssh",

combined = stdout + stderr
gomega.Expect(combined).To(
gomega.ContainSubstring("Good"),
"git log --show-signature should report a good signature",
)
gomega.Expect(combined).To(
gomega.ContainSubstring("test@example.com"),
"signature should be associated with the test email principal",
)
},
)
Expand Down
Loading