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 d12a68799..2f2398030 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 github.com/urfave/cli v1.22.15 + golang.org/x/crypto v0.24.0 golang.org/x/net v0.26.0 golang.org/x/sys v0.21.0 golang.org/x/term v0.21.0 @@ -53,6 +54,7 @@ require ( cloud.google.com/go/iam v1.1.8 // indirect cloud.google.com/go/longrunning v0.5.7 // indirect dario.cat/mergo v1.0.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect @@ -125,7 +127,6 @@ require ( go.opentelemetry.io/otel v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.27.0 // indirect go.opentelemetry.io/otel/trace v1.27.0 // indirect - golang.org/x/crypto v0.24.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/text v0.16.0 // indirect diff --git a/go.sum b/go.sum index 772e3af89..1e4bf9674 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/age v1.2.0 h1:vRDp7pUMaAJzXNIWJVAZnEf/Dyi4Vu4wI8S1LBzufhE= filippo.io/age v1.2.0/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 h1:1nGuui+4POelzDwI7RG56yfQJHCnKvwfMoU7VsEp+Zg= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0/go.mod h1:99EvauvlcJ1U06amZiksfYz/3aFGyIhWGHVyiZXtBAI= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=