From 7fea4324b8d40cd34440cef0394b87dda0b1a6c9 Mon Sep 17 00:00:00 2001 From: Aram Akhavan <1147328+kaysond@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:16:59 -0700 Subject: [PATCH] feat(age): support encrypting to age identities * add `age-identity:` recipients for SOPS_AGE_KEY, SOPS_AGE_KEY_FILE, SOPS_AGE_KEY_CMD and sops/age/keys.txt * add encryption support for above sources * tag loaded identities with their source * update help text and docs Co-Authored-By: Claude Opus 5.5 Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com> --- age/keysource.go | 89 ++++++++++++++++++---- age/keysource_test.go | 173 ++++++++++++++++++++++++++++++++++++++---- cmd/sops/main.go | 12 ++- 3 files changed, 241 insertions(+), 33 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index 4eb9a200ed..02417ad31a 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -50,6 +50,12 @@ const ( xdgConfigHome = "XDG_CONFIG_HOME" // KeyTypeIdentifier is the string used to identify an age MasterKey. KeyTypeIdentifier = "age" + // IdentityReferencePrefix prefixes a recipient naming the source of age + // identities: SopsAgeKeyEnv, SopsAgeKeyFileEnv, SopsAgeKeyCmdEnv or + // SopsAgeKeyUserConfigPath. Encrypting to it wraps the data key with every + // identity loaded from that source, and hints at where the identities + // needed for decryption are expected. + IdentityReferencePrefix = "age-identity:" ) // log is the global logger for any age MasterKey. @@ -114,10 +120,19 @@ func (e errSet) Error() string { return strings.Join(str, "; ") } -// MasterKeyFromRecipient takes a Bech32-encoded age public key, parses it, and -// returns a new MasterKey. +// MasterKeyFromRecipient takes a Bech32-encoded age public key, or an age +// identity reference (see IdentityReferencePrefix), parses it, and returns a +// new MasterKey. func MasterKeyFromRecipient(recipient string) (*MasterKey, error) { recipient = strings.TrimSpace(recipient) + if source, ok := strings.CutPrefix(recipient, IdentityReferencePrefix); ok { + switch source { + case SopsAgeKeyEnv, SopsAgeKeyFileEnv, SopsAgeKeyCmdEnv, SopsAgeKeyUserConfigPath: + // Resolved on Encrypt, as it requires the identities to be loaded. + return &MasterKey{Recipient: recipient}, nil + } + return nil, fmt.Errorf("unknown age identity source %q: expected one of %s, %s, %s or %s", source, SopsAgeKeyEnv, SopsAgeKeyFileEnv, SopsAgeKeyCmdEnv, SopsAgeKeyUserConfigPath) + } parsedRecipient, err := parseRecipient(recipient) if err != nil { return nil, err @@ -161,18 +176,15 @@ func (i ParsedIdentities) ApplyToMasterKey(key *MasterKey) { // Encrypt takes a SOPS data key, encrypts it with the Recipient, and stores // the result in the EncryptedKey field. func (key *MasterKey) Encrypt(dataKey []byte) error { - if key.parsedRecipient == nil { - parsedRecipient, err := parseRecipient(key.Recipient) - if err != nil { - log.WithField("recipient", key.parsedRecipient).Info("Encryption failed") - return err - } - key.parsedRecipient = parsedRecipient + recipients, err := key.recipients() + if err != nil { + log.WithField("recipient", key.Recipient).Info("Encryption failed") + return err } var buffer bytes.Buffer aw := armor.NewWriter(&buffer) - w, err := age.Encrypt(aw, key.parsedRecipient) + w, err := age.Encrypt(aw, recipients...) if err != nil { log.WithField("recipient", key.parsedRecipient).Info("Encryption failed") return fmt.Errorf("failed to create writer for encrypting sops data key with age: %w", err) @@ -195,6 +207,39 @@ func (key *MasterKey) Encrypt(dataKey []byte) error { return nil } +// recipients returns the parsed Recipient, or the recipients of the identities +// loaded from the source an identity reference names. +func (key *MasterKey) recipients() ([]age.Recipient, error) { + source, ok := strings.CutPrefix(key.Recipient, IdentityReferencePrefix) + if !ok { + if key.parsedRecipient == nil { + parsedRecipient, err := parseRecipient(key.Recipient) + if err != nil { + return nil, err + } + key.parsedRecipient = parsedRecipient + } + return []age.Recipient{key.parsedRecipient}, nil + } + + _, bySource, unusedLocations, errs := key.loadIdentities() + var recipients []age.Recipient + for _, id := range bySource[source] { + switch id := id.(type) { + case *age.X25519Identity: + recipients = append(recipients, id.Recipient()) + case *age.HybridIdentity: + recipients = append(recipients, id.Recipient()) + case *plugin.Identity: + recipients = append(recipients, id.Recipient()) + } + } + if len(recipients) == 0 { + return nil, formatError(fmt.Sprintf("no age identities loaded from %s", source), nil, errs, unusedLocations) + } + return recipients, nil +} + // EncryptIfNeeded encrypts the provided SOPS data key, if it has not been // encrypted yet. func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error { @@ -241,14 +286,19 @@ func formatError(msg string, err error, errs errSet, unusedLocations []string) e // Decrypt decrypts the EncryptedKey with the parsed or loaded identities, and // returns the result. func (key *MasterKey) Decrypt() ([]byte, error) { + var hint string + if source, ok := strings.CutPrefix(key.Recipient, IdentityReferencePrefix); ok { + hint = fmt.Sprintf(" (encrypted to the age identities from %s)", source) + } + var errs errSet var unusedLocations []string if len(key.parsedIdentities) == 0 { var ids ParsedIdentities - ids, unusedLocations, errs = key.loadIdentities() + ids, _, unusedLocations, errs = key.loadIdentities() if len(ids) == 0 { log.Info("Decryption failed") - return nil, formatError("failed to load age identities", nil, errs, unusedLocations) + return nil, formatError("failed to load age identities"+hint, nil, errs, unusedLocations) } ids.ApplyToMasterKey(key) } @@ -258,7 +308,7 @@ func (key *MasterKey) Decrypt() ([]byte, error) { r, err := age.Decrypt(ar, key.parsedIdentities...) if err != nil { log.Info("Decryption failed") - return nil, formatError("failed to create reader for decrypting sops data key with age", err, errs, unusedLocations) + return nil, formatError("failed to create reader for decrypting sops data key with age"+hint, err, errs, unusedLocations) } var b bytes.Buffer @@ -402,13 +452,16 @@ func getUserConfigDir() (string, error) { type identityReader struct { reader io.Reader allowMultipleKeysPerLine bool + // source names where the identities come from (see IdentityReferencePrefix). + source string } // loadIdentities attempts to load the age identities based on runtime // environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv, // SopsAgeSshPrivateKeyFileEnv, SopsAgeKeyUserConfigPath). It will load all // found references, and expects at least one configuration to be present. -func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { +// Besides all identities, it returns the non-SSH identities by their source. +func (key *MasterKey) loadIdentities() (ParsedIdentities, map[string]ParsedIdentities, []string, errSet) { identities, unusedLocations, errs := key.loadAgeSSHIdentities() var readers = make(map[string]identityReader, 0) @@ -417,6 +470,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { readers[SopsAgeKeyEnv] = identityReader{ reader: strings.NewReader(ageKey), allowMultipleKeysPerLine: true, + source: SopsAgeKeyEnv, } } else { unusedLocations = append(unusedLocations, SopsAgeKeyEnv) @@ -431,6 +485,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { readers[SopsAgeKeyFileEnv] = identityReader{ reader: f, allowMultipleKeysPerLine: false, + source: SopsAgeKeyFileEnv, } } } else { @@ -445,6 +500,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { readers[SopsAgeKeyCmdEnv] = identityReader{ reader: bytes.NewReader(out), allowMultipleKeysPerLine: false, + source: SopsAgeKeyCmdEnv, } } } else { @@ -466,22 +522,25 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { readers[ageKeyFilePath] = identityReader{ reader: f, allowMultipleKeysPerLine: false, + source: SopsAgeKeyUserConfigPath, } } } + bySource := make(map[string]ParsedIdentities, len(readers)) for location, r := range readers { ids, err := unwrapIdentities(location, r.reader, r.allowMultipleKeysPerLine) if err != nil { errs = append(errs, err) } else { identities = append(identities, ids...) + bySource[r.source] = ids if len(ids) == 0 { unusedLocations = append(unusedLocations, location) } } } - return identities, unusedLocations, errs + return identities, bySource, unusedLocations, errs } // parseRecipient attempts to parse a string containing an encoded age public diff --git a/age/keysource_test.go b/age/keysource_test.go index da315d1a40..73af85293f 100644 --- a/age/keysource_test.go +++ b/age/keysource_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + "filippo.io/age" + "filippo.io/age/plugin" "github.com/stretchr/testify/assert" ) @@ -145,6 +147,20 @@ func TestMasterKeyFromRecipient(t *testing.T) { assert.Error(t, err) assert.Nil(t, got) }) + + t.Run("identity reference", func(t *testing.T) { + ref := IdentityReferencePrefix + SopsAgeKeyUserConfigPath + got, err := MasterKeyFromRecipient(" " + ref + " ") + assert.NoError(t, err) + assert.EqualValues(t, ref, got.Recipient) + assert.Nil(t, got.parsedRecipient) + }) + + t.Run("invalid identity reference", func(t *testing.T) { + got, err := MasterKeyFromRecipient(IdentityReferencePrefix + "/some/file") + assert.ErrorContains(t, err, "unknown age identity source") + assert.Nil(t, got) + }) } func TestParsedIdentities_Import(t *testing.T) { @@ -371,7 +387,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeKeyEnv, mockIdentity) key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 1) assert.Len(t, unusedLocations, 6) @@ -385,7 +401,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeKeyEnv, mockIdentity+"\n"+mockOtherIdentity) key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 2) assert.Len(t, unusedLocations, 6) @@ -402,7 +418,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeKeyFileEnv, keyPath) key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 1) assert.Len(t, unusedLocations, 6) @@ -421,7 +437,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700)) assert.NoError(t, os.WriteFile(keyPath, []byte(mockIdentity), 0o644)) - got, unusedLocations, errs := (&MasterKey{}).loadIdentities() + got, _, unusedLocations, errs := (&MasterKey{}).loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 1) assert.Len(t, unusedLocations, 7) @@ -441,7 +457,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeSshPrivateKeyFileEnv, keyPath) key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 1) assert.Len(t, unusedLocations, 6) @@ -451,7 +467,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { tmpDir := t.TempDir() overwriteUserConfigDir(t, tmpDir) - got, unusedLocations, errs := (&MasterKey{}).loadIdentities() + got, _, unusedLocations, errs := (&MasterKey{}).loadIdentities() assert.Len(t, errs, 0) assert.Nil(t, got) assert.Len(t, unusedLocations, 8) @@ -474,7 +490,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { assert.NoError(t, os.WriteFile(keyPath2, []byte(mockOtherIdentity), 0o644)) t.Setenv(SopsAgeKeyFileEnv, keyPath2) - got, unusedLocations, errs := (&MasterKey{}).loadIdentities() + got, _, unusedLocations, errs := (&MasterKey{}).loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 2) assert.Len(t, unusedLocations, 6) @@ -488,7 +504,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeKeyEnv, "invalid") key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 1) assert.Error(t, errs[0]) assert.ErrorContains(t, errs[0], fmt.Sprintf("failed to parse '%s' age identities", SopsAgeKeyEnv)) @@ -504,7 +520,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeSshPrivateKeyCmdEnv, "echo '"+mockSshIdentity+"'") key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 1) assert.Len(t, unusedLocations, 6) @@ -518,7 +534,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeSshPrivateKeyCmdEnv, "meow") key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 1) assert.Error(t, errs[0]) assert.ErrorContains(t, errs[0], "failed to execute command meow") @@ -534,7 +550,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeKeyCmdEnv, "echo '"+mockIdentity+"'") key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 1) assert.Len(t, unusedLocations, 6) @@ -548,13 +564,13 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeKeyCmdEnv, fmt.Sprintf("bash -c 'if [[ $SOPS_AGE_RECIPIENT = %s ]]; then echo %s; fi'", mockRecipient, mockIdentity)) key := &MasterKey{Recipient: mockRecipient} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 1) assert.Len(t, unusedLocations, 6) key = &MasterKey{Recipient: mockRecipient + "abc"} - got, unusedLocations, errs = key.loadIdentities() + got, _, unusedLocations, errs = key.loadIdentities() assert.Len(t, errs, 0) assert.Len(t, got, 0) assert.Len(t, unusedLocations, 7) @@ -568,7 +584,7 @@ func TestMasterKey_loadIdentities(t *testing.T) { t.Setenv(SopsAgeKeyCmdEnv, "meow") key := &MasterKey{} - got, unusedLocations, errs := key.loadIdentities() + got, _, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 1) assert.Error(t, errs[0]) assert.ErrorContains(t, errs[0], "failed to execute command meow") @@ -655,3 +671,132 @@ func TestMasterKey_Identities_Passphrase(t *testing.T) { assert.Nil(t, got) }) } + +func TestMain(m *testing.M) { + // Act as the age plugin when exec'd through the symlink from setupTestPlugin. + if filepath.Base(os.Args[0]) == "age-plugin-sopstest" { + p, _ := plugin.New("sopstest") + p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) { + return testPluginKey(data), nil + }) + p.HandleIdentity(func(data []byte) (age.Identity, error) { + return testPluginKey(data), nil + }) + os.Exit(p.Main()) + } + os.Exit(m.Run()) +} + +// testPluginKey wraps file keys in the clear, tagged with the identity data. +type testPluginKey []byte + +func (k testPluginKey) Wrap(fileKey []byte) ([]*age.Stanza, error) { + return []*age.Stanza{{Type: "sopstest", Args: []string{string(k)}, Body: fileKey}}, nil +} + +func (k testPluginKey) Unwrap(stanzas []*age.Stanza) ([]byte, error) { + for _, s := range stanzas { + if s.Type == "sopstest" && len(s.Args) == 1 && s.Args[0] == string(k) { + return s.Body, nil + } + } + return nil, age.ErrIncorrectIdentity +} + +// setupTestPlugin puts the test binary on PATH as age-plugin-sopstest. +func setupTestPlugin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("plugin symlink is not supported on Windows") + } + exe, err := os.Executable() + assert.NoError(t, err) + dir := t.TempDir() + assert.NoError(t, os.Symlink(exe, filepath.Join(dir, "age-plugin-sopstest"))) + t.Setenv("PATH", dir) + overwriteUserConfigDir(t, t.TempDir()) +} + +func TestMasterKey_IdentityReference(t *testing.T) { + setupTestPlugin(t) + alice := plugin.EncodeIdentity("sopstest", []byte("alice")) + bob := plugin.EncodeIdentity("sopstest", []byte("bob")) + carol := plugin.EncodeIdentity("sopstest", []byte("carol")) + data := []byte("some secret data") + + decrypt := func(t *testing.T, enc string, identities ...string) error { + var ids ParsedIdentities + assert.NoError(t, ids.Import(identities...)) + key := &MasterKey{EncryptedKey: enc} + ids.ApplyToMasterKey(key) + got, err := key.Decrypt() + if err == nil { + assert.Equal(t, data, got) + } + return err + } + + t.Run("encrypts to every identity from the source", func(t *testing.T) { + t.Setenv(SopsAgeKeyEnv, strings.Join([]string{mockIdentity, alice, bob}, "\n")) + keyFile := filepath.Join(t.TempDir(), "keys.txt") + assert.NoError(t, os.WriteFile(keyFile, []byte(carol), 0o600)) + t.Setenv(SopsAgeKeyFileEnv, keyFile) + + key, err := MasterKeyFromRecipient(IdentityReferencePrefix + SopsAgeKeyEnv) + assert.NoError(t, err) + assert.NoError(t, key.Encrypt(data)) + assert.NotEmpty(t, key.EncryptedKey) + + assert.NoError(t, decrypt(t, key.EncryptedKey, alice)) + assert.NoError(t, decrypt(t, key.EncryptedKey, bob)) + assert.NoError(t, decrypt(t, key.EncryptedKey, mockIdentity)) + assert.Error(t, decrypt(t, key.EncryptedKey, carol)) + }) + + t.Run("encrypts to hybrid identities", func(t *testing.T) { + t.Setenv(SopsAgeKeyEnv, mockHybridIdentity) + key, err := MasterKeyFromRecipient(IdentityReferencePrefix + SopsAgeKeyEnv) + assert.NoError(t, err) + assert.NoError(t, key.Encrypt(data)) + assert.NoError(t, decrypt(t, key.EncryptedKey, mockHybridIdentity)) + }) + + t.Run("decrypts with identities from any source", func(t *testing.T) { + t.Setenv(SopsAgeKeyEnv, alice) + key, err := MasterKeyFromRecipient(IdentityReferencePrefix + SopsAgeKeyEnv) + assert.NoError(t, err) + assert.NoError(t, key.Encrypt(data)) + + os.Unsetenv(SopsAgeKeyEnv) + keyFile := filepath.Join(t.TempDir(), "keys.txt") + assert.NoError(t, os.WriteFile(keyFile, []byte(mockIdentity+"\n"+alice), 0o600)) + t.Setenv(SopsAgeKeyFileEnv, keyFile) + decryptKey := &MasterKey{Recipient: key.Recipient, EncryptedKey: key.EncryptedKey} + got, err := decryptKey.Decrypt() + assert.NoError(t, err) + assert.Equal(t, data, got) + }) + + t.Run("decryption error names the source", func(t *testing.T) { + t.Setenv(SopsAgeKeyEnv, alice) + key, err := MasterKeyFromRecipient(IdentityReferencePrefix + SopsAgeKeyEnv) + assert.NoError(t, err) + assert.NoError(t, key.Encrypt(data)) + + t.Setenv(SopsAgeKeyEnv, bob) + decryptKey := &MasterKey{Recipient: key.Recipient, EncryptedKey: key.EncryptedKey} + _, err = decryptKey.Decrypt() + assert.ErrorContains(t, err, "encrypted to the age identities from "+SopsAgeKeyEnv) + }) + + t.Run("no identity in source", func(t *testing.T) { + t.Setenv(SopsAgeKeyEnv, "") + keyFile := filepath.Join(t.TempDir(), "keys.txt") + assert.NoError(t, os.WriteFile(keyFile, []byte(alice), 0o600)) + t.Setenv(SopsAgeKeyFileEnv, keyFile) + + key, err := MasterKeyFromRecipient(IdentityReferencePrefix + SopsAgeKeyEnv) + assert.NoError(t, err) + assert.ErrorContains(t, key.Encrypt(data), "no age identities loaded from "+SopsAgeKeyEnv) + assert.Empty(t, key.EncryptedKey) + }) +} diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 8092a3f400..03325f56d0 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -134,7 +134,11 @@ func main() { The user/sp needs the key/encrypt and key/decrypt permissions.) To encrypt or decrypt using age, specify the recipient in the -a flag, - or in the SOPS_AGE_RECIPIENTS environment variable. + or in the SOPS_AGE_RECIPIENTS environment variable. Identities can be + used by specifying "age-identity:" where "" is one of + "SOPS_AGE_KEY", "SOPS_AGE_KEY_FILE", "SOPS_AGE_KEY_CMD", referring to + the corresponding environment variables, or "sops/age/keys.txt" to use + the default key file contents. To encrypt or decrypt using PGP, specify the PGP fingerprint in the -p flag or in the SOPS_PGP_FP environment variable. @@ -967,7 +971,7 @@ func main() { }, cli.StringFlag{ Name: "age, a", - Usage: "comma separated list of age recipients", + Usage: "comma separated list of age recipients or identity sources", EnvVar: "SOPS_AGE_RECIPIENTS", }, cli.StringFlag{ @@ -1326,7 +1330,7 @@ func main() { }, cli.StringFlag{ Name: "age, a", - Usage: "comma separated list of age recipients", + Usage: "comma separated list of age recipients, or age-identity: to encrypt to the identities from SOPS_AGE_KEY, SOPS_AGE_KEY_FILE, SOPS_AGE_KEY_CMD or sops/age/keys.txt", EnvVar: "SOPS_AGE_RECIPIENTS", }, cli.StringFlag{ @@ -1739,7 +1743,7 @@ func main() { }, cli.StringFlag{ Name: "age, a", - Usage: "comma separated list of age recipients", + Usage: "comma separated list of age recipients, or age-identity: to encrypt to the identities from SOPS_AGE_KEY, SOPS_AGE_KEY_FILE, SOPS_AGE_KEY_CMD or sops/age/keys.txt", EnvVar: "SOPS_AGE_RECIPIENTS", }, cli.BoolFlag{