From 89f743f4e891b8b2d6eaa1ad3d4190a193fe3f9e Mon Sep 17 00:00:00 2001 From: Marvin Strangfeld Date: Fri, 12 Jan 2024 18:28:21 +0100 Subject: [PATCH 1/6] feat: add ssh support for age Signed-off-by: Marvin Strangfeld --- age/keysource.go | 142 ++++++++++++++++++++++++++++++++++++++---- age/keysource_test.go | 114 ++++++++++++++++++++++++++++++--- age/tui.go | 53 ++++++++++++++++ go.mod | 7 ++- 4 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 age/tui.go diff --git a/age/keysource.go b/age/keysource.go index 83bdbe0a6..d2f6e2ec8 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -11,10 +11,12 @@ import ( "strings" "filippo.io/age" + "filippo.io/age/agessh" "filippo.io/age/armor" "github.com/sirupsen/logrus" "github.com/getsops/sops/v3/logging" + "golang.org/x/crypto/ssh" ) const ( @@ -24,6 +26,9 @@ const ( // SopsAgeKeyFileEnv can be set as an environment variable pointing to an // age keys file. SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE" + // SopsAgeSshPrivateKeyEnv can be set as an environment variable pointing to + // a private SSH key file. + SopsAgeSshPrivateKeyEnv = "SOPS_AGE_SSH_PRIVATE_KEY" // SopsAgeKeyUserConfigPath is the default age keys file path in // getUserConfigDir(). SopsAgeKeyUserConfigPath = "sops/age/keys.txt" @@ -60,7 +65,7 @@ type MasterKey struct { parsedIdentities []age.Identity // parsedRecipient contains a parsed age public key. // It is used to lazy-load the Recipient at-most once. - parsedRecipient *age.X25519Recipient + parsedRecipient age.Recipient } // MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded @@ -233,6 +238,98 @@ func (key *MasterKey) TypeToIdentifier() string { return KeyTypeIdentifier } +// readPublicKeyFile attempts to read a public key based on the given private +// key path. It assumes the public key is in the same directory, with the same +// name, but with a ".pub" extension. If the public key cannot be read, an +// error is returned. +func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) { + publicKeyPath := privateKeyPath + ".pub" + f, err := os.Open(publicKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to obtain public %q key for %q SSH key: %w", publicKeyPath, privateKeyPath, err) + } + defer f.Close() + contents, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", publicKeyPath, err) + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents) + if err != nil { + return nil, fmt.Errorf("failed to parse %q: %w", publicKeyPath, err) + } + return pubKey, nil +} + +// parseSSHIdentityFromPrivateKeyFile returns an age.Identity from the given +// private key file. If the private key file is encrypted, it will configure +// the identity to prompt for a passphrase. +func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { + keyFile, err := os.Open(keyPath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer keyFile.Close() + contents, err := io.ReadAll(keyFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + id, err := agessh.ParseIdentity(contents) + if sshErr, ok := err.(*ssh.PassphraseMissingError); ok { + pubKey := sshErr.PublicKey + if pubKey == nil { + pubKey, err = readPublicKeyFile(keyPath) + if err != nil { + return nil, err + } + } + passphrasePrompt := func() ([]byte, error) { + pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for %q:", keyPath)) + if err != nil { + return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err) + } + return pass, nil + } + i, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt) + if err != nil { + return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err) + } + return i, nil + } + if err != nil { + return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err) + } + return id, nil +} + +// loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH +// private key from the SopsAgeSshPrivateKeyEnv environment variable. If the +// environment variable is not present, it will fall back to `~/.ssh/id_ed25519` +// or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil. +func loadAgeSSHIdentity() (age.Identity, error) { + sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyEnv) + if ok { + return parseSSHIdentityFromPrivateKeyFile(sshKeyFilePath) + } + + userHomeDir, err := os.UserHomeDir() + if err != nil || userHomeDir == "" { + log.Warnf("could not determine the user home directory: %v", err) + return nil, nil + } + + sshEd25519PrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_ed25519") + if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil { + return parseSSHIdentityFromPrivateKeyFile(sshEd25519PrivateKeyPath) + } + + sshRsaPrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_rsa") + if _, err := os.Stat(sshRsaPrivateKeyPath); err == nil { + return parseSSHIdentityFromPrivateKeyFile(sshRsaPrivateKeyPath) + } + + return nil, nil +} + func getUserConfigDir() (string, error) { if runtime.GOOS == "darwin" { if userConfigDir, ok := os.LookupEnv(xdgConfigHome); ok && userConfigDir != "" { @@ -244,9 +341,19 @@ func getUserConfigDir() (string, error) { // loadIdentities attempts to load the age identities based on runtime // environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv, -// SopsAgeKeyUserConfigPath). It will load all found references, and expects -// at least one configuration to be present. +// SopsAgeSshPrivateKeyEnv, SopsAgeKeyUserConfigPath). It will load all +// found references, and expects at least one configuration to be present. func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { + var identities ParsedIdentities + + sshIdentity, err := loadAgeSSHIdentity() + if err != nil { + return nil, fmt.Errorf("failed to get SSH identity: %w", err) + } + if sshIdentity != nil { + identities = append(identities, sshIdentity) + } + var readers = make(map[string]io.Reader, 0) if ageKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok { @@ -263,7 +370,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { } userConfigDir, err := getUserConfigDir() - if err != nil && len(readers) == 0 { + if err != nil && len(readers) == 0 && len(identities) == 0 { return nil, fmt.Errorf("user config directory could not be determined: %w", err) } if userConfigDir != "" { @@ -272,7 +379,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("failed to open file: %w", err) } - if errors.Is(err, os.ErrNotExist) && len(readers) == 0 { + if errors.Is(err, os.ErrNotExist) && len(readers) == 0 && len(identities) == 0 { // If we have no other readers, presence of the file is required. return nil, fmt.Errorf("failed to open file: %w", err) } @@ -282,7 +389,6 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { } } - var identities ParsedIdentities for n, r := range readers { ids, err := age.ParseIdentities(r) if err != nil { @@ -294,13 +400,25 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { } // parseRecipient attempts to parse a string containing an encoded age public -// key. -func parseRecipient(recipient string) (*age.X25519Recipient, error) { - parsedRecipient, err := age.ParseX25519Recipient(recipient) - if err != nil { - return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err) +// key or a public ssh key. +func parseRecipient(recipient string) (age.Recipient, error) { + switch { + case strings.HasPrefix(recipient, "age1"): + parsedRecipient, err := age.ParseX25519Recipient(recipient) + if err != nil { + return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err) + } + + return parsedRecipient, nil + case strings.HasPrefix(recipient, "ssh-"): + parsedRecipient, err := agessh.ParseRecipient(recipient) + if err != nil { + return nil, fmt.Errorf("failed to parse input as age-ssh public key: %w", err) + } + return parsedRecipient, nil } - return parsedRecipient, nil + + return nil, fmt.Errorf("failed to parse input, unknown recipient type: %q", recipient) } // parseIdentities attempts to parse the string set of encoded age identities. diff --git a/age/keysource_test.go b/age/keysource_test.go index 1a07058a6..bf6e3ffe1 100644 --- a/age/keysource_test.go +++ b/age/keysource_test.go @@ -28,6 +28,23 @@ EylloI7MNGbadPGb -----END AGE ENCRYPTED FILE-----` // mockEncryptedKeyPlain is the plain value of mockEncryptedKey. mockEncryptedKeyPlain string = "data" + // mockSshRecipient is a mock age ssh recipient, it matches mockSshIdentity + mockSshRecipient string = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID+Wi8WZw2bXfBpcs/WECttCzP39OkenS6pHWHWGFJvN Test" + // mockSshIdentity is a mock age identity based on an OpenSSH private key (ed25519) + mockSshIdentity string = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA/lovFmcNm13waXLP1hArbQsz9/TpHp0uqR1h1hhSbzQAAAIgCXDMIAlwz +CAAAAAtzc2gtZWQyNTUxOQAAACA/lovFmcNm13waXLP1hArbQsz9/TpHp0uqR1h1hhSbzQ +AAAEBJdWTJ8dC0OnMcwy4gQ96sp6KG8GE9EiyhFGhKldKiST+Wi8WZw2bXfBpcs/WECttC +zP39OkenS6pHWHWGFJvNAAAABFRlc3QB +-----END OPENSSH PRIVATE KEY-----` + mockEncryptedSshKey string = `-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IDJjd0R4dyB2R3Ns +VUNHaXBiTEJaNU5BMFFQZUpCYWJqODFyTTZ4WWZoRVpUd2M2aTBFCkduUFJHb1U2 +K3RqWVQrLzE4anZKZ3h2T3c2MFpZTHlGaHprcElXenByWTAKLS0tIG56MHFSZERl +em9PWmRMMTY4aytYTnVZN04yeER5Z2E3TWxWT3JTZWR2ekUKp/HZLy4MzQqoszGk ++P0hSPPNhOhvFwv4AqCw1+A+WyeHGQPq +-----END AGE ENCRYPTED FILE-----` ) func TestMasterKeysFromRecipients(t *testing.T) { @@ -41,22 +58,32 @@ func TestMasterKeysFromRecipients(t *testing.T) { assert.Equal(t, got[0].Recipient, mockRecipient) }) + t.Run("recipient-ssh", func(t *testing.T) { + got, err := MasterKeysFromRecipients(mockSshRecipient) + assert.NoError(t, err) + + assert.Len(t, got, 1) + assert.Equal(t, got[0].Recipient, mockSshRecipient) + }) + t.Run("recipients", func(t *testing.T) { - got, err := MasterKeysFromRecipients(mockRecipient + "," + otherRecipient) + got, err := MasterKeysFromRecipients(mockRecipient + "," + otherRecipient + "," + mockSshRecipient) assert.NoError(t, err) - assert.Len(t, got, 2) + assert.Len(t, got, 3) assert.Equal(t, got[0].Recipient, mockRecipient) assert.Equal(t, got[1].Recipient, otherRecipient) + assert.Equal(t, got[2].Recipient, mockSshRecipient) }) t.Run("leading and trailing spaces", func(t *testing.T) { - got, err := MasterKeysFromRecipients(" " + mockRecipient + " , " + otherRecipient + " ") + got, err := MasterKeysFromRecipients(" " + mockRecipient + " , " + otherRecipient + " , " + mockSshRecipient + " ") assert.NoError(t, err) - assert.Len(t, got, 2) + assert.Len(t, got, 3) assert.Equal(t, got[0].Recipient, mockRecipient) assert.Equal(t, got[1].Recipient, otherRecipient) + assert.Equal(t, got[2].Recipient, mockSshRecipient) }) t.Run("empty", func(t *testing.T) { @@ -75,6 +102,14 @@ func TestMasterKeyFromRecipient(t *testing.T) { assert.Nil(t, got.parsedIdentities) }) + t.Run("recipient-ssh", func(t *testing.T) { + got, err := MasterKeyFromRecipient(mockSshRecipient) + assert.NoError(t, err) + assert.EqualValues(t, mockSshRecipient, got.Recipient) + assert.NotNil(t, got.parsedRecipient) + assert.Nil(t, got.parsedIdentities) + }) + t.Run("leading and trailing spaces", func(t *testing.T) { got, err := MasterKeyFromRecipient(" " + mockRecipient + " ") assert.NoError(t, err) @@ -83,6 +118,14 @@ func TestMasterKeyFromRecipient(t *testing.T) { assert.Nil(t, got.parsedIdentities) }) + t.Run("leading and trailing spaces - ssh", func(t *testing.T) { + got, err := MasterKeyFromRecipient(" " + mockSshRecipient + " ") + assert.NoError(t, err) + assert.EqualValues(t, mockSshRecipient, got.Recipient) + assert.NotNil(t, got.parsedRecipient) + assert.Nil(t, got.parsedIdentities) + }) + t.Run("invalid recipient", func(t *testing.T) { got, err := MasterKeyFromRecipient("invalid") assert.Error(t, err) @@ -111,6 +154,8 @@ func TestParsedIdentities_ApplyToMasterKey(t *testing.T) { func TestMasterKey_Encrypt(t *testing.T) { mockParsedRecipient, err := parseRecipient(mockRecipient) assert.NoError(t, err) + mockSshParsedRecipient, err := parseRecipient(mockSshRecipient) + assert.NoError(t, err) t.Run("recipient", func(t *testing.T) { key := &MasterKey{ @@ -120,6 +165,14 @@ func TestMasterKey_Encrypt(t *testing.T) { assert.NotEmpty(t, key.EncryptedKey) }) + t.Run("recipient ssh", func(t *testing.T) { + key := &MasterKey{ + Recipient: mockSshRecipient, + } + assert.NoError(t, key.Encrypt([]byte(mockEncryptedKeyPlain))) + assert.NotEmpty(t, key.EncryptedKey) + }) + t.Run("parsed recipient", func(t *testing.T) { key := &MasterKey{ parsedRecipient: mockParsedRecipient, @@ -128,13 +181,21 @@ func TestMasterKey_Encrypt(t *testing.T) { assert.NotEmpty(t, key.EncryptedKey) }) + t.Run("parsed recipient ssh", func(t *testing.T) { + key := &MasterKey{ + parsedRecipient: mockSshParsedRecipient, + } + assert.NoError(t, key.Encrypt([]byte(mockEncryptedKeyPlain))) + assert.NotEmpty(t, key.EncryptedKey) + }) + t.Run("invalid recipient", func(t *testing.T) { key := &MasterKey{ Recipient: "invalid", } err := key.Encrypt([]byte(mockEncryptedKeyPlain)) assert.Error(t, err) - assert.ErrorContains(t, err, "failed to parse input as Bech32-encoded age public key") + assert.ErrorContains(t, err, "failed to parse input, unknown recipient type:") assert.Empty(t, key.EncryptedKey) }) @@ -188,6 +249,25 @@ func TestMasterKey_Decrypt(t *testing.T) { assert.EqualValues(t, mockEncryptedKeyPlain, got) }) + t.Run("loaded identities ssh", func(t *testing.T) { + key := &MasterKey{EncryptedKey: mockEncryptedSshKey} + tmp := t.TempDir() + overwriteUserConfigDir(t, tmp) + + homeDir, err := os.UserHomeDir() + assert.NoError(t, err) + keyPath := filepath.Join(homeDir, ".ssh/id_25519") + assert.True(t, strings.HasPrefix(keyPath, homeDir)) + + assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700)) + assert.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644)) + t.Setenv(SopsAgeSshPrivateKeyEnv, keyPath) + + got, err := key.Decrypt() + assert.NoError(t, err) + assert.EqualValues(t, mockEncryptedKeyPlain, got) + }) + t.Run("no identities", func(t *testing.T) { tmpDir := t.TempDir() overwriteUserConfigDir(t, tmpDir) @@ -327,6 +407,25 @@ func TestMasterKey_loadIdentities(t *testing.T) { assert.Len(t, got, 1) }) + t.Run(SopsAgeSshPrivateKeyEnv, func(t *testing.T) { + tmpDir := t.TempDir() + overwriteUserConfigDir(t, tmpDir) + + homeDir, err := os.UserHomeDir() + assert.NoError(t, err) + keyPath := filepath.Join(homeDir, ".ssh/id_25519") + assert.True(t, strings.HasPrefix(keyPath, homeDir)) + + assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700)) + assert.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644)) + t.Setenv(SopsAgeSshPrivateKeyEnv, keyPath) + + key := &MasterKey{} + got, err := key.loadIdentities() + assert.NoError(t, err) + assert.Len(t, got, 1) + }) + t.Run("no identity", func(t *testing.T) { tmpDir := t.TempDir() overwriteUserConfigDir(t, tmpDir) @@ -374,8 +473,8 @@ func TestMasterKey_loadIdentities(t *testing.T) { }) } -// overwriteUserConfigDir sets the user config directory based on the -// os.UserConfigDir logic. +// overwriteUserConfigDir sets the user config directory and the user home directory +// based on the os.UserConfigDir logic. func overwriteUserConfigDir(t *testing.T, path string) { switch runtime.GOOS { case "windows": @@ -384,6 +483,7 @@ func overwriteUserConfigDir(t *testing.T, path string) { t.Setenv("home", path) default: // Unix t.Setenv("XDG_CONFIG_HOME", path) + t.Setenv("HOME", path) } } diff --git a/age/tui.go b/age/tui.go new file mode 100644 index 000000000..e1aef3883 --- /dev/null +++ b/age/tui.go @@ -0,0 +1,53 @@ +// These functions have been copied from the age project +// https://github.com/FiloSottile/age/blob/v1.0.0/cmd/age/encrypted_keys.go +// Copyright 2021 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package age + +import ( + "fmt" + "os" + "runtime" + + "golang.org/x/term" +) + +// readPassphrase reads a passphrase from the terminal. It does not read from a +// non-terminal stdin, so it does not check stdinInUse. +func readPassphrase(prompt string) ([]byte, error) { + var in, out *os.File + if runtime.GOOS == "windows" { + var err error + in, err = os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + return nil, err + } + defer in.Close() + out, err = os.OpenFile("CONOUT$", os.O_WRONLY, 0) + if err != nil { + return nil, err + } + defer out.Close() + } else if _, err := os.Stat("/dev/tty"); err == nil { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return nil, err + } + defer tty.Close() + in, out = tty, tty + } else { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return nil, fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) + } + in, out = os.Stdin, os.Stderr + } + fmt.Fprintf(out, "%s ", prompt) + // Use CRLF to work around an apparent bug in WSL2's handling of CONOUT$. + // Only when running a Windows binary from WSL2, the cursor would not go + // back to the start of the line with a simple LF. Honestly, it's impressive + // CONIN$ and CONOUT$ even work at all inside WSL2. + defer fmt.Fprintf(out, "\r\n") + return term.ReadPassword(int(in.Fd())) +} diff --git a/go.mod b/go.mod index f45dd3817..62bd7c0e6 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/getsops/sops/v3 -go 1.22 +go 1.22.7 -toolchain go1.22.9 +toolchain go1.23.4 require ( cloud.google.com/go/kms v1.20.3 @@ -35,6 +35,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 github.com/urfave/cli v1.22.16 + golang.org/x/crypto v0.31.0 golang.org/x/net v0.33.0 golang.org/x/sys v0.28.0 golang.org/x/term v0.27.0 @@ -56,6 +57,7 @@ require ( cloud.google.com/go/longrunning v0.6.3 // indirect cloud.google.com/go/monitoring v1.22.0 // indirect dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect @@ -139,7 +141,6 @@ require ( go.opentelemetry.io/otel/sdk v1.33.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.33.0 // indirect go.opentelemetry.io/otel/trace v1.33.0 // indirect - golang.org/x/crypto v0.31.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/text v0.21.0 // indirect From a05c837682e7317a4613af6a82f97015e64bc603 Mon Sep 17 00:00:00 2001 From: haoqixu Date: Mon, 2 Dec 2024 05:07:52 +0800 Subject: [PATCH 2/6] Rename SopsAgeSshPrivateKeyEnv to SopsAgeSshPrivateKeyFileEnv Signed-off-by: haoqixu --- age/keysource.go | 6 +++--- age/keysource_test.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index d2f6e2ec8..f52d6f442 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -26,9 +26,9 @@ const ( // SopsAgeKeyFileEnv can be set as an environment variable pointing to an // age keys file. SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE" - // SopsAgeSshPrivateKeyEnv can be set as an environment variable pointing to + // SopsAgeSshPrivateKeyFileEnv can be set as an environment variable pointing to // a private SSH key file. - SopsAgeSshPrivateKeyEnv = "SOPS_AGE_SSH_PRIVATE_KEY" + SopsAgeSshPrivateKeyFileEnv = "SOPS_AGE_SSH_PRIVATE_KEY_FILE" // SopsAgeKeyUserConfigPath is the default age keys file path in // getUserConfigDir(). SopsAgeKeyUserConfigPath = "sops/age/keys.txt" @@ -306,7 +306,7 @@ func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { // environment variable is not present, it will fall back to `~/.ssh/id_ed25519` // or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil. func loadAgeSSHIdentity() (age.Identity, error) { - sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyEnv) + sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyFileEnv) if ok { return parseSSHIdentityFromPrivateKeyFile(sshKeyFilePath) } diff --git a/age/keysource_test.go b/age/keysource_test.go index bf6e3ffe1..f2b592489 100644 --- a/age/keysource_test.go +++ b/age/keysource_test.go @@ -261,7 +261,7 @@ func TestMasterKey_Decrypt(t *testing.T) { assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700)) assert.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644)) - t.Setenv(SopsAgeSshPrivateKeyEnv, keyPath) + t.Setenv(SopsAgeSshPrivateKeyFileEnv, keyPath) got, err := key.Decrypt() assert.NoError(t, err) @@ -407,7 +407,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { assert.Len(t, got, 1) }) - t.Run(SopsAgeSshPrivateKeyEnv, func(t *testing.T) { + t.Run(SopsAgeSshPrivateKeyFileEnv, func(t *testing.T) { tmpDir := t.TempDir() overwriteUserConfigDir(t, tmpDir) @@ -418,7 +418,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700)) assert.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644)) - t.Setenv(SopsAgeSshPrivateKeyEnv, keyPath) + t.Setenv(SopsAgeSshPrivateKeyFileEnv, keyPath) key := &MasterKey{} got, err := key.loadIdentities() From f97b7bfbe61dc656338d354e0bde09c5d81511d8 Mon Sep 17 00:00:00 2001 From: haoqixu Date: Thu, 2 Jan 2025 14:56:44 +0800 Subject: [PATCH 3/6] update license header as suggested Signed-off-by: haoqixu --- age/tui.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/age/tui.go b/age/tui.go index e1aef3883..6aa1dc5c0 100644 --- a/age/tui.go +++ b/age/tui.go @@ -2,7 +2,10 @@ // https://github.com/FiloSottile/age/blob/v1.0.0/cmd/age/encrypted_keys.go // Copyright 2021 The age Authors. All rights reserved. // Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. +// license that can be found in age's LICENSE file at +// https://github.com/FiloSottile/age/blob/v1.0.0/LICENSE +// +// SPDX-License-Identifier: BSD-3-Clause package age From e27ce34808375183eb79434f4be99d2424b2f887 Mon Sep 17 00:00:00 2001 From: haoqixu Date: Thu, 2 Jan 2025 15:57:03 +0800 Subject: [PATCH 4/6] replace SopsAgeSshPrivateKeyEnv in comments Signed-off-by: haoqixu --- age/keysource.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index f52d6f442..4b6915fda 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -302,7 +302,7 @@ func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { } // loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH -// private key from the SopsAgeSshPrivateKeyEnv environment variable. If the +// private key from the SopsAgeSshPrivateKeyFileEnv environment variable. If the // environment variable is not present, it will fall back to `~/.ssh/id_ed25519` // or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil. func loadAgeSSHIdentity() (age.Identity, error) { @@ -341,7 +341,7 @@ func getUserConfigDir() (string, error) { // loadIdentities attempts to load the age identities based on runtime // environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv, -// SopsAgeSshPrivateKeyEnv, SopsAgeKeyUserConfigPath). It will load all +// SopsAgeSshPrivateKeyFileEnv, SopsAgeKeyUserConfigPath). It will load all // found references, and expects at least one configuration to be present. func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { var identities ParsedIdentities From 714f23df3994a59481f7000a98c53d10996c56b8 Mon Sep 17 00:00:00 2001 From: haoqixu Date: Thu, 2 Jan 2025 16:09:09 +0800 Subject: [PATCH 5/6] move functions into ssh_parse.go Signed-off-by: haoqixu --- age/keysource.go | 64 ------------------------------------ age/ssh_parse.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++ age/tui.go | 1 + 3 files changed, 85 insertions(+), 64 deletions(-) create mode 100644 age/ssh_parse.go diff --git a/age/keysource.go b/age/keysource.go index 4b6915fda..1d9d227f4 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -16,7 +16,6 @@ import ( "github.com/sirupsen/logrus" "github.com/getsops/sops/v3/logging" - "golang.org/x/crypto/ssh" ) const ( @@ -238,69 +237,6 @@ func (key *MasterKey) TypeToIdentifier() string { return KeyTypeIdentifier } -// readPublicKeyFile attempts to read a public key based on the given private -// key path. It assumes the public key is in the same directory, with the same -// name, but with a ".pub" extension. If the public key cannot be read, an -// error is returned. -func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) { - publicKeyPath := privateKeyPath + ".pub" - f, err := os.Open(publicKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to obtain public %q key for %q SSH key: %w", publicKeyPath, privateKeyPath, err) - } - defer f.Close() - contents, err := io.ReadAll(f) - if err != nil { - return nil, fmt.Errorf("failed to read %q: %w", publicKeyPath, err) - } - pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents) - if err != nil { - return nil, fmt.Errorf("failed to parse %q: %w", publicKeyPath, err) - } - return pubKey, nil -} - -// parseSSHIdentityFromPrivateKeyFile returns an age.Identity from the given -// private key file. If the private key file is encrypted, it will configure -// the identity to prompt for a passphrase. -func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { - keyFile, err := os.Open(keyPath) - if err != nil { - return nil, fmt.Errorf("failed to open file: %w", err) - } - defer keyFile.Close() - contents, err := io.ReadAll(keyFile) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - id, err := agessh.ParseIdentity(contents) - if sshErr, ok := err.(*ssh.PassphraseMissingError); ok { - pubKey := sshErr.PublicKey - if pubKey == nil { - pubKey, err = readPublicKeyFile(keyPath) - if err != nil { - return nil, err - } - } - passphrasePrompt := func() ([]byte, error) { - pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for %q:", keyPath)) - if err != nil { - return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err) - } - return pass, nil - } - i, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt) - if err != nil { - return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err) - } - return i, nil - } - if err != nil { - return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err) - } - return id, nil -} - // loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH // private key from the SopsAgeSshPrivateKeyFileEnv environment variable. If the // environment variable is not present, it will fall back to `~/.ssh/id_ed25519` diff --git a/age/ssh_parse.go b/age/ssh_parse.go new file mode 100644 index 000000000..abc8e260f --- /dev/null +++ b/age/ssh_parse.go @@ -0,0 +1,84 @@ +// These functions are similar to those in the age project +// https://github.com/FiloSottile/age/blob/v1.0.0/cmd/age/parse.go +// +// Copyright 2021 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in age's LICENSE file at +// https://github.com/FiloSottile/age/blob/v1.0.0/LICENSE +// +// SPDX-License-Identifier: BSD-3-Clause + +package age + +import ( + "fmt" + "io" + "os" + + "filippo.io/age" + "filippo.io/age/agessh" + "golang.org/x/crypto/ssh" +) + +// readPublicKeyFile attempts to read a public key based on the given private +// key path. It assumes the public key is in the same directory, with the same +// name, but with a ".pub" extension. If the public key cannot be read, an +// error is returned. +func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) { + publicKeyPath := privateKeyPath + ".pub" + f, err := os.Open(publicKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to obtain public %q key for %q SSH key: %w", publicKeyPath, privateKeyPath, err) + } + defer f.Close() + contents, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", publicKeyPath, err) + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents) + if err != nil { + return nil, fmt.Errorf("failed to parse %q: %w", publicKeyPath, err) + } + return pubKey, nil +} + +// parseSSHIdentityFromPrivateKeyFile returns an age.Identity from the given +// private key file. If the private key file is encrypted, it will configure +// the identity to prompt for a passphrase. +func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { + keyFile, err := os.Open(keyPath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer keyFile.Close() + contents, err := io.ReadAll(keyFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + id, err := agessh.ParseIdentity(contents) + if sshErr, ok := err.(*ssh.PassphraseMissingError); ok { + pubKey := sshErr.PublicKey + if pubKey == nil { + pubKey, err = readPublicKeyFile(keyPath) + if err != nil { + return nil, err + } + } + passphrasePrompt := func() ([]byte, error) { + pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for %q:", keyPath)) + if err != nil { + return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err) + } + return pass, nil + } + i, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt) + if err != nil { + return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err) + } + return i, nil + } + if err != nil { + return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err) + } + return id, nil +} diff --git a/age/tui.go b/age/tui.go index 6aa1dc5c0..e0c82831c 100644 --- a/age/tui.go +++ b/age/tui.go @@ -1,5 +1,6 @@ // These functions have been copied from the age project // https://github.com/FiloSottile/age/blob/v1.0.0/cmd/age/encrypted_keys.go +// // Copyright 2021 The age Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in age's LICENSE file at From 1886116e2a3edcfa2b0903ebd9a8e2c2cc7cdff4 Mon Sep 17 00:00:00 2001 From: haoqixu Date: Thu, 2 Jan 2025 18:47:56 +0800 Subject: [PATCH 6/6] unchange values of go and toolchain Signed-off-by: haoqixu --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 62bd7c0e6..929ce8217 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/getsops/sops/v3 -go 1.22.7 +go 1.22 -toolchain go1.23.4 +toolchain go1.22.9 require ( cloud.google.com/go/kms v1.20.3