Skip to content

Commit b2400e8

Browse files
DanielleMaywoodmafredri
authored andcommitted
feat: add env var for ssh private key (#396)
(cherry picked from commit 08bdb8d)
1 parent 179f3c8 commit b2400e8

File tree

7 files changed

+137
-6
lines changed

7 files changed

+137
-6
lines changed

cmd/envbuilder/main.go

+4
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ func envbuilderCmd() serpent.Command {
7575
}
7676
}
7777

78+
if o.GitSSHPrivateKeyPath != "" && o.GitSSHPrivateKeyBase64 != "" {
79+
return errors.New("cannot have both GIT_SSH_PRIVATE_KEY_PATH and GIT_SSH_PRIVATE_KEY_BASE64 set")
80+
}
81+
7882
if o.GetCachedImage {
7983
img, err := envbuilder.RunCacheProbe(inv.Context(), o)
8084
if err != nil {

docs/env-variables.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
| `--git-clone-single-branch` | `ENVBUILDER_GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. |
2828
| `--git-username` | `ENVBUILDER_GIT_USERNAME` | | The username to use for Git authentication. This is optional. |
2929
| `--git-password` | `ENVBUILDER_GIT_PASSWORD` | | The password to use for Git authentication. This is optional. |
30-
| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. |
30+
| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set. |
31+
| `--git-ssh-private-key-base64` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_BASE64` | | Base64 encoded SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set. |
3132
| `--git-http-proxy-url` | `ENVBUILDER_GIT_HTTP_PROXY_URL` | | The URL for the HTTP proxy. This is optional. |
3233
| `--workspace-folder` | `ENVBUILDER_WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. |
3334
| `--ssl-cert-base64` | `ENVBUILDER_SSL_CERT_BASE64` | | The content of an SSL cert file. This is useful for self-signed certificates. |

git/git.go

+28
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package git
22

33
import (
44
"context"
5+
"encoding/base64"
56
"errors"
67
"fmt"
78
"io"
@@ -181,6 +182,22 @@ func ReadPrivateKey(path string) (gossh.Signer, error) {
181182
return k, nil
182183
}
183184

185+
// DecodeBase64PrivateKey attempts to decode a base64 encoded private
186+
// key and returns an ssh.Signer
187+
func DecodeBase64PrivateKey(key string) (gossh.Signer, error) {
188+
bs, err := base64.StdEncoding.DecodeString(key)
189+
if err != nil {
190+
return nil, fmt.Errorf("decode base64: %w", err)
191+
}
192+
193+
k, err := gossh.ParsePrivateKey(bs)
194+
if err != nil {
195+
return nil, fmt.Errorf("parse private key: %w", err)
196+
}
197+
198+
return k, nil
199+
}
200+
184201
// LogHostKeyCallback is a HostKeyCallback that just logs host keys
185202
// and does nothing else.
186203
func LogHostKeyCallback(logger func(string, ...any)) gossh.HostKeyCallback {
@@ -273,6 +290,17 @@ func SetupRepoAuth(logf func(string, ...any), options *options.Options) transpor
273290
}
274291
}
275292

293+
// If no path was provided, fall back to the environment variable
294+
if options.GitSSHPrivateKeyBase64 != "" {
295+
s, err := DecodeBase64PrivateKey(options.GitSSHPrivateKeyBase64)
296+
if err != nil {
297+
logf("❌ Failed to decode base 64 private key: %s", err.Error())
298+
} else {
299+
logf("🔑 Using %s key!", s.PublicKey().Type())
300+
signer = s
301+
}
302+
}
303+
276304
// If no SSH key set, fall back to agent auth.
277305
if signer == nil {
278306
logf("🔑 No SSH key found, falling back to agent!")

git/git_test.go

+21
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package git_test
33
import (
44
"context"
55
"crypto/ed25519"
6+
"encoding/base64"
67
"fmt"
78
"io"
89
"net/http/httptest"
@@ -433,6 +434,22 @@ func TestSetupRepoAuth(t *testing.T) {
433434
require.Equal(t, actualSigner, pk.Signer)
434435
})
435436

437+
t.Run("SSH/Base64PrivateKey", func(t *testing.T) {
438+
opts := &options.Options{
439+
GitURL: "ssh://[email protected]:repo/path",
440+
GitSSHPrivateKeyBase64: base64EncodeTestPrivateKey(),
441+
}
442+
auth := git.SetupRepoAuth(t.Logf, opts)
443+
444+
pk, ok := auth.(*gitssh.PublicKeys)
445+
require.True(t, ok)
446+
require.NotNil(t, pk.Signer)
447+
448+
actualSigner, err := gossh.ParsePrivateKey([]byte(testKey))
449+
require.NoError(t, err)
450+
require.Equal(t, actualSigner, pk.Signer)
451+
})
452+
436453
t.Run("SSH/NoAuthMethods", func(t *testing.T) {
437454
opts := &options.Options{
438455
GitURL: "ssh://[email protected]:repo/path",
@@ -502,3 +519,7 @@ func writeTestPrivateKey(t *testing.T) string {
502519
require.NoError(t, os.WriteFile(kPath, []byte(testKey), 0o600))
503520
return kPath
504521
}
522+
523+
func base64EncodeTestPrivateKey() string {
524+
return base64.StdEncoding.EncodeToString([]byte(testKey))
525+
}

integration/integration_test.go

+61
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"context"
7+
"crypto/ed25519"
78
"encoding/base64"
89
"encoding/json"
910
"encoding/pem"
@@ -32,6 +33,8 @@ import (
3233
"github.com/coder/envbuilder/testutil/gittest"
3334
"github.com/coder/envbuilder/testutil/mwtest"
3435
"github.com/coder/envbuilder/testutil/registrytest"
36+
"github.com/go-git/go-billy/v5/osfs"
37+
gossh "golang.org/x/crypto/ssh"
3538

3639
clitypes "github.com/docker/cli/cli/config/types"
3740
"github.com/docker/docker/api/types"
@@ -58,6 +61,16 @@ const (
5861
testContainerLabel = "envbox-integration-test"
5962
testImageAlpine = "localhost:5000/envbuilder-test-alpine:latest"
6063
testImageUbuntu = "localhost:5000/envbuilder-test-ubuntu:latest"
64+
65+
// nolint:gosec // Throw-away key for testing. DO NOT REUSE.
66+
testSSHKey = `-----BEGIN OPENSSH PRIVATE KEY-----
67+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
68+
QyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuwAAAKgM05mUDNOZ
69+
lAAAAAtzc2gtZWQyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuw
70+
AAAEDCawwtjrM4AGYXD1G6uallnbsgMed4cfkFsQ+mLZtOkFc4aACB78Rtx6OoBKplrqzw
71+
8VdmV7noOIYS3RVieWO7AAAAHmNpYW5AY2RyLW1icC1mdmZmdzBuOHEwNXAuaG9tZQECAw
72+
QFBgc=
73+
-----END OPENSSH PRIVATE KEY-----`
6174
)
6275

6376
func TestLogs(t *testing.T) {
@@ -378,6 +391,54 @@ func TestSucceedsGitAuth(t *testing.T) {
378391
require.Contains(t, gitConfig, srv.URL)
379392
}
380393

394+
func TestGitSSHAuth(t *testing.T) {
395+
t.Parallel()
396+
397+
base64Key := base64.StdEncoding.EncodeToString([]byte(testSSHKey))
398+
399+
t.Run("Base64/Success", func(t *testing.T) {
400+
signer, err := gossh.ParsePrivateKey([]byte(testSSHKey))
401+
require.NoError(t, err)
402+
require.NotNil(t, signer)
403+
404+
tmpDir := t.TempDir()
405+
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())
406+
407+
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
408+
tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey())
409+
410+
_, err = runEnvbuilder(t, runOpts{env: []string{
411+
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
412+
envbuilderEnv("GIT_URL", tr.String()+"."),
413+
envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key),
414+
}})
415+
// TODO: Ensure it actually clones but this does mean we have
416+
// successfully authenticated.
417+
require.ErrorContains(t, err, "repository not found")
418+
})
419+
420+
t.Run("Base64/Failure", func(t *testing.T) {
421+
_, randomKey, err := ed25519.GenerateKey(nil)
422+
require.NoError(t, err)
423+
signer, err := gossh.NewSignerFromKey(randomKey)
424+
require.NoError(t, err)
425+
require.NotNil(t, signer)
426+
427+
tmpDir := t.TempDir()
428+
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())
429+
430+
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
431+
tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey())
432+
433+
_, err = runEnvbuilder(t, runOpts{env: []string{
434+
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
435+
envbuilderEnv("GIT_URL", tr.String()+"."),
436+
envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key),
437+
}})
438+
require.ErrorContains(t, err, "handshake failed")
439+
})
440+
}
441+
381442
func TestSucceedsGitAuthInURL(t *testing.T) {
382443
t.Parallel()
383444
srv := gittest.CreateGitServer(t, gittest.Options{

options/options.go

+15-4
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ type Options struct {
108108
// GitSSHPrivateKeyPath is the path to an SSH private key to be used for
109109
// Git authentication.
110110
GitSSHPrivateKeyPath string
111+
// GitSSHPrivateKeyBase64 is the content of an SSH private key to be used
112+
// for Git authentication.
113+
GitSSHPrivateKeyBase64 string
111114
// GitHTTPProxyURL is the URL for the HTTP proxy. This is optional.
112115
GitHTTPProxyURL string
113116
// WorkspaceFolder is the path to the workspace folder that will be built.
@@ -358,10 +361,18 @@ func (o *Options) CLI() serpent.OptionSet {
358361
Description: "The password to use for Git authentication. This is optional.",
359362
},
360363
{
361-
Flag: "git-ssh-private-key-path",
362-
Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"),
363-
Value: serpent.StringOf(&o.GitSSHPrivateKeyPath),
364-
Description: "Path to an SSH private key to be used for Git authentication.",
364+
Flag: "git-ssh-private-key-path",
365+
Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"),
366+
Value: serpent.StringOf(&o.GitSSHPrivateKeyPath),
367+
Description: "Path to an SSH private key to be used for Git authentication." +
368+
" If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set.",
369+
},
370+
{
371+
Flag: "git-ssh-private-key-base64",
372+
Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_BASE64"),
373+
Value: serpent.StringOf(&o.GitSSHPrivateKeyBase64),
374+
Description: "Base64 encoded SSH private key to be used for Git authentication." +
375+
" If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set.",
365376
},
366377
{
367378
Flag: "git-http-proxy-url",

options/testdata/options.golden

+6-1
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,13 @@ OPTIONS:
9494
--git-password string, $ENVBUILDER_GIT_PASSWORD
9595
The password to use for Git authentication. This is optional.
9696

97+
--git-ssh-private-key-base64 string, $ENVBUILDER_GIT_SSH_PRIVATE_KEY_BASE64
98+
Base64 encoded SSH private key to be used for Git authentication. If
99+
this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set.
100+
97101
--git-ssh-private-key-path string, $ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH
98-
Path to an SSH private key to be used for Git authentication.
102+
Path to an SSH private key to be used for Git authentication. If this
103+
is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set.
99104

100105
--git-url string, $ENVBUILDER_GIT_URL
101106
The URL of a Git repository containing a Devcontainer or Docker image

0 commit comments

Comments
 (0)