From 02a866f27d8545fdc3de3b44a563e46f8e9c9df9 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 27 May 2022 11:53:54 +0200 Subject: [PATCH] age: improve identity loading, add tests, tidy This adds improvements to identity loading, extensive test coverage and a general tidying of bits of code. The improvements are based on a fork of the age key source in the Flux project's kustomize-controller, which was built due to SOPS' limitations around identity management without relying on runtime environment variables. - It introduces a `ParsedIdentity` type which contains a slice of age identities, and can be applied to the `MasterKey`. When applied, further loading of identities from the runtime environment is skipped for `Decrypt` operations. This is most useful when working with SOPS as an SDK, in combination with e.g. a local key service server implementation. - The `Identity` field has been deprecated in the `MasterKey` struct. Presence of the field was misleading, as it is not actually used. - Any detected identity reference is now loaded, instead of it assuming a priority order. This makes more sense, as age is able to work with a set of loaded identities. If no environment variables are defined, the existence of the keys.txt in the user's config directory is required. - Decrypt logs have been added to match other key sources. - Extensive test coverage. Signed-off-by: Hidde Beydals --- age/keys.txt | 6 - age/keysource.go | 300 ++++++++++++++++----------- age/keysource_test.go | 459 +++++++++++++++++++++++++++++++----------- 3 files changed, 532 insertions(+), 233 deletions(-) delete mode 100644 age/keys.txt diff --git a/age/keys.txt b/age/keys.txt deleted file mode 100644 index 9b998fcc8..000000000 --- a/age/keys.txt +++ /dev/null @@ -1,6 +0,0 @@ -# created: 2020-07-18T03:16:47-07:00 -# public key: age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw -AGE-SECRET-KEY-1NJT5YCS2LWU4V4QAJQ6R4JNU7LXPDX602DZ9NUFANVU5GDTGUWCQ5T59M6 -# created: 2021-12-12T01:39:30+01:00 -# public key: age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep -AGE-SECRET-KEY-1T0Z66WSXS6RMNCPSL7P2E8N4Q7SUD8VMG9ND27S08JL7Y2XAU9EQECHDS7 \ No newline at end of file diff --git a/age/keysource.go b/age/keysource.go index 14f114629..d05431383 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -2,6 +2,7 @@ package age import ( "bytes" + "errors" "fmt" "io" "os" @@ -14,146 +15,192 @@ import ( "go.mozilla.org/sops/v3/logging" ) +const ( + // SopsAgeKeyEnv can be set as an environment variable with a string list + // of age keys as value. + SopsAgeKeyEnv = "SOPS_AGE_KEY" + // SopsAgeKeyFileEnv can be set as an environment variable pointing to an + // age keys file. + SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE" + // SopsAgeKeyUserConfigPath is the default age keys file path in + // os.UserConfigDir. + SopsAgeKeyUserConfigPath = "sops/age/keys.txt" +) + +// log is the global logger for any age MasterKey. var log *logrus.Logger func init() { log = logging.NewLogger("AGE") } -const SopsAgeKeyEnv = "SOPS_AGE_KEY" -const SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE" - -// MasterKey is an age key used to encrypt and decrypt sops' data key. +// MasterKey is an age key used to Encrypt and Decrypt SOPS' data key. type MasterKey struct { - Identity string // a Bech32-encoded private key - Recipient string // a Bech32-encoded public key - EncryptedKey string // a sops data key encrypted with age + // Identity used to contain a Bench32-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 string + // EncryptedKey contains the SOPS data key encrypted with age. + EncryptedKey string + + // parsedIdentities contains a slice of parsed age identities. + // It is used to lazy-load the Identities at-most once. + // It can also be injected by a (local) keyservice.KeyServiceServer using + // ParsedIdentities.ApplyToMasterKey(). + 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.X25519Recipient // a parsed age public key +// MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded +// public keys, parses them, and returns a slice of new MasterKeys. +func MasterKeysFromRecipients(commaSeparatedRecipients string) ([]*MasterKey, error) { + if commaSeparatedRecipients == "" { + // otherwise Split returns [""] and MasterKeyFromRecipient is unhappy + return make([]*MasterKey, 0), nil + } + recipients := strings.Split(commaSeparatedRecipients, ",") + + var keys []*MasterKey + for _, recipient := range recipients { + key, err := MasterKeyFromRecipient(recipient) + if err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, nil } -// Encrypt takes a sops data key, encrypts it with age and stores the result in the EncryptedKey field. -func (key *MasterKey) Encrypt(datakey []byte) error { - buffer := &bytes.Buffer{} +// MasterKeyFromRecipient takes a Bech32-encoded age public key, parses it, and +// returns a new MasterKey. +func MasterKeyFromRecipient(recipient string) (*MasterKey, error) { + recipient = strings.TrimSpace(recipient) + parsedRecipient, err := parseRecipient(recipient) + if err != nil { + return nil, err + } + return &MasterKey{ + Recipient: recipient, + parsedRecipient: parsedRecipient, + }, nil +} +// ParsedIdentities contains a set of parsed age identities. +// It allows for creating a (local) keyservice.KeyServiceServer which parses +// identities only once, to then inject them using ApplyToMasterKey() for all +// requests. +type ParsedIdentities []age.Identity + +// Import attempts to parse the given identities, to then add them to itself. +// It returns any parsing error. +// A single identity argument is allowed to be a multiline string containing +// multiple identities. Empty lines and lines starting with "#" are ignored. +// It is not thread safe, and parallel importing would better be done by +// parsing (using age.ParseIdentities) and appending to the slice yourself, in +// combination with e.g. a sync.Mutex. +func (i *ParsedIdentities) Import(identity ...string) error { + identities, err := parseIdentities(identity...) + if err != nil { + return fmt.Errorf("failed to parse and add to age identities: %w", err) + } + *i = append(*i, identities...) + return nil +} + +// ApplyToMasterKey configures the ParsedIdentities on the provided key. +func (i ParsedIdentities) ApplyToMasterKey(key *MasterKey) { + key.parsedIdentities = i +} + +// 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).Error("Encryption failed") + log.WithError(err).WithField("recipient", key.parsedRecipient).Error("Encryption failed") return err } - key.parsedRecipient = parsedRecipient } - aw := armor.NewWriter(buffer) + var buffer bytes.Buffer + aw := armor.NewWriter(&buffer) w, err := age.Encrypt(aw, key.parsedRecipient) if err != nil { - return fmt.Errorf("failed to open file for encrypting sops data key with age: %w", err) + log.WithError(err).WithField("recipient", key.parsedRecipient).Error("Encryption failed") + return fmt.Errorf("failed to create writer for encrypting sops data key with age: %w", err) } - - if _, err := w.Write(datakey); err != nil { - log.WithField("recipient", key.parsedRecipient).Error("Encryption failed") + if _, err := w.Write(dataKey); err != nil { + log.WithError(err).WithField("recipient", key.parsedRecipient).Error("Encryption failed") return fmt.Errorf("failed to encrypt sops data key with age: %w", err) } - if err := w.Close(); err != nil { - log.WithField("recipient", key.parsedRecipient).Error("Encryption failed") - return fmt.Errorf("failed to close file for encrypting sops data key with age: %w", err) + log.WithError(err).WithField("recipient", key.parsedRecipient).Error("Encryption failed") + return fmt.Errorf("failed to close writer for encrypting sops data key with age: %w", err) } - if err := aw.Close(); err != nil { - log.WithField("recipient", key.parsedRecipient).Error("Encryption failed") + log.WithError(err).WithField("recipient", key.parsedRecipient).Error("Encryption failed") return fmt.Errorf("failed to close armored writer: %w", err) } - key.EncryptedKey = buffer.String() - + key.SetEncryptedDataKey(buffer.Bytes()) log.WithField("recipient", key.parsedRecipient).Info("Encryption succeeded") - return nil } -// EncryptIfNeeded encrypts the provided sops' data key and encrypts it if it hasn't been encrypted yet. -func (key *MasterKey) EncryptIfNeeded(datakey []byte) error { +// EncryptIfNeeded encrypts the provided SOPS data key, if it has not been +// encrypted yet. +func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error { if key.EncryptedKey == "" { - return key.Encrypt(datakey) + return key.Encrypt(dataKey) } - return nil } -// EncryptedDataKey returns the encrypted data key this master key holds. +// EncryptedDataKey returns the encrypted SOPS data key this master key holds. func (key *MasterKey) EncryptedDataKey() []byte { return []byte(key.EncryptedKey) } -// SetEncryptedDataKey sets the encrypted data key for this master key. +// SetEncryptedDataKey sets the encrypted SOPS data key for this master key. func (key *MasterKey) SetEncryptedDataKey(enc []byte) { key.EncryptedKey = string(enc) } -// Decrypt decrypts the EncryptedKey field with the age identity and returns the result. +// Decrypt decrypts the EncryptedKey with the parsed or loaded identities, and +// returns the result. func (key *MasterKey) Decrypt() ([]byte, error) { - var ageKeyReader io.Reader - var ageKeyReaderName string - - if ageKeyReader == nil { - ageKey, ok := os.LookupEnv(SopsAgeKeyEnv) - if ok { - ageKeyReader = strings.NewReader(ageKey) - ageKeyReaderName = "environment variable" - } - } - - if ageKeyReader == nil { - ageKeyFilePath, ok := os.LookupEnv(SopsAgeKeyFileEnv) - if ok { - ageKeyFile, err := os.Open(ageKeyFilePath) - if err != nil { - return nil, fmt.Errorf("failed to open file: %w", err) - } - defer ageKeyFile.Close() - ageKeyReader = ageKeyFile - ageKeyReaderName = ageKeyFilePath - } - } - - if ageKeyReader == nil { - userConfigDir, err := os.UserConfigDir() + if len(key.parsedIdentities) == 0 { + ids, err := key.loadIdentities() if err != nil { - return nil, fmt.Errorf("user config directory could not be determined: %w", err) + log.WithError(err).Error("Decryption failed") + return nil, fmt.Errorf("failed to load age identities: %w", err) } - ageKeyFilePath := filepath.Join(userConfigDir, "sops", "age", "keys.txt") - ageKeyFile, err := os.Open(ageKeyFilePath) - if err != nil { - return nil, fmt.Errorf("failed to open file: %w", err) - } - defer ageKeyFile.Close() - ageKeyReader = ageKeyFile - ageKeyReaderName = ageKeyFilePath - } - - identities, err := age.ParseIdentities(ageKeyReader) - - if err != nil { - return nil, err + ids.ApplyToMasterKey(key) } src := bytes.NewReader([]byte(key.EncryptedKey)) ar := armor.NewReader(src) - r, err := age.Decrypt(ar, identities...) - + r, err := age.Decrypt(ar, key.parsedIdentities...) if err != nil { - return nil, fmt.Errorf("no age identity found in %q that could decrypt the data", ageKeyReaderName) + log.WithError(err).Error("Decryption failed") + return nil, fmt.Errorf("failed to create reader for decrypting sops data key with age: %w", err) } var b bytes.Buffer if _, err := io.Copy(&b, r); err != nil { - return nil, fmt.Errorf("failed to copy decrypted data into bytes.Buffer: %w", err) + log.WithError(err).Error("Decryption failed") + return nil, fmt.Errorf("failed to copy age decrypted data into bytes.Buffer: %w", err) } + log.Info("Decryption succeeded") return b.Bytes(), nil } @@ -169,55 +216,84 @@ func (key *MasterKey) ToString() string { // ToMap converts the MasterKey to a map for serialization purposes. func (key *MasterKey) ToMap() map[string]interface{} { - return map[string]interface{}{"recipient": key.Recipient, "enc": key.EncryptedKey} + out := make(map[string]interface{}) + out["recipient"] = key.Recipient + out["enc"] = key.EncryptedKey + return out } -// MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded public keys and returns a -// slice of new MasterKeys. -func MasterKeysFromRecipients(commaSeparatedRecipients string) ([]*MasterKey, error) { - if commaSeparatedRecipients == "" { - // otherwise Split returns [""] and MasterKeyFromRecipient is unhappy - return make([]*MasterKey, 0), nil - } - recipients := strings.Split(commaSeparatedRecipients, ",") - - var keys []*MasterKey +// 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. +func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { + var readers = make(map[string]io.Reader, 0) - for _, recipient := range recipients { - key, err := masterKeyFromRecipient(recipient) + if ageKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok { + readers[SopsAgeKeyEnv] = strings.NewReader(ageKey) + } + if ageKeyFile, ok := os.LookupEnv(SopsAgeKeyFileEnv); ok { + f, err := os.Open(ageKeyFile) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to open %s file: %w", SopsAgeKeyFileEnv, err) } - - keys = append(keys, key) + defer f.Close() + readers[SopsAgeKeyFileEnv] = f } - return keys, nil -} - -// masterKeyFromRecipient takes a Bech32-encoded public key and returns a new MasterKey. -func masterKeyFromRecipient(recipient string) (*MasterKey, error) { - recipient = strings.TrimSpace(recipient) - parsedRecipient, err := parseRecipient(recipient) - - if err != nil { - return nil, err + userConfigDir, err := os.UserConfigDir() + if err != nil && len(readers) == 0 { + return nil, fmt.Errorf("user config directory could not be determined: %w", err) + } + if userConfigDir != "" { + ageKeyFilePath := filepath.Join(userConfigDir, filepath.FromSlash(SopsAgeKeyUserConfigPath)) + f, err := os.Open(ageKeyFilePath) + 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 we have no other readers, presence of the file is required. + return nil, fmt.Errorf("failed to open file: %w", err) + } + if err == nil { + defer f.Close() + readers[ageKeyFilePath] = f + } } - return &MasterKey{ - Recipient: recipient, - parsedRecipient: parsedRecipient, - }, nil + var identities ParsedIdentities + for n, r := range readers { + ids, err := age.ParseIdentities(r) + if err != nil { + return nil, fmt.Errorf("failed to parse '%s' age identities: %w", n, err) + } + identities = append(identities, ids...) + } + return identities, nil } -// parseRecipient attempts to parse a string containing an encoded age public key +// 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) } - return parsedRecipient, nil } + +// parseIdentities attempts to parse the string set of encoded age identities. +// A single identity argument is allowed to be a multiline string containing +// multiple identities. Empty lines and lines starting with "#" are ignored. +func parseIdentities(identity ...string) (ParsedIdentities, error) { + var identities []age.Identity + for _, i := range identity { + parsed, err := age.ParseIdentities(strings.NewReader(i)) + if err != nil { + return nil, err + } + identities = append(identities, parsed...) + } + return identities, nil +} diff --git a/age/keysource_test.go b/age/keysource_test.go index 57e53f7dd..dd56351ea 100644 --- a/age/keysource_test.go +++ b/age/keysource_test.go @@ -1,161 +1,390 @@ package age import ( - "io/ioutil" + "fmt" "os" - "path" + "path/filepath" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" ) -func TestMasterKeysFromRecipientsEmpty(t *testing.T) { - assert := assert.New(t) - - commaSeparatedRecipients := "" - recipients, err := MasterKeysFromRecipients(commaSeparatedRecipients) - - assert.NoError(err) - - assert.Equal(recipients, make([]*MasterKey, 0)) -} +const ( + // mockRecipient is a mock age recipient, it matches mockIdentity. + mockRecipient string = "age1lzd99uklcjnc0e7d860axevet2cz99ce9pq6tzuzd05l5nr28ams36nvun" + // mockIdentity is a mock age identity. + mockIdentity string = "AGE-SECRET-KEY-1G0Q5K9TV4REQ3ZSQRMTMG8NSWQGYT0T7TZ33RAZEE0GZYVZN0APSU24RK7" + // mockOtherIdentity is another mock age identity. + mockOtherIdentity string = "AGE-SECRET-KEY-1432K5YRNSC44GC4986NXMX6GVZ52WTMT9C79CLUVWYY4DKDHD5JSNDP4MC" + // mockEncryptedKey equals to mockEncryptedKeyPlain when decrypted with mockIdentity. + mockEncryptedKey string = `-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvY2t2NkdLUGRvY3l2OGNy +MVJWcUhCOEZrUG8yeCtnRnhxL0I5NFk4YjJFCmE4SVQ3MEdyZkFqRWpSa2F0NVhF +VDUybzBxdS9nSGpHSVRVMUI0UEVqZkkKLS0tIGJjeGhNQ0Y5L2VZRVVYSm90djFF +bzdnQ3UwTGljMmtrbWNMV1MxYkFzUFUK4xjOZOTGdcbzuwUY/zeBXhcF+Md3e5PQ +EylloI7MNGbadPGb +-----END AGE ENCRYPTED FILE-----` + // mockEncryptedKeyPlain is the plain value of mockEncryptedKey. + mockEncryptedKeyPlain string = "data" +) -func TestMasterKeyFromRecipientWithLeadingAndTrailingSpacesSingle(t *testing.T) { - assert := assert.New(t) +func TestMasterKeysFromRecipients(t *testing.T) { + const otherRecipient = "age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep" - commaSeparatedRecipients := " age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw " - keys, err := MasterKeysFromRecipients(commaSeparatedRecipients) + t.Run("recipient", func(t *testing.T) { + got, err := MasterKeysFromRecipients(mockRecipient) + assert.NoError(t, err) - assert.NoError(err) + assert.Len(t, got, 1) + assert.Equal(t, got[0].Recipient, mockRecipient) + }) - assert.Equal(len(keys), 1) - assert.Equal(keys[0].Recipient, "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw") -} + t.Run("recipients", func(t *testing.T) { + got, err := MasterKeysFromRecipients(mockRecipient + "," + otherRecipient) + assert.NoError(t, err) -func TestMasterKeyFromRecipientWithLeadingAndTrailingSpacesMultiple(t *testing.T) { - assert := assert.New(t) + assert.Len(t, got, 2) + assert.Equal(t, got[0].Recipient, mockRecipient) + assert.Equal(t, got[1].Recipient, otherRecipient) + }) - commaSeparatedRecipients := " age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw , age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep " - keys, err := MasterKeysFromRecipients(commaSeparatedRecipients) + t.Run("leading and trailing spaces", func(t *testing.T) { + got, err := MasterKeysFromRecipients(" " + mockRecipient + " , " + otherRecipient + " ") + assert.NoError(t, err) - assert.NoError(err) + assert.Len(t, got, 2) + assert.Equal(t, got[0].Recipient, mockRecipient) + assert.Equal(t, got[1].Recipient, otherRecipient) + }) - assert.Equal(len(keys), 2) - assert.Equal(keys[0].Recipient, "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw") - assert.Equal(keys[1].Recipient, "age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep") + t.Run("empty", func(t *testing.T) { + got, err := MasterKeysFromRecipients("") + assert.NoError(t, err) + assert.Len(t, got, 0) + }) } -func TestMasterKeysFromRecipientsWithSingle(t *testing.T) { - assert := assert.New(t) - - commaSeparatedRecipients := "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw" - keys, err := MasterKeysFromRecipients(commaSeparatedRecipients) - - assert.NoError(err) - - assert.Equal(len(keys), 1) - assert.Equal(keys[0].Recipient, "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw") +func TestMasterKeyFromRecipient(t *testing.T) { + t.Run("recipient", func(t *testing.T) { + got, err := MasterKeyFromRecipient(mockRecipient) + assert.NoError(t, err) + assert.EqualValues(t, mockRecipient, 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) + assert.EqualValues(t, mockRecipient, 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) + assert.Nil(t, got) + }) } -func TestMasterKeysFromRecipientsWithMultiple(t *testing.T) { - assert := assert.New(t) - - commaSeparatedRecipients := "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw,age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep" - keys, err := MasterKeysFromRecipients(commaSeparatedRecipients) - - assert.NoError(err) +func TestParsedIdentities_Import(t *testing.T) { + i := make(ParsedIdentities, 0) + assert.NoError(t, i.Import(mockIdentity, mockOtherIdentity)) + assert.Len(t, i, 2) - assert.Equal(len(keys), 2) - assert.Equal(keys[0].Recipient, "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw") - assert.Equal(keys[1].Recipient, "age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep") + assert.Error(t, i.Import("invalid")) + assert.Len(t, i, 2) } -func TestAge(t *testing.T) { - assert := assert.New(t) +func TestParsedIdentities_ApplyToMasterKey(t *testing.T) { + i := make(ParsedIdentities, 0) + assert.NoError(t, i.Import(mockIdentity, mockOtherIdentity)) - commaSeparatedRecipients := "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw,age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep" - keys, err := MasterKeysFromRecipients(commaSeparatedRecipients) - - assert.NoError(err) - - assert.Equal(len(keys), 2) - assert.Equal(keys[0].Recipient, "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw") - assert.Equal(keys[1].Recipient, "age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep") + key := &MasterKey{} + i.ApplyToMasterKey(key) + assert.EqualValues(t, key.parsedIdentities, i) +} - dataKey := []byte("abcdefghijklmnopqrstuvwxyz123456") +func TestMasterKey_Encrypt(t *testing.T) { + mockParsedRecipient, err := parseRecipient(mockRecipient) + assert.NoError(t, err) + + t.Run("recipient", func(t *testing.T) { + key := &MasterKey{ + Recipient: mockRecipient, + } + assert.NoError(t, key.Encrypt([]byte(mockEncryptedKeyPlain))) + assert.NotEmpty(t, key.EncryptedKey) + }) + + t.Run("parsed recipient", func(t *testing.T) { + key := &MasterKey{ + parsedRecipient: mockParsedRecipient, + } + 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.Empty(t, key.EncryptedKey) + }) + + t.Run("parsed recipient and invalid recipient", func(t *testing.T) { + key := &MasterKey{ + Recipient: "invalid", + parsedRecipient: mockParsedRecipient, + } + // Validates mockParsedRecipient > Recipient + assert.NoError(t, key.Encrypt([]byte(mockEncryptedKeyPlain))) + assert.NotEmpty(t, key.EncryptedKey) + }) +} - for _, key := range keys { - err = key.Encrypt(dataKey) - assert.NoError(err) +func TestMasterKey_EncryptIfNeeded(t *testing.T) { + key, err := MasterKeyFromRecipient(mockRecipient) + assert.NoError(t, err) - _, filename, _, _ := runtime.Caller(0) - err = os.Setenv("SOPS_AGE_KEY_FILE", path.Join(path.Dir(filename), "keys.txt")) - assert.NoError(err) + assert.NoError(t, key.EncryptIfNeeded([]byte(mockEncryptedKeyPlain))) - decryptedKey, err := key.Decrypt() - assert.NoError(err) - assert.Equal(dataKey, decryptedKey) - } + encryptedKey := key.EncryptedKey + assert.Contains(t, encryptedKey, "AGE ENCRYPTED FILE") + assert.NoError(t, key.EncryptIfNeeded([]byte("some other data"))) + assert.Equal(t, encryptedKey, key.EncryptedKey) } -func TestAgeDotEnv(t *testing.T) { - assert := assert.New(t) - - commaSeparatedRecipients := "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw,age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep" - keys, err := MasterKeysFromRecipients(commaSeparatedRecipients) +func TestMasterKey_EncryptedDataKey(t *testing.T) { + key := &MasterKey{EncryptedKey: "some key"} + assert.EqualValues(t, key.EncryptedKey, key.EncryptedDataKey()) +} - assert.NoError(err) +func TestMasterKey_Decrypt(t *testing.T) { + t.Run("parsed identities", func(t *testing.T) { + key := &MasterKey{EncryptedKey: mockEncryptedKey} + var ids ParsedIdentities + assert.NoError(t, ids.Import(mockOtherIdentity, mockIdentity)) + ids.ApplyToMasterKey(key) + + got, err := key.Decrypt() + assert.NoError(t, err) + assert.EqualValues(t, mockEncryptedKeyPlain, got) + }) + + t.Run("loaded identities", func(t *testing.T) { + key := &MasterKey{EncryptedKey: mockEncryptedKey} + t.Setenv(SopsAgeKeyEnv, mockIdentity) + + 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) + + key := &MasterKey{EncryptedKey: mockEncryptedKey} + got, err := key.Decrypt() + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to load age identities") + assert.Nil(t, got) + }) + + t.Run("no matching identity", func(t *testing.T) { + key := &MasterKey{EncryptedKey: mockEncryptedKey} + var ids ParsedIdentities + assert.NoError(t, ids.Import(mockOtherIdentity)) + ids.ApplyToMasterKey(key) + + // This confirms lazy-loading works as intended + t.Setenv(SopsAgeKeyEnv, mockIdentity) + + got, err := key.Decrypt() + assert.Error(t, err) + assert.ErrorContains(t, err, "no identity matched any of the recipients") + assert.Nil(t, got) + }) + + t.Run("invalid encrypted key", func(t *testing.T) { + key := &MasterKey{EncryptedKey: "invalid"} + t.Setenv(SopsAgeKeyEnv, mockIdentity) + + got, err := key.Decrypt() + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to create reader for decrypting sops data key with age") + assert.Nil(t, got) + }) +} - assert.Equal(len(keys), 2) - assert.Equal(keys[0].Recipient, "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw") - assert.Equal(keys[1].Recipient, "age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep") +func TestMasterKey_EncryptDecrypt_RoundTrip(t *testing.T) { + encryptKey, err := MasterKeyFromRecipient(mockRecipient) + assert.NoError(t, err) - dotenv := `IMAGE_PREFIX=repo/service- -APPLICATION_KEY=K6pfAWuUVND9Fz5SC7jmA6pfAWuUVND9Fz5SC7jmA -KEY_ID=003683d721f2ae683d721f2a1 -DOMAIN=files.127.0.0.1.nip.io` - dataKey := []byte(dotenv) + data := []byte("some secret data") + assert.NoError(t, encryptKey.Encrypt(data)) + assert.NotEmpty(t, encryptKey.EncryptedKey) - err = keys[0].Encrypt(dataKey) - assert.NoError(err) + var ids ParsedIdentities + assert.NoError(t, ids.Import(mockIdentity)) - _, filename, _, _ := runtime.Caller(0) - err = os.Setenv(SopsAgeKeyFileEnv, path.Join(path.Dir(filename), "keys.txt")) - defer os.Unsetenv(SopsAgeKeyFileEnv) - assert.NoError(err) + decryptKey := &MasterKey{} + decryptKey.EncryptedKey = encryptKey.EncryptedKey + ids.ApplyToMasterKey(decryptKey) - decryptedKey, err := keys[0].Decrypt() - assert.NoError(err) - assert.Equal(dataKey, decryptedKey) + decryptedData, err := decryptKey.Decrypt() + assert.NoError(t, err) + assert.Equal(t, data, decryptedData) } -func TestAgeEnv(t *testing.T) { - assert := assert.New(t) - - commaSeparatedRecipients := "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw,age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep" - keys, err := MasterKeysFromRecipients(commaSeparatedRecipients) - - assert.NoError(err) - - assert.Equal(len(keys), 2) - assert.Equal(keys[0].Recipient, "age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw") - assert.Equal(keys[1].Recipient, "age1tmaae3ld5vpevmsh5yacsauzx8jetg300mpvc4ugp5zr5l6ssq9sla97ep") +func TestMasterKey_NeedsRotation(t *testing.T) { + key := &MasterKey{Recipient: mockRecipient} + assert.False(t, key.NeedsRotation()) +} - dataKey := []byte("abcdefghijklmnopqrstuvwxyz123456") +func TestMasterKey_ToString(t *testing.T) { + key := &MasterKey{Recipient: mockRecipient} + assert.Equal(t, key.Recipient, key.ToString()) +} - err = keys[0].Encrypt(dataKey) - assert.NoError(err) +func TestMasterKey_ToMap(t *testing.T) { + key := &MasterKey{ + Recipient: mockRecipient, + EncryptedKey: "some-encrypted-key", + } + assert.Equal(t, map[string]interface{}{ + "recipient": mockRecipient, + "enc": key.EncryptedKey, + }, key.ToMap()) +} - _, filename, _, _ := runtime.Caller(0) - keysBytes, err := ioutil.ReadFile(path.Join(path.Dir(filename), "keys.txt")) - assert.NoError(err) - err = os.Setenv(SopsAgeKeyEnv, string(keysBytes)) - defer os.Unsetenv(SopsAgeKeyEnv) - assert.NoError(err) +func TestMasterKey_loadIdentities(t *testing.T) { + t.Run(SopsAgeKeyEnv, func(t *testing.T) { + tmpDir := t.TempDir() + // Overwrite to ensure local config is not picked up by tests + overwriteUserConfigDir(t, tmpDir) + + t.Setenv(SopsAgeKeyEnv, mockIdentity) + + key := &MasterKey{} + got, err := key.loadIdentities() + assert.NoError(t, err) + assert.Len(t, got, 1) + }) + + t.Run(SopsAgeKeyEnv+" multiple", func(t *testing.T) { + tmpDir := t.TempDir() + // Overwrite to ensure local config is not picked up by tests + overwriteUserConfigDir(t, tmpDir) + + t.Setenv(SopsAgeKeyEnv, mockIdentity+"\n"+mockOtherIdentity) + + key := &MasterKey{} + got, err := key.loadIdentities() + assert.NoError(t, err) + assert.Len(t, got, 2) + }) + + t.Run(SopsAgeKeyFileEnv, func(t *testing.T) { + tmpDir := t.TempDir() + // Overwrite to ensure local config is not picked up by tests + overwriteUserConfigDir(t, tmpDir) + + keyPath := filepath.Join(tmpDir, "keys.txt") + assert.NoError(t, os.WriteFile(keyPath, []byte(mockIdentity), 0o644)) + + t.Setenv(SopsAgeKeyFileEnv, keyPath) + + key := &MasterKey{} + got, err := key.loadIdentities() + assert.NoError(t, err) + assert.Len(t, got, 1) + }) + + t.Run(SopsAgeKeyUserConfigPath, func(t *testing.T) { + tmpDir := t.TempDir() + overwriteUserConfigDir(t, tmpDir) + + // We need to use os.UserConfigDir and not tmpDir as it may add a suffix + cfgDir, err := os.UserConfigDir() + assert.NoError(t, err) + keyPath := filepath.Join(cfgDir, SopsAgeKeyUserConfigPath) + assert.True(t, strings.HasPrefix(keyPath, cfgDir)) + + assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700)) + assert.NoError(t, os.WriteFile(keyPath, []byte(mockIdentity), 0o644)) + + got, err := (&MasterKey{}).loadIdentities() + assert.NoError(t, err) + assert.Len(t, got, 1) + }) + + t.Run("no identity", func(t *testing.T) { + tmpDir := t.TempDir() + overwriteUserConfigDir(t, tmpDir) + + got, err := (&MasterKey{}).loadIdentities() + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to open file") + assert.Nil(t, got) + }) + + t.Run("multiple identities", func(t *testing.T) { + tmpDir := t.TempDir() + overwriteUserConfigDir(t, tmpDir) + + // We need to use os.UserConfigDir and not tmpDir as it may add a suffix + cfgDir, err := os.UserConfigDir() + assert.NoError(t, err) + keyPath1 := filepath.Join(cfgDir, SopsAgeKeyUserConfigPath) + assert.True(t, strings.HasPrefix(keyPath1, cfgDir)) + + assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath1), 0o700)) + assert.NoError(t, os.WriteFile(keyPath1, []byte(mockIdentity), 0o644)) + + keyPath2 := filepath.Join(tmpDir, "keys.txt") + assert.NoError(t, os.WriteFile(keyPath2, []byte(mockOtherIdentity), 0o644)) + t.Setenv(SopsAgeKeyFileEnv, keyPath2) + + got, err := (&MasterKey{}).loadIdentities() + assert.NoError(t, err) + assert.Len(t, got, 2) + }) + + t.Run("parsing error", func(t *testing.T) { + tmpDir := t.TempDir() + // Overwrite to ensure local config is not picked up by tests + overwriteUserConfigDir(t, tmpDir) + + t.Setenv(SopsAgeKeyEnv, "invalid") + + key := &MasterKey{} + got, err := key.loadIdentities() + assert.Error(t, err) + assert.ErrorContains(t, err, fmt.Sprintf("failed to parse '%s' age identities", SopsAgeKeyEnv)) + assert.Nil(t, got) + }) +} - decryptedKey, err := keys[0].Decrypt() - assert.NoError(err) - assert.Equal(dataKey, decryptedKey) +// overwriteUserConfigDir sets the user config directory based on the +// os.UserConfigDir logic. +func overwriteUserConfigDir(t *testing.T, path string) { + switch runtime.GOOS { + case "windows": + t.Setenv("AppData", path) + case "darwin", "ios": // This adds "/Library/Application Support" as a suffix to $HOME + t.Setenv("HOME", path) + case "plan9": // This adds "/lib" as a suffix to $home + t.Setenv("home", path) + default: // Unix + t.Setenv("XDG_CONFIG_HOME", path) + } }