diff --git a/cmd/sops/encrypt.go b/cmd/sops/encrypt.go index cabba0fd42..2d0db6796a 100644 --- a/cmd/sops/encrypt.go +++ b/cmd/sops/encrypt.go @@ -22,6 +22,7 @@ type encryptConfig struct { EncryptedRegex string UnencryptedCommentRegex string EncryptedCommentRegex string + CommentEncryption string MACOnlyEncrypted bool KeyGroups []sops.KeyGroup GroupThreshold int @@ -85,6 +86,7 @@ func metadataFromEncryptionConfig(config encryptConfig) sops.Metadata { EncryptedRegex: config.EncryptedRegex, UnencryptedCommentRegex: config.UnencryptedCommentRegex, EncryptedCommentRegex: config.EncryptedCommentRegex, + CommentEncryption: config.CommentEncryption, MACOnlyEncrypted: config.MACOnlyEncrypted, Version: version.Version, ShamirThreshold: config.GroupThreshold, diff --git a/cmd/sops/main.go b/cmd/sops/main.go index e590e1c82b..7bf0cc8984 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -1842,6 +1842,10 @@ func main() { Name: "encrypted-comment-regex", Usage: "set the encrypted comment suffix. When specified, only keys that have comment matching the regex will be encrypted.", }, + cli.StringFlag{ + Name: "comment-encryption", + Usage: "control comment encryption independently of value encryption. One of \"plaintext\" or \"encrypted\". Cannot be used together with encrypted-comment-regex or unencrypted-comment-regex.", + }, cli.StringFlag{ Name: "config", Usage: "path to sops' config file. If set, sops will not search for the config file recursively.", @@ -2116,6 +2120,7 @@ func getEncryptConfig(c *cli.Context, fileName string, inputStore common.Store, unencryptedRegex := c.String("unencrypted-regex") encryptedCommentRegex := c.String("encrypted-comment-regex") unencryptedCommentRegex := c.String("unencrypted-comment-regex") + commentEncryption := c.String("comment-encryption") macOnlyEncrypted := c.GlobalBool("mac-only-encrypted") var err error if optionalConfig == nil { @@ -2144,6 +2149,9 @@ func getEncryptConfig(c *cli.Context, fileName string, inputStore common.Store, if unencryptedCommentRegex == "" { unencryptedCommentRegex = optionalConfig.UnencryptedCommentRegex } + if commentEncryption == "" { + commentEncryption = optionalConfig.CommentEncryption + } if !macOnlyEncrypted { macOnlyEncrypted = optionalConfig.MACOnlyEncrypted } @@ -2171,6 +2179,9 @@ func getEncryptConfig(c *cli.Context, fileName string, inputStore common.Store, if encryptedCommentRegex != "" { log.Warn(fmt.Sprintf("Using an encrypted comment regex does not make sense with the input store (the %s store never produces comments) and will be ignored.", inputStore.Name())) } + if commentEncryption != "" { + log.Warn(fmt.Sprintf("Using comment-encryption does not make sense with the input store (the %s store never produces comments) and will be ignored.", inputStore.Name())) + } // Do not warn about unencryptedCommentRegex and macOnlyEncrypted since they cannot have any effect. unencryptedSuffix = "" encryptedSuffix = "" @@ -2178,6 +2189,7 @@ func getEncryptConfig(c *cli.Context, fileName string, inputStore common.Store, unencryptedRegex = "" encryptedCommentRegex = "" unencryptedCommentRegex = "" + commentEncryption = "" macOnlyEncrypted = false } @@ -2205,6 +2217,10 @@ func getEncryptConfig(c *cli.Context, fileName string, inputStore common.Store, return encryptConfig{}, common.NewExitError("Error: cannot use more than one of encrypted_suffix, unencrypted_suffix, encrypted_regex, unencrypted_regex, encrypted_comment_regex, or unencrypted_comment_regex in the same file", codes.ErrorConflictingParameters) } + if err := sops.ValidateCommentEncryption(commentEncryption, encryptedCommentRegex, unencryptedCommentRegex); err != nil { + return encryptConfig{}, common.NewExitError(fmt.Sprintf("Error: %s", err), codes.ErrorConflictingParameters) + } + // only supply the default UnencryptedSuffix when EncryptedSuffix, EncryptedRegex, and others are not provided if cryptRuleCount == 0 && !isSingleValueStore { unencryptedSuffix = sops.DefaultUnencryptedSuffix @@ -2229,6 +2245,7 @@ func getEncryptConfig(c *cli.Context, fileName string, inputStore common.Store, EncryptedRegex: encryptedRegex, UnencryptedCommentRegex: unencryptedCommentRegex, EncryptedCommentRegex: encryptedCommentRegex, + CommentEncryption: commentEncryption, MACOnlyEncrypted: macOnlyEncrypted, KeyGroups: groups, GroupThreshold: threshold, diff --git a/config/config.go b/config/config.go index 511df1bc15..1377065fb9 100644 --- a/config/config.go +++ b/config/config.go @@ -193,6 +193,7 @@ type creationRule struct { EncryptedRegex string `yaml:"encrypted_regex"` UnencryptedCommentRegex string `yaml:"unencrypted_comment_regex"` EncryptedCommentRegex string `yaml:"encrypted_comment_regex"` + CommentEncryption string `yaml:"comment_encryption"` MACOnlyEncrypted bool `yaml:"mac_only_encrypted"` } @@ -285,6 +286,7 @@ type Config struct { EncryptedRegex string UnencryptedCommentRegex string EncryptedCommentRegex string + CommentEncryption string MACOnlyEncrypted bool Destination publish.Destination OmitExtensions bool @@ -489,6 +491,14 @@ func configFromRule(rule *creationRule, kmsEncryptionContext map[string]*string) return nil, fmt.Errorf("error loading config: cannot use more than one of encrypted_suffix, unencrypted_suffix, encrypted_regex, unencrypted_regex, encrypted_comment_regex, or unencrypted_comment_regex for the same rule") } + // comment_encryption is a separate, orthogonal setting: it does not participate in the + // six-way mutual exclusion above (it stays combinable with the four value-only + // selectors), but it directly conflicts with encrypted_comment_regex/ + // unencrypted_comment_regex, which also decide comment encryption. + if err := sops.ValidateCommentEncryption(rule.CommentEncryption, rule.EncryptedCommentRegex, rule.UnencryptedCommentRegex); err != nil { + return nil, fmt.Errorf("error loading config: %s", err) + } + groups, err := getKeyGroupsFromCreationRule(rule, kmsEncryptionContext) if err != nil { return nil, err @@ -503,6 +513,7 @@ func configFromRule(rule *creationRule, kmsEncryptionContext map[string]*string) EncryptedRegex: rule.EncryptedRegex, UnencryptedCommentRegex: rule.UnencryptedCommentRegex, EncryptedCommentRegex: rule.EncryptedCommentRegex, + CommentEncryption: rule.CommentEncryption, MACOnlyEncrypted: rule.MACOnlyEncrypted, }, nil } diff --git a/config/config_test.go b/config/config_test.go index 04bed7f564..90e4b49e6b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -336,6 +336,48 @@ creation_rules: unencrypted_comment_regex: "sops:dec" `) +var sampleConfigWithCommentEncryptionPlaintext = []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + pgp: "2" + comment_encryption: plaintext + `) + +var sampleConfigWithCommentEncryptionEncrypted = []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + pgp: "2" + comment_encryption: encrypted + `) + +var sampleConfigWithCommentEncryptionInvalidValue = []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + pgp: "2" + comment_encryption: sometimes + `) + +var sampleConfigWithCommentEncryptionAndEncryptedCommentRegex = []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + pgp: "2" + comment_encryption: plaintext + encrypted_comment_regex: "sops:enc" + `) + +var sampleConfigWithCommentEncryptionAndUnencryptedCommentRegex = []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + pgp: "2" + comment_encryption: plaintext + unencrypted_comment_regex: "sops:dec" + `) + var sampleConfigWithInvalidParameters = []byte(` creation_rules: - path_regex: foobar* @@ -699,6 +741,91 @@ func TestLoadConfigFileWithEncryptedCommentRegex(t *testing.T) { assert.Equal(t, "sops:enc", conf.EncryptedCommentRegex) } +func TestLoadConfigFileWithCommentEncryptionPlaintext(t *testing.T) { + conf, err := parseCreationRuleForFile(parseConfigFile(sampleConfigWithCommentEncryptionPlaintext, t), "/conf/path", "barbar", nil) + assert.Equal(t, nil, err) + assert.Equal(t, "plaintext", conf.CommentEncryption) +} + +func TestLoadConfigFileWithCommentEncryptionEncrypted(t *testing.T) { + conf, err := parseCreationRuleForFile(parseConfigFile(sampleConfigWithCommentEncryptionEncrypted, t), "/conf/path", "barbar", nil) + assert.Equal(t, nil, err) + assert.Equal(t, "encrypted", conf.CommentEncryption) +} + +func TestLoadConfigFileWithCommentEncryptionInvalidValue(t *testing.T) { + _, err := parseCreationRuleForFile(parseConfigFile(sampleConfigWithCommentEncryptionInvalidValue, t), "/conf/path", "barbar", nil) + assert.NotNil(t, err) +} + +// TestCommentEncryptionCompatibilityMatrix pins down the full compatibility surface: +// +// Existing six selectors +// │ +// └── remain mutually exclusive and unchanged +// +// New comment_encryption +// │ +// ├── compatible with: +// │ encrypted_regex, unencrypted_regex, encrypted_suffix, unencrypted_suffix +// │ +// └── incompatible with: +// encrypted_comment_regex, unencrypted_comment_regex +// +// comment_encryption controls comments only, so it stays combinable with the four value-only +// selectors, but conflicts with encrypted_comment_regex/unencrypted_comment_regex, which also +// decide comment encryption. +func TestCommentEncryptionCompatibilityMatrix(t *testing.T) { + compatible := map[string][]byte{ + "unencrypted_regex": []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + comment_encryption: plaintext + unencrypted_regex: "^dec:" +`), + "encrypted_regex": []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + comment_encryption: plaintext + encrypted_regex: "^enc:" +`), + "unencrypted_suffix": []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + comment_encryption: plaintext + unencrypted_suffix: "_unencrypted" +`), + "encrypted_suffix": []byte(` +creation_rules: + - path_regex: barbar* + kms: "1" + comment_encryption: plaintext + encrypted_suffix: "_enc" +`), + } + for name, cfg := range compatible { + t.Run("compatible/"+name, func(t *testing.T) { + conf, err := parseCreationRuleForFile(parseConfigFile(cfg, t), "/conf/path", "barbar", nil) + assert.Nil(t, err, "comment_encryption + %s should be allowed", name) + assert.Equal(t, "plaintext", conf.CommentEncryption) + }) + } + + incompatible := map[string][]byte{ + "encrypted_comment_regex": sampleConfigWithCommentEncryptionAndEncryptedCommentRegex, + "unencrypted_comment_regex": sampleConfigWithCommentEncryptionAndUnencryptedCommentRegex, + } + for name, cfg := range incompatible { + t.Run("incompatible/"+name, func(t *testing.T) { + _, err := parseCreationRuleForFile(parseConfigFile(cfg, t), "/conf/path", "barbar", nil) + assert.NotNil(t, err, "comment_encryption + %s should be rejected", name) + }) + } +} + func TestLoadConfigFileWithInvalidParameters(t *testing.T) { _, err := parseCreationRuleForFile(parseConfigFile(sampleConfigWithInvalidParameters, t), "/conf/path", "foobar", nil) assert.NotNil(t, err) diff --git a/sops.go b/sops.go index 95d2147ae3..caa07a39a9 100644 --- a/sops.go +++ b/sops.go @@ -63,6 +63,32 @@ import ( // DefaultUnencryptedSuffix is the default suffix a TreeItem key has to end with for sops to leave its Value unencrypted const DefaultUnencryptedSuffix = "_unencrypted" +// CommentEncryptionPlaintext is the Metadata.CommentEncryption value that forces every comment to +// remain unencrypted, regardless of how associated values are encrypted. +const CommentEncryptionPlaintext = "plaintext" + +// CommentEncryptionEncrypted is the Metadata.CommentEncryption value that forces every comment to +// be encrypted, regardless of how associated values are encrypted. +const CommentEncryptionEncrypted = "encrypted" + +// ValidateCommentEncryption checks a would-be CommentEncryption value against the rule shared by +// every place that accepts one (the config file parser, the file-metadata loader, and the CLI +// flag): it must be empty, CommentEncryptionPlaintext, or CommentEncryptionEncrypted, and it must +// not be combined with an EncryptedCommentRegex/UnencryptedCommentRegex value, since both +// mechanisms would then be deciding comment encryption at once. Returns nil when commentEncryption +// is empty (the feature is opt-in) or valid and non-conflicting. +func ValidateCommentEncryption(commentEncryption, encryptedCommentRegex, unencryptedCommentRegex string) error { + switch commentEncryption { + case "", CommentEncryptionPlaintext, CommentEncryptionEncrypted: + default: + return fmt.Errorf("invalid comment_encryption value %q, must be %q or %q", commentEncryption, CommentEncryptionPlaintext, CommentEncryptionEncrypted) + } + if commentEncryption != "" && (encryptedCommentRegex != "" || unencryptedCommentRegex != "") { + return fmt.Errorf("cannot use comment_encryption together with encrypted_comment_regex or unencrypted_comment_regex") + } + return nil +} + var DefaultDecryptionOrder = []string{age.KeyTypeIdentifier, pgp.KeyTypeIdentifier} type sopsError string @@ -435,6 +461,12 @@ func (branch TreeBranch) walkBranch(in TreeBranch, path []string, commentsStack } func (tree Tree) shouldBeEncrypted(path []string, commentsStack [][]string, isComment bool) bool { + if isComment && tree.Metadata.CommentEncryption != "" { + // CommentEncryption is an orthogonal, opt-in override: when set, it alone decides + // whether a comment is encrypted, independently of the six value/comment selectors + // below. It never affects non-comment nodes, and it has no effect at all unless set. + return tree.Metadata.CommentEncryption == CommentEncryptionEncrypted + } encrypted := true if tree.Metadata.UnencryptedSuffix != "" { for _, v := range path { @@ -551,7 +583,7 @@ func (tree Tree) Encrypt(key []byte, cipher Cipher) (string, error) { if err != nil { return nil, fmt.Errorf("Could not encrypt value: %s", err) } - if ok && tree.Metadata.UnencryptedCommentRegex != "" { + if ok && tree.Metadata.CommentEncryption == "" && tree.Metadata.UnencryptedCommentRegex != "" { // If an encrypted comment matches tree.Metadata.UnencryptedCommentRegex, decryption will fail // as the MAC does not match, and the commented value will not be decrypted. // Note that cipher.Encrypt() returns a string, but we stored the result in an interface{} @@ -672,13 +704,17 @@ func (tree *Tree) GenerateDataKeyWithKeyServices(svcs []keyservice.KeyServiceCli // Metadata holds information about a file encrypted by sops type Metadata struct { - LastModified time.Time - UnencryptedSuffix string - EncryptedSuffix string - UnencryptedRegex string - EncryptedRegex string - UnencryptedCommentRegex string - EncryptedCommentRegex string + LastModified time.Time + UnencryptedSuffix string + EncryptedSuffix string + UnencryptedRegex string + EncryptedRegex string + UnencryptedCommentRegex string + EncryptedCommentRegex string + // CommentEncryption, when non-empty ("plaintext" or "encrypted"), decides comment + // encryption on its own, independently of the six selectors above. It is opt-in: + // leaving it empty preserves prior behavior exactly. + CommentEncryption string MessageAuthenticationCode string MACOnlyEncrypted bool Version string diff --git a/sops_test.go b/sops_test.go index bbdc7aaf26..6af34081cf 100644 --- a/sops_test.go +++ b/sops_test.go @@ -636,6 +636,134 @@ func TestUnencryptedCommentRegexFail(t *testing.T) { assert.ErrorContains(t, err, "Encrypted comment \"ENC:sops:noenc\" matches UnencryptedCommentRegex!") } +// TestCommentEncryptionPlaintextIndependentOfValueRegex reproduces the +// motivating scenario: a value policy (UnencryptedRegex) decides which +// values are encrypted, while CommentEncryption independently forces every +// comment to stay plaintext, including the comment directly preceding an +// encrypted value. +func TestCommentEncryptionPlaintextIndependentOfValueRegex(t *testing.T) { + branches := TreeBranches{ + TreeBranch{ + TreeItem{ + Key: "LOG_LEVEL", + Value: "DEBUG", + }, + TreeItem{ + Key: Comment{Value: " required | default: db"}, + Value: nil, + }, + TreeItem{ + Key: "DATABASE_URL", + Value: "postgres://user:password@localhost/mydb", + }, + TreeItem{ + Key: "JWT_SECRET", + Value: "secret", + }, + }, + } + tree := Tree{ + Branches: branches, + Metadata: Metadata{ + UnencryptedRegex: "^LOG_LEVEL$", + CommentEncryption: CommentEncryptionPlaintext, + }, + } + cipher := reverseCipher{} + _, err := tree.Encrypt(bytes.Repeat([]byte("f"), 32), cipher) + assert.NoError(t, err) + + got := tree.Branches[0] + assert.Equal(t, "DEBUG", got[0].Value, "LOG_LEVEL matches UnencryptedRegex and must stay plaintext") + assert.Equal(t, Comment{Value: " required | default: db"}, got[1].Key, "comment must stay plaintext even though the following value is encrypted") + assert.Equal(t, reverse("postgres://user:password@localhost/mydb"), got[2].Value, "DATABASE_URL does not match UnencryptedRegex and must be encrypted") + assert.Equal(t, reverse("secret"), got[3].Value, "JWT_SECRET does not match UnencryptedRegex and must be encrypted") + + _, err = tree.Decrypt(bytes.Repeat([]byte("f"), 32), cipher) + assert.NoError(t, err) + got = tree.Branches[0] + assert.Equal(t, "DEBUG", got[0].Value) + assert.Equal(t, Comment{Value: " required | default: db"}, got[1].Key) + assert.Equal(t, "postgres://user:password@localhost/mydb", got[2].Value) + assert.Equal(t, "secret", got[3].Value) +} + +// TestCommentEncryptionEncryptedIndependentOfUnencryptedSuffix proves +// independence in the other direction: CommentEncryption forces comments to +// be encrypted even where UnencryptedSuffix would otherwise leave them (and +// their sibling value) plaintext, while the sibling value's own encryption +// decision is left untouched by CommentEncryption. +func TestCommentEncryptionEncryptedIndependentOfUnencryptedSuffix(t *testing.T) { + branches := TreeBranches{ + TreeBranch{ + TreeItem{ + Key: "bar_unencrypted", + Value: TreeBranch{ + TreeItem{ + Key: Comment{Value: "secret note"}, + Value: nil, + }, + TreeItem{ + Key: "foo", + Value: "bar", + }, + }, + }, + }, + } + tree := Tree{ + Branches: branches, + Metadata: Metadata{ + UnencryptedSuffix: "_unencrypted", + CommentEncryption: CommentEncryptionEncrypted, + }, + } + cipher := reverseCipher{} + _, err := tree.Encrypt(bytes.Repeat([]byte("f"), 32), cipher) + assert.NoError(t, err) + + inner := tree.Branches[0][0].Value.(TreeBranch) + assert.Equal(t, Comment{Value: reverse("secret note")}, inner[0].Key, "comment must be encrypted despite UnencryptedSuffix matching its path") + assert.Equal(t, "bar", inner[1].Value, "sibling value must stay plaintext per UnencryptedSuffix, unaffected by comment_encryption") + + _, err = tree.Decrypt(bytes.Repeat([]byte("f"), 32), cipher) + assert.NoError(t, err) + inner = tree.Branches[0][0].Value.(TreeBranch) + assert.Equal(t, Comment{Value: "secret note"}, inner[0].Key) + assert.Equal(t, "bar", inner[1].Value) +} + +// TestCommentEncryptionUnsetPreservesLegacyBehavior guards against +// regressions: when CommentEncryption is left unset (the default/zero +// value), comment encryption must be decided exactly as before, from +// EncryptedCommentRegex/UnencryptedCommentRegex, with no influence from the +// new field. +func TestCommentEncryptionUnsetPreservesLegacyBehavior(t *testing.T) { + branches := TreeBranches{ + TreeBranch{ + TreeItem{ + Key: Comment{Value: "sops:enc"}, + Value: nil, + }, + TreeItem{ + Key: "foo", + Value: "bar", + }, + }, + } + tree := Tree{Branches: branches, Metadata: Metadata{EncryptedCommentRegex: "sops:enc"}} + assert.Equal(t, "", tree.Metadata.CommentEncryption, "field must default to the empty string when not configured") + + cipher := reverseCipher{} + _, err := tree.Encrypt(bytes.Repeat([]byte("f"), 32), cipher) + assert.NoError(t, err) + // The comment line itself matching EncryptedCommentRegex is the special + // "do not encrypt the triggering line" case, unchanged from before this + // feature existed. + assert.Equal(t, Comment{Value: "sops:enc"}, tree.Branches[0][0].Key) + assert.Equal(t, reverse("bar"), tree.Branches[0][1].Value) +} + type MockCipher struct{} func (m MockCipher) Encrypt(value interface{}, key []byte, path string) (string, error) { diff --git a/stores/metadata_test.go b/stores/metadata_test.go index d8ed677b31..598a21efce 100644 --- a/stores/metadata_test.go +++ b/stores/metadata_test.go @@ -1039,3 +1039,74 @@ func TestSerializeMetadata(t *testing.T) { } assert.Equal(t, "example-tenant", contextValue, "KMS encryption context value must be a string, not a pointer") } + +// TestCommentEncryptionRoundTrip proves CommentEncryption survives being written to and read +// back from an encrypted file's persisted "sops" metadata block, for both enum values. +func TestCommentEncryptionRoundTrip(t *testing.T) { + for _, commentEncryption := range []string{sops.CommentEncryptionPlaintext, sops.CommentEncryptionEncrypted} { + t.Run(commentEncryption, func(t *testing.T) { + tree := sops.Tree{ + Branches: sops.TreeBranches{sops.TreeBranch{}}, + Metadata: sops.Metadata{ + LastModified: time.Unix(0, 0).UTC(), + Version: "3.0.0", + CommentEncryption: commentEncryption, + KeyGroups: []sops.KeyGroup{ + { + &pgp.MasterKey{ + Fingerprint: "1234", + EncryptedKey: "ABCD", + CreationDate: time.Unix(0, 0).UTC(), + }, + }, + }, + }, + } + branches, err := SerializeMetadata(tree, MetadataOpts{Flatten: MetadataFlattenFull}) + assert.Nil(t, err) + + _, metadata, err := ExtractMetadata(branches, MetadataOpts{Flatten: MetadataFlattenFull}) + assert.Nil(t, err) + assert.Equal(t, commentEncryption, metadata.CommentEncryption) + }) + } +} + +// TestCommentEncryptionConflictsWithCommentRegex mirrors config.configFromRule's validation: +// a persisted file that combines CommentEncryption with EncryptedCommentRegex or +// UnencryptedCommentRegex is rejected, since both mechanisms decide comment encryption. +func TestCommentEncryptionConflictsWithCommentRegex(t *testing.T) { + baseTree := func(m sops.Metadata) sops.Tree { + m.LastModified = time.Unix(0, 0).UTC() + m.Version = "3.0.0" + m.KeyGroups = []sops.KeyGroup{ + { + &pgp.MasterKey{ + Fingerprint: "1234", + EncryptedKey: "ABCD", + CreationDate: time.Unix(0, 0).UTC(), + }, + }, + } + return sops.Tree{Branches: sops.TreeBranches{sops.TreeBranch{}}, Metadata: m} + } + + for name, metadata := range map[string]sops.Metadata{ + "EncryptedCommentRegex": { + CommentEncryption: sops.CommentEncryptionPlaintext, + EncryptedCommentRegex: "sops:enc", + }, + "UnencryptedCommentRegex": { + CommentEncryption: sops.CommentEncryptionPlaintext, + UnencryptedCommentRegex: "sops:dec", + }, + } { + t.Run(name, func(t *testing.T) { + branches, err := SerializeMetadata(baseTree(metadata), MetadataOpts{Flatten: MetadataFlattenFull}) + assert.Nil(t, err) + + _, _, err = ExtractMetadata(branches, MetadataOpts{Flatten: MetadataFlattenFull}) + assert.NotNil(t, err) + }) + } +} diff --git a/stores/stores.go b/stores/stores.go index b1b496dc76..2e4bf7ba3d 100644 --- a/stores/stores.go +++ b/stores/stores.go @@ -52,6 +52,7 @@ type metadata struct { EncryptedRegex string `mapstructure:"encrypted_regex,omitempty"` UnencryptedCommentRegex string `mapstructure:"unencrypted_comment_regex,omitempty"` EncryptedCommentRegex string `mapstructure:"encrypted_comment_regex,omitempty"` + CommentEncryption string `mapstructure:"comment_encryption,omitempty"` MACOnlyEncrypted bool `mapstructure:"mac_only_encrypted,omitempty"` Version string `mapstructure:"version"` } @@ -125,6 +126,7 @@ func metadataFromInternal(sopsMetadata sops.Metadata) metadata { m.EncryptedRegex = sopsMetadata.EncryptedRegex m.UnencryptedCommentRegex = sopsMetadata.UnencryptedCommentRegex m.EncryptedCommentRegex = sopsMetadata.EncryptedCommentRegex + m.CommentEncryption = sopsMetadata.CommentEncryption m.MessageAuthenticationCode = sopsMetadata.MessageAuthenticationCode m.MACOnlyEncrypted = sopsMetadata.MACOnlyEncrypted m.Version = sopsMetadata.Version @@ -293,6 +295,13 @@ func (m *metadata) ToInternal() (sops.Metadata, error) { return sops.Metadata{}, fmt.Errorf("Cannot use more than one of encrypted_suffix, unencrypted_suffix, encrypted_regex, unencrypted_regex, encrypted_comment_regex, or unencrypted_comment_regex in the same file") } + // comment_encryption is orthogonal to the six selectors above (it stays out of + // cryptRuleCount), but it conflicts directly with encrypted_comment_regex/ + // unencrypted_comment_regex, which also decide comment encryption. + if err := sops.ValidateCommentEncryption(m.CommentEncryption, m.EncryptedCommentRegex, m.UnencryptedCommentRegex); err != nil { + return sops.Metadata{}, err + } + if cryptRuleCount == 0 { m.UnencryptedSuffix = sops.DefaultUnencryptedSuffix } @@ -307,6 +316,7 @@ func (m *metadata) ToInternal() (sops.Metadata, error) { EncryptedRegex: m.EncryptedRegex, UnencryptedCommentRegex: m.UnencryptedCommentRegex, EncryptedCommentRegex: m.EncryptedCommentRegex, + CommentEncryption: m.CommentEncryption, MACOnlyEncrypted: m.MACOnlyEncrypted, LastModified: lastModified, }, nil