Skip to content

Commit c52702b

Browse files
authored
fix(agent/git_ssh_signature): ssh signature forwarding fails when signing (#648)
1 parent 5e92a90 commit c52702b

File tree

7 files changed

+341
-36
lines changed

7 files changed

+341
-36
lines changed

cmd/agent/git_ssh_signature.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,13 @@ func NewGitSSHSignatureCmd(flags *flags.GlobalFlags) *cobra.Command {
3232
GlobalFlags: flags,
3333
}
3434

35-
gitSshSignatureCmd := &cobra.Command{
35+
gitSSHSignatureCmd := &cobra.Command{
3636
Use: "git-ssh-signature",
37+
// Allow unknown flags so that git can pass any ssh-keygen flags
38+
// (e.g. -U for stdin input) without cobra rejecting them.
39+
FParseErrWhitelist: cobra.FParseErrWhitelist{
40+
UnknownFlags: true,
41+
},
3742
RunE: func(_ *cobra.Command, args []string) error {
3843
logger := log.GetInstance()
3944

@@ -54,10 +59,10 @@ func NewGitSSHSignatureCmd(flags *flags.GlobalFlags) *cobra.Command {
5459
},
5560
}
5661

57-
gitSshSignatureCmd.Flags().StringVarP(&cmd.CertPath, "file", "f", "", "Path to the private key")
58-
gitSshSignatureCmd.Flags().StringVarP(&cmd.Namespace, "namespace", "n", "", "Namespace")
59-
gitSshSignatureCmd.Flags().
62+
gitSSHSignatureCmd.Flags().StringVarP(&cmd.CertPath, "file", "f", "", "Path to the private key")
63+
gitSSHSignatureCmd.Flags().StringVarP(&cmd.Namespace, "namespace", "n", "", "Namespace")
64+
gitSSHSignatureCmd.Flags().
6065
StringVarP(&cmd.Command, "command", "Y", "sign", "Command - should be 'sign'")
6166

62-
return gitSshSignatureCmd
67+
return gitSSHSignatureCmd
6368
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package agent
2+
3+
import (
4+
"testing"
5+
6+
"github.com/skevetter/devpod/cmd/flags"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/suite"
9+
)
10+
11+
type GitSSHSignatureTestSuite struct {
12+
suite.Suite
13+
}
14+
15+
func TestGitSSHSignatureSuite(t *testing.T) {
16+
suite.Run(t, new(GitSSHSignatureTestSuite))
17+
}
18+
19+
func (s *GitSSHSignatureTestSuite) TestAcceptsUnknownFlags() {
20+
cmd := NewGitSSHSignatureCmd(&flags.GlobalFlags{})
21+
22+
// Git passes: -Y sign -n git -f /path/to/key -U /dev/stdin /tmp/buffer
23+
// -U is an unknown flag that consumes /dev/stdin as its value.
24+
// /tmp/buffer remains as a positional argument.
25+
err := cmd.ParseFlags(
26+
[]string{
27+
"-Y",
28+
"sign",
29+
"-n",
30+
"git",
31+
"-f",
32+
"/path/to/key",
33+
"-U",
34+
"/dev/stdin",
35+
"/tmp/buffer",
36+
},
37+
)
38+
assert.NoError(s.T(), err, "flag parsing should succeed with unknown flag -U")
39+
40+
args := cmd.Flags().Args()
41+
s.Require().NotEmpty(args, "should have positional args")
42+
assert.Equal(s.T(), "/tmp/buffer", args[len(args)-1],
43+
"buffer file should be preserved as last positional arg")
44+
}
45+
46+
func (s *GitSSHSignatureTestSuite) TestBufferFileAsPositionalArg() {
47+
cmd := NewGitSSHSignatureCmd(&flags.GlobalFlags{})
48+
49+
err := cmd.ParseFlags(
50+
[]string{"-Y", "sign", "-n", "git", "-f", "/path/to/key", "/tmp/buffer"},
51+
)
52+
assert.NoError(s.T(), err)
53+
54+
args := cmd.Flags().Args()
55+
s.Require().NotEmpty(args, "should have positional args")
56+
assert.Equal(s.T(), "/tmp/buffer", args[len(args)-1],
57+
"last positional arg should be the buffer file")
58+
}
59+
60+
func (s *GitSSHSignatureTestSuite) TestKnownFlagsParsed() {
61+
cmd := NewGitSSHSignatureCmd(&flags.GlobalFlags{})
62+
63+
err := cmd.ParseFlags(
64+
[]string{"-Y", "sign", "-n", "git", "-f", "/path/to/key", "/tmp/buffer"},
65+
)
66+
assert.NoError(s.T(), err)
67+
68+
val, err := cmd.Flags().GetString("command")
69+
assert.NoError(s.T(), err)
70+
assert.Equal(s.T(), "sign", val, "command flag should be 'sign'")
71+
72+
args := cmd.Flags().Args()
73+
s.Require().NotEmpty(args, "should have positional args")
74+
assert.Equal(s.T(), "/tmp/buffer", args[len(args)-1],
75+
"last positional arg should be the buffer file")
76+
}

cmd/up.go

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"strings"
1616
"syscall"
1717

18+
"al.essio.dev/pkg/shellescape"
1819
"github.com/blang/semver/v4"
1920
"github.com/sirupsen/logrus"
2021
"github.com/skevetter/devpod/cmd/flags"
@@ -382,21 +383,27 @@ func (cmd *UpCmd) configureWorkspace(
382383
log.Info("SSH configuration completed in workspace")
383384
}
384385

385-
if cmd.GitSSHSigningKey != "" {
386-
if err := setupGitSSHSignature(cmd.GitSSHSigningKey, client, log); err != nil {
387-
return err
388-
}
389-
}
390-
391-
return setupDotfiles(
386+
if err := setupDotfiles(
392387
cmd.DotfilesSource,
393388
cmd.DotfilesScript,
394389
cmd.DotfilesScriptEnvFile,
395390
cmd.DotfilesScriptEnv,
396391
client,
397392
devPodConfig,
398393
log,
399-
)
394+
); err != nil {
395+
return err
396+
}
397+
398+
// Run after dotfiles so the signing config isn't overwritten by a
399+
// dotfiles installer that replaces .gitconfig.
400+
if cmd.GitSSHSigningKey != "" {
401+
if err := setupGitSSHSignature(cmd.GitSSHSigningKey, client); err != nil {
402+
return err
403+
}
404+
}
405+
406+
return nil
400407
}
401408

402409
// openIDE opens the configured IDE.
@@ -1539,7 +1546,6 @@ func collectDotfilesScriptEnvKeyvaluePairs(envFiles []string) ([]string, error)
15391546
func setupGitSSHSignature(
15401547
signingKey string,
15411548
client client2.BaseWorkspaceClient,
1542-
log log.Logger,
15431549
) error {
15441550
execPath, err := os.Executable()
15451551
if err != nil {
@@ -1555,7 +1561,8 @@ func setupGitSSHSignature(
15551561
remoteUser = "root"
15561562
}
15571563

1558-
err = exec.Command(
1564+
// #nosec G204 -- execPath is from os.Executable(), not user input
1565+
out, err := exec.Command(
15591566
execPath,
15601567
"ssh",
15611568
"--agent-forwarding=true",
@@ -1565,10 +1572,13 @@ func setupGitSSHSignature(
15651572
"--context",
15661573
client.Context(),
15671574
client.Workspace(),
1568-
"--command", fmt.Sprintf("devpod agent git-ssh-signature-helper %s", signingKey),
1569-
).Run()
1575+
"--command",
1576+
shellescape.QuoteCommand(
1577+
[]string{"devpod", "agent", "git-ssh-signature-helper", signingKey},
1578+
),
1579+
).CombinedOutput()
15701580
if err != nil {
1571-
log.Error("failure in setting up git ssh signature helper")
1581+
return fmt.Errorf("setup git ssh signature helper: %w, output: %s", err, string(out))
15721582
}
15731583
return nil
15741584
}

e2e/tests/ssh/ssh.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"path/filepath"
1010
"runtime"
1111
"strconv"
12+
"strings"
1213
"time"
1314

1415
"github.com/onsi/ginkgo/v2"
@@ -104,6 +105,81 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord
104105
// framework.ExpectNoError(err)
105106
// })
106107

108+
ginkgo.It(
109+
"should set up git SSH signature helper in workspace",
110+
func(ctx context.Context) {
111+
if runtime.GOOS == "windows" {
112+
ginkgo.Skip("skipping on windows")
113+
}
114+
115+
tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/ssh-signing")
116+
framework.ExpectNoError(err)
117+
118+
f := framework.NewDefaultFramework(initialDir + "/bin")
119+
_ = f.DevPodProviderAdd(ctx, "docker")
120+
err = f.DevPodProviderUse(ctx, "docker")
121+
framework.ExpectNoError(err)
122+
123+
ginkgo.DeferCleanup(func(cleanupCtx context.Context) {
124+
_ = f.DevPodWorkspaceDelete(cleanupCtx, tempDir)
125+
framework.CleanupTempDir(initialDir, tempDir)
126+
})
127+
128+
// Generate a temporary SSH key for signing
129+
sshKeyDir, err := os.MkdirTemp("", "devpod-ssh-signing-test")
130+
framework.ExpectNoError(err)
131+
defer func() { _ = os.RemoveAll(sshKeyDir) }()
132+
133+
keyPath := filepath.Join(sshKeyDir, "id_ed25519")
134+
// #nosec G204 -- test command with controlled arguments
135+
err = exec.Command(
136+
"ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q",
137+
).Run()
138+
framework.ExpectNoError(err)
139+
140+
// Start workspace with git-ssh-signing-key flag
141+
devpodUpCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
142+
defer cancel()
143+
err = f.DevPodUp(devpodUpCtx, tempDir,
144+
"--git-ssh-signing-key", keyPath+".pub",
145+
)
146+
framework.ExpectNoError(err)
147+
148+
sshCtx, cancelSSH := context.WithTimeout(ctx, 20*time.Second)
149+
defer cancelSSH()
150+
151+
// Verify the helper script was installed
152+
out, err := f.DevPodSSH(sshCtx, tempDir,
153+
"test -x /usr/local/bin/devpod-ssh-signature && echo EXISTS",
154+
)
155+
framework.ExpectNoError(err)
156+
gomega.Expect(strings.TrimSpace(out)).To(
157+
gomega.Equal("EXISTS"),
158+
"devpod-ssh-signature helper script should be installed and executable",
159+
)
160+
161+
// Verify git config has the SSH signing program set
162+
out, err = f.DevPodSSH(sshCtx, tempDir,
163+
"git config --global gpg.ssh.program",
164+
)
165+
framework.ExpectNoError(err)
166+
gomega.Expect(strings.TrimSpace(out)).To(
167+
gomega.Equal("devpod-ssh-signature"),
168+
"git gpg.ssh.program should be set to devpod-ssh-signature",
169+
)
170+
171+
// Verify git config has gpg format set to ssh
172+
out, err = f.DevPodSSH(sshCtx, tempDir,
173+
"git config --global gpg.format",
174+
)
175+
framework.ExpectNoError(err)
176+
gomega.Expect(strings.TrimSpace(out)).To(
177+
gomega.Equal("ssh"),
178+
"git gpg.format should be set to ssh",
179+
)
180+
},
181+
)
182+
107183
ginkgo.It(
108184
"should start a new workspace with a docker provider (default) and forward a port into it",
109185
func(ctx context.Context) {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "SSH Signing Test",
3+
"image": "mcr.microsoft.com/devcontainers/go:1"
4+
}

pkg/gitsshsigning/helper.go

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"github.com/skevetter/devpod/pkg/command"
1111
"github.com/skevetter/devpod/pkg/file"
1212
"github.com/skevetter/log"
13-
"github.com/skevetter/log/scanner"
1413
)
1514

1615
const (
@@ -150,27 +149,71 @@ func removeGitConfigHelper(gitConfigPath, userName string) error {
150149
}
151150

152151
func removeSignatureHelper(content string) string {
153-
scan := scanner.NewScanner(strings.NewReader(content))
154-
isGpgSetup := false
155-
out := []string{}
156-
157-
for scan.Scan() {
158-
line := scan.Text()
159-
if strings.TrimSpace(line) == "[gpg \"ssh\"]" {
160-
isGpgSetup = true
161-
continue
162-
} else if strings.TrimSpace(line) == "[gpg]" {
163-
isGpgSetup = true
164-
} else if isGpgSetup {
165-
trimmed := strings.TrimSpace(line)
166-
if len(trimmed) > 0 && trimmed[0] == '[' {
167-
isGpgSetup = false
168-
} else {
152+
inGpgSSHSection := false
153+
inGpgSection := false
154+
var gpgSSHBuffer []string
155+
var out []string
156+
157+
for line := range strings.Lines(content) {
158+
line = strings.TrimRight(line, "\n")
159+
trimmed := strings.TrimSpace(line)
160+
161+
if isSectionHeader(trimmed) {
162+
if inGpgSSHSection {
163+
out = append(out, filterGpgSSHSection(gpgSSHBuffer)...)
164+
gpgSSHBuffer = nil
165+
}
166+
inGpgSSHSection = trimmed == `[gpg "ssh"]`
167+
inGpgSection = trimmed == "[gpg]"
168+
if inGpgSSHSection {
169+
gpgSSHBuffer = append(gpgSSHBuffer, line)
169170
continue
170171
}
171172
}
172-
out = append(out, line)
173+
174+
if inGpgSSHSection {
175+
gpgSSHBuffer = append(gpgSSHBuffer, line)
176+
continue
177+
}
178+
179+
if !isDevpodManagedGpgKey(inGpgSection, trimmed) {
180+
out = append(out, line)
181+
}
182+
}
183+
184+
if inGpgSSHSection {
185+
out = append(out, filterGpgSSHSection(gpgSSHBuffer)...)
173186
}
174187

175188
return strings.Join(out, "\n")
176189
}
190+
191+
func isSectionHeader(trimmed string) bool {
192+
return len(trimmed) > 0 && trimmed[0] == '['
193+
}
194+
195+
func isDevpodManagedGpgKey(inGpgSection bool, trimmed string) bool {
196+
if !inGpgSection || len(trimmed) == 0 || trimmed[0] == '[' {
197+
return false
198+
}
199+
return strings.HasPrefix(trimmed, "format = ssh")
200+
}
201+
202+
// filterGpgSSHSection removes devpod-managed keys from a buffered [gpg "ssh"]
203+
// section. Returns the header + remaining user keys, or nil if no user keys remain.
204+
func filterGpgSSHSection(lines []string) []string {
205+
if len(lines) == 0 {
206+
return nil
207+
}
208+
var kept []string
209+
for _, line := range lines[1:] {
210+
trimmed := strings.TrimSpace(line)
211+
if !strings.HasPrefix(trimmed, "program = devpod-ssh-signature") {
212+
kept = append(kept, line)
213+
}
214+
}
215+
if len(kept) == 0 {
216+
return nil
217+
}
218+
return append([]string{lines[0]}, kept...)
219+
}

0 commit comments

Comments
 (0)