diff --git a/cmd/envbuilder/main.go b/cmd/envbuilder/main.go index e8dc220..91cde8a 100644 --- a/cmd/envbuilder/main.go +++ b/cmd/envbuilder/main.go @@ -75,6 +75,10 @@ func envbuilderCmd() serpent.Command { } } + if o.GitSSHPrivateKeyPath != "" && o.GitSSHPrivateKeyBase64 != "" { + return errors.New("cannot have both GIT_SSH_PRIVATE_KEY_PATH and GIT_SSH_PRIVATE_KEY_BASE64 set") + } + if o.GetCachedImage { img, err := envbuilder.RunCacheProbe(inv.Context(), o) if err != nil { diff --git a/docs/env-variables.md b/docs/env-variables.md index 1c80f4f..5513fd5 100644 --- a/docs/env-variables.md +++ b/docs/env-variables.md @@ -27,7 +27,8 @@ | `--git-clone-single-branch` | `ENVBUILDER_GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. | | `--git-username` | `ENVBUILDER_GIT_USERNAME` | | The username to use for Git authentication. This is optional. | | `--git-password` | `ENVBUILDER_GIT_PASSWORD` | | The password to use for Git authentication. This is optional. | -| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. | +| `--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. | +| `--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. | | `--git-http-proxy-url` | `ENVBUILDER_GIT_HTTP_PROXY_URL` | | The URL for the HTTP proxy. This is optional. | | `--workspace-folder` | `ENVBUILDER_WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. | | `--ssl-cert-base64` | `ENVBUILDER_SSL_CERT_BASE64` | | The content of an SSL cert file. This is useful for self-signed certificates. | diff --git a/git/git.go b/git/git.go index 7d132c3..f37b968 100644 --- a/git/git.go +++ b/git/git.go @@ -2,6 +2,7 @@ package git import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -181,6 +182,22 @@ func ReadPrivateKey(path string) (gossh.Signer, error) { return k, nil } +// DecodeBase64PrivateKey attempts to decode a base64 encoded private +// key and returns an ssh.Signer +func DecodeBase64PrivateKey(key string) (gossh.Signer, error) { + bs, err := base64.StdEncoding.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("decode base64: %w", err) + } + + k, err := gossh.ParsePrivateKey(bs) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + return k, nil +} + // LogHostKeyCallback is a HostKeyCallback that just logs host keys // and does nothing else. func LogHostKeyCallback(logger func(string, ...any)) gossh.HostKeyCallback { @@ -273,6 +290,17 @@ func SetupRepoAuth(logf func(string, ...any), options *options.Options) transpor } } + // If no path was provided, fall back to the environment variable + if options.GitSSHPrivateKeyBase64 != "" { + s, err := DecodeBase64PrivateKey(options.GitSSHPrivateKeyBase64) + if err != nil { + logf("❌ Failed to decode base 64 private key: %s", err.Error()) + } else { + logf("🔑 Using %s key!", s.PublicKey().Type()) + signer = s + } + } + // If no SSH key set, fall back to agent auth. if signer == nil { logf("🔑 No SSH key found, falling back to agent!") diff --git a/git/git_test.go b/git/git_test.go index e7a58f9..0da5a16 100644 --- a/git/git_test.go +++ b/git/git_test.go @@ -3,6 +3,7 @@ package git_test import ( "context" "crypto/ed25519" + "encoding/base64" "fmt" "io" "net/http/httptest" @@ -433,6 +434,22 @@ func TestSetupRepoAuth(t *testing.T) { require.Equal(t, actualSigner, pk.Signer) }) + t.Run("SSH/Base64PrivateKey", func(t *testing.T) { + opts := &options.Options{ + GitURL: "ssh://git@host.tld:repo/path", + GitSSHPrivateKeyBase64: base64EncodeTestPrivateKey(), + } + auth := git.SetupRepoAuth(t.Logf, opts) + + pk, ok := auth.(*gitssh.PublicKeys) + require.True(t, ok) + require.NotNil(t, pk.Signer) + + actualSigner, err := gossh.ParsePrivateKey([]byte(testKey)) + require.NoError(t, err) + require.Equal(t, actualSigner, pk.Signer) + }) + t.Run("SSH/NoAuthMethods", func(t *testing.T) { opts := &options.Options{ GitURL: "ssh://git@host.tld:repo/path", @@ -502,3 +519,7 @@ func writeTestPrivateKey(t *testing.T) string { require.NoError(t, os.WriteFile(kPath, []byte(testKey), 0o600)) return kPath } + +func base64EncodeTestPrivateKey() string { + return base64.StdEncoding.EncodeToString([]byte(testKey)) +} diff --git a/integration/integration_test.go b/integration/integration_test.go index deb21ef..9413c34 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "crypto/ed25519" "encoding/base64" "encoding/json" "encoding/pem" @@ -32,6 +33,8 @@ import ( "github.com/coder/envbuilder/testutil/gittest" "github.com/coder/envbuilder/testutil/mwtest" "github.com/coder/envbuilder/testutil/registrytest" + "github.com/go-git/go-billy/v5/osfs" + gossh "golang.org/x/crypto/ssh" clitypes "github.com/docker/cli/cli/config/types" "github.com/docker/docker/api/types" @@ -58,6 +61,16 @@ const ( testContainerLabel = "envbox-integration-test" testImageAlpine = "localhost:5000/envbuilder-test-alpine:latest" testImageUbuntu = "localhost:5000/envbuilder-test-ubuntu:latest" + + // nolint:gosec // Throw-away key for testing. DO NOT REUSE. + testSSHKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuwAAAKgM05mUDNOZ +lAAAAAtzc2gtZWQyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuw +AAAEDCawwtjrM4AGYXD1G6uallnbsgMed4cfkFsQ+mLZtOkFc4aACB78Rtx6OoBKplrqzw +8VdmV7noOIYS3RVieWO7AAAAHmNpYW5AY2RyLW1icC1mdmZmdzBuOHEwNXAuaG9tZQECAw +QFBgc= +-----END OPENSSH PRIVATE KEY-----` ) func TestLogs(t *testing.T) { @@ -378,6 +391,54 @@ func TestSucceedsGitAuth(t *testing.T) { require.Contains(t, gitConfig, srv.URL) } +func TestGitSSHAuth(t *testing.T) { + t.Parallel() + + base64Key := base64.StdEncoding.EncodeToString([]byte(testSSHKey)) + + t.Run("Base64/Success", func(t *testing.T) { + signer, err := gossh.ParsePrivateKey([]byte(testSSHKey)) + require.NoError(t, err) + require.NotNil(t, signer) + + tmpDir := t.TempDir() + srvFS := osfs.New(tmpDir, osfs.WithChrootOS()) + + _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit")) + tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey()) + + _, err = runEnvbuilder(t, runOpts{env: []string{ + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + envbuilderEnv("GIT_URL", tr.String()+"."), + envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key), + }}) + // TODO: Ensure it actually clones but this does mean we have + // successfully authenticated. + require.ErrorContains(t, err, "repository not found") + }) + + t.Run("Base64/Failure", func(t *testing.T) { + _, randomKey, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + signer, err := gossh.NewSignerFromKey(randomKey) + require.NoError(t, err) + require.NotNil(t, signer) + + tmpDir := t.TempDir() + srvFS := osfs.New(tmpDir, osfs.WithChrootOS()) + + _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit")) + tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey()) + + _, err = runEnvbuilder(t, runOpts{env: []string{ + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + envbuilderEnv("GIT_URL", tr.String()+"."), + envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key), + }}) + require.ErrorContains(t, err, "handshake failed") + }) +} + func TestSucceedsGitAuthInURL(t *testing.T) { t.Parallel() srv := gittest.CreateGitServer(t, gittest.Options{ diff --git a/options/options.go b/options/options.go index 18bd56d..ed6bfa9 100644 --- a/options/options.go +++ b/options/options.go @@ -108,6 +108,9 @@ type Options struct { // GitSSHPrivateKeyPath is the path to an SSH private key to be used for // Git authentication. GitSSHPrivateKeyPath string + // GitSSHPrivateKeyBase64 is the content of an SSH private key to be used + // for Git authentication. + GitSSHPrivateKeyBase64 string // GitHTTPProxyURL is the URL for the HTTP proxy. This is optional. GitHTTPProxyURL string // WorkspaceFolder is the path to the workspace folder that will be built. @@ -358,10 +361,18 @@ func (o *Options) CLI() serpent.OptionSet { Description: "The password to use for Git authentication. This is optional.", }, { - Flag: "git-ssh-private-key-path", - Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"), - Value: serpent.StringOf(&o.GitSSHPrivateKeyPath), - Description: "Path to an SSH private key to be used for Git authentication.", + Flag: "git-ssh-private-key-path", + Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"), + Value: serpent.StringOf(&o.GitSSHPrivateKeyPath), + Description: "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.", + }, + { + Flag: "git-ssh-private-key-base64", + Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_BASE64"), + Value: serpent.StringOf(&o.GitSSHPrivateKeyBase64), + Description: "Base64 encoded SSH private key to be used for Git authentication." + + " If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set.", }, { Flag: "git-http-proxy-url", diff --git a/options/testdata/options.golden b/options/testdata/options.golden index 0bfbd64..518f774 100644 --- a/options/testdata/options.golden +++ b/options/testdata/options.golden @@ -94,8 +94,13 @@ OPTIONS: --git-password string, $ENVBUILDER_GIT_PASSWORD The password to use for Git authentication. This is optional. + --git-ssh-private-key-base64 string, $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. + --git-ssh-private-key-path string, $ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH - Path to an SSH private key to be used for Git authentication. + 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. --git-url string, $ENVBUILDER_GIT_URL The URL of a Git repository containing a Devcontainer or Docker image