Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions age/keysource.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ func init() {

// MasterKey is an age key used to Encrypt and Decrypt SOPS' data key.
type MasterKey struct {
// Identity used to contain a Bench32-encoded private key.
// Identity used to contain a Bech32-encoded private key.
// Deprecated: private keys are no longer publicly exposed.
// Instead, they are either injected by a (local) key service server
// using ParsedIdentities.ApplyToMasterKey, or loaded from the runtime
// environment (variables) as defined by the `SopsAgeKey*` constants.
Identity string
// Recipient contains the Bench32-encoded age public key used to Encrypt.
// Recipient contains the Bech32-encoded age public key used to Encrypt.
Recipient string
// EncryptedKey contains the SOPS data key encrypted with age.
EncryptedKey string
Expand Down Expand Up @@ -219,7 +219,7 @@ func formatError(msg string, err error, errs errSet, unusedLocations []string) e
} else if count == 2 {
unusedSuffix = fmt.Sprintf("s '%s' and '%s'", unusedLocations[0], unusedLocations[1])
} else {
unusedSuffix = fmt.Sprintf("s '%s', and '%s'", strings.Join(unusedLocations[:count - 1], "', '"), unusedLocations[count - 1])
unusedSuffix = fmt.Sprintf("s '%s', and '%s'", strings.Join(unusedLocations[:count-1], "', '"), unusedLocations[count-1])
}
unusedSuffix = fmt.Sprintf(". Did not find keys in location%s.", unusedSuffix)
}
Expand All @@ -230,6 +230,12 @@ func formatError(msg string, err error, errs errSet, unusedLocations []string) e
}
}

// recipientMatcher is implemented by identities that can pre-filter by recipient
// to avoid unnecessary passphrase prompts or expensive operations.
type recipientMatcher interface {
matchesRecipient(recipient string) bool
}

// Decrypt decrypts the EncryptedKey with the parsed or loaded identities, and
// returns the result.
func (key *MasterKey) Decrypt() ([]byte, error) {
Expand All @@ -245,9 +251,24 @@ func (key *MasterKey) Decrypt() ([]byte, error) {
ids.ApplyToMasterKey(key)
}

// Filter identities that match this recipient.
matchingIdentities := make([]age.Identity, 0, len(key.parsedIdentities))
for _, id := range key.parsedIdentities {
if matcher, ok := id.(recipientMatcher); ok {
if !matcher.matchesRecipient(key.Recipient) {
continue
}
}
matchingIdentities = append(matchingIdentities, id)
}

if len(matchingIdentities) == 0 {
return nil, formatError(fmt.Sprintf("no identity available that can decrypt for recipient %s", key.Recipient), nil, errs, unusedLocations)
}

src := bytes.NewReader([]byte(key.EncryptedKey))
ar := armor.NewReader(src)
r, err := age.Decrypt(ar, key.parsedIdentities...)
r, err := age.Decrypt(ar, matchingIdentities...)
if err != nil {
log.Info("Decryption failed")
return nil, formatError("failed to create reader for decrypting sops data key with age", err, errs, unusedLocations)
Expand Down Expand Up @@ -297,11 +318,11 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {

sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyFileEnv)
if ok {
identity, err := parseSSHIdentityFromPrivateKeyFile(sshKeyFilePath)
ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshKeyFilePath)
if err != nil {
errs = append(errs, err)
} else {
identities = append(identities, identity)
identities = append(identities, ids...)
}
} else {
unusedLocations = append(unusedLocations, SopsAgeSshPrivateKeyFileEnv)
Expand All @@ -315,23 +336,23 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {
} else {
sshEd25519PrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_ed25519")
if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil {
identity, err := parseSSHIdentityFromPrivateKeyFile(sshEd25519PrivateKeyPath)
ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshEd25519PrivateKeyPath)
if err != nil {
errs = append(errs, err)
} else {
identities = append(identities, identity)
identities = append(identities, ids...)
}
} else {
unusedLocations = append(unusedLocations, sshEd25519PrivateKeyPath)
}

sshRsaPrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_rsa")
if _, err := os.Stat(sshRsaPrivateKeyPath); err == nil {
identity, err := parseSSHIdentityFromPrivateKeyFile(sshRsaPrivateKeyPath)
ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshRsaPrivateKeyPath)
if err != nil {
errs = append(errs, err)
} else {
identities = append(identities, identity)
identities = append(identities, ids...)
}
} else {
unusedLocations = append(unusedLocations, sshRsaPrivateKeyPath)
Expand Down
61 changes: 60 additions & 1 deletion age/keysource_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package age

import (
"bytes"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"filippo.io/age"
"filippo.io/age/armor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
Expand Down Expand Up @@ -281,6 +285,60 @@ func TestMasterKey_Decrypt(t *testing.T) {
assert.EqualValues(t, mockEncryptedKeyPlain, got)
})

// Regression test for https://github.com/getsops/sops/issues/1999
// Verifies that SSH keys can decrypt data encrypted to age recipients
// derived from the same SSH key (via ssh-to-age or similar tools)
t.Run("ssh key decrypts age recipient", func(t *testing.T) {
tmp := t.TempDir()
overwriteUserConfigDir(t, tmp)

homeDir, err := os.UserHomeDir()
require.NoError(t, err)
keyPath := filepath.Join(homeDir, ".ssh/id_ed25519_test")
require.True(t, strings.HasPrefix(keyPath, homeDir))

require.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700))
require.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644))
t.Setenv(SopsAgeSshPrivateKeyFileEnv, keyPath)

// Should return both SSH and age identities for ed25519
identities, _, errs := loadAgeSSHIdentities()
require.Empty(t, errs)
require.GreaterOrEqual(t, len(identities), 2, "ed25519 SSH key should produce both SSH and age identities")

// Find the X25519 identity
var x25519Identity *age.X25519Identity
for _, id := range identities {
if xi, ok := id.(*age.X25519Identity); ok {
x25519Identity = xi
break
}
}
require.NotNil(t, x25519Identity, "should have X25519 identity derived from SSH key")

recipient := x25519Identity.Recipient()

// Encrypt data to the age recipient
plaintext := []byte("test data for issue #1999")
var encryptedBuf bytes.Buffer
armorWriter := armor.NewWriter(&encryptedBuf)
encWriter, err := age.Encrypt(armorWriter, recipient)
require.NoError(t, err)
_, err = encWriter.Write(plaintext)
require.NoError(t, err)
require.NoError(t, encWriter.Close())
require.NoError(t, armorWriter.Close())

// Decrypt using MasterKey
key := &MasterKey{
Recipient: recipient.String(),
EncryptedKey: encryptedBuf.String(),
}
got, err := key.Decrypt()
require.NoError(t, err, "SSH key should decrypt data encrypted to age recipient derived from same key")
assert.EqualValues(t, plaintext, got)
})

t.Run("no identities", func(t *testing.T) {
tmpDir := t.TempDir()
overwriteUserConfigDir(t, tmpDir)
Expand Down Expand Up @@ -441,7 +499,8 @@ func TestMasterKey_loadIdentities(t *testing.T) {
key := &MasterKey{}
got, unusedLocations, errs := key.loadIdentities()
assert.Len(t, errs, 0)
assert.Len(t, got, 1)
// ed25519 SSH keys return 2 identities: SSH identity + age X25519 identity
assert.Len(t, got, 2)
assert.Len(t, unusedLocations, 5)
})

Expand Down
130 changes: 123 additions & 7 deletions age/ssh_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ import (
"fmt"
"io"
"os"
"sync"

"filippo.io/age"
"filippo.io/age/agessh"
agesshconv "github.com/Mic92/ssh-to-age"
"golang.org/x/crypto/ssh"
)

const (
sshEd25519KeyType = "ssh-ed25519"
ageX25519StanzaType = "X25519"
)

// 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
Expand All @@ -42,10 +49,81 @@ func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) {
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) {
// lazyEd25519AgeIdentity wraps an encrypted SSH ed25519 key and lazily converts
// it to an age X25519 identity only when decryption is attempted.
type lazyEd25519AgeIdentity struct {
contents []byte
expectedRecip string // age recipient derived from SSH public key
getPassphrase func() ([]byte, error)

mutex sync.Mutex
wrapped age.Identity // nil until successfully initialized
}

// matchesRecipient checks if this identity matches the recipient.
func (l *lazyEd25519AgeIdentity) matchesRecipient(recipient string) bool {
return l.expectedRecip == recipient
}

func (l *lazyEd25519AgeIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
// X25519 identities only handle X25519 stanzas. Check before prompting for passphrase.
hasX25519 := false
for _, s := range stanzas {
if s.Type == ageX25519StanzaType {
hasX25519 = true
break
}
}
if !hasX25519 {
return nil, age.ErrIncorrectIdentity
}

wrapped, err := l.getOrInitWrapped()
if err != nil {
return nil, err
}
return wrapped.Unwrap(stanzas)
}

// getOrInitWrapped lazily initializes the wrapped age identity, prompting for
// passphrase if needed. Returns the cached identity on subsequent calls.
func (l *lazyEd25519AgeIdentity) getOrInitWrapped() (age.Identity, error) {
l.mutex.Lock()
defer l.mutex.Unlock()

if l.wrapped != nil {
return l.wrapped, nil
}

passphrase, err := l.getPassphrase()
if err != nil {
return nil, fmt.Errorf("could not read passphrase: %w", err)
}

ageIdentityStr, _, err := agesshconv.SSHPrivateKeyToAge(l.contents, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to convert SSH key to age identity: %w", err)
}
if ageIdentityStr == nil {
return nil, fmt.Errorf("failed to convert SSH key to age identity: no identity returned")
}

l.wrapped, err = age.ParseX25519Identity(*ageIdentityStr)
if err != nil {
return nil, fmt.Errorf("failed to parse age identity: %w", err)
}

return l.wrapped, nil
}

// parseSSHIdentitiesFromPrivateKeyFile returns age identities from the given
// private key file. For ed25519 keys (encrypted or unencrypted), it returns:
// - An SSH identity (for decrypting data encrypted to SSH recipients)
// - An age X25519 identity (for decrypting data encrypted to age recipients
// derived from the same SSH key via ssh-to-age)
//
// For non-ed25519 keys, only the SSH identity is returned.
func parseSSHIdentitiesFromPrivateKeyFile(keyPath string) ([]age.Identity, error) {
keyFile, err := os.Open(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
Expand All @@ -55,6 +133,7 @@ func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) {
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
Expand All @@ -64,21 +143,58 @@ func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) {
return nil, err
}
}

passphrasePrompt := func() ([]byte, error) {
pass, err := readSecret(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)

sshIdentity, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt)
if err != nil {
return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err)
}
return i, nil

identities := []age.Identity{sshIdentity}

// For ed25519 keys, also create a lazy age X25519 identity
if pubKey.Type() == sshEd25519KeyType {
pubKeyBytes := ssh.MarshalAuthorizedKey(pubKey)
ageRecip, err := agesshconv.SSHPublicKeyToAge(pubKeyBytes)
if err != nil {
log.WithField("path", keyPath).Debugf("Failed to derive age recipient from SSH public key, skipping age identity: %v", err)
} else if ageRecip != nil {
identities = append(identities, &lazyEd25519AgeIdentity{
contents: contents,
expectedRecip: *ageRecip,
getPassphrase: passphrasePrompt,
})
}
}

return identities, nil
}
if err != nil {
return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err)
}
return id, nil

identities := []age.Identity{id}

// For ed25519 keys, also create an age X25519 identity so we can decrypt
// data encrypted to age recipients derived from this SSH key (via ssh-to-age).
ageIdentityStr, _, err := agesshconv.SSHPrivateKeyToAge(contents, nil)
if err != nil {
log.WithField("path", keyPath).Debugf("Failed to convert SSH key to age identity, skipping: %v", err)
} else if ageIdentityStr != nil {
ageIdentity, err := age.ParseX25519Identity(*ageIdentityStr)
if err != nil {
log.WithField("path", keyPath).Debugf("Failed to parse age identity from converted SSH key: %v", err)
} else {
identities = append(identities, ageIdentity)
}
}

return identities, nil
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0
github.com/Mic92/ssh-to-age v0.0.0-20251215041857-58c160f0b0e5
github.com/ProtonMail/go-crypto v1.3.0
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.6
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/Mic92/ssh-to-age v0.0.0-20251215041857-58c160f0b0e5 h1:rJ5ex4z15yfMDmP/Ia3Zz38k0WDTyVCRosWDsgH3Vv0=
github.com/Mic92/ssh-to-age v0.0.0-20251215041857-58c160f0b0e5/go.mod h1:0swN1sHoxt6VkeS8s85/aDtJe6rsTDgglDgskjPnLeI=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
Expand Down
Loading