Repository: getsops/sops (language: python)

Issue:
Sort masterkeys to try offline decrypt methods first
This is to address #305. Sort master keys so PGP/age keys appear before the others and being used first.


Discussion / hints:
@ajvb Hi, sorry to bother you, but could you maybe take a look please? Hopefully it's a quick win :)
This is a sensible change, I think. It would be great if you could rebase your work and sign-off your commit. Thanks! :bow: 
@hiddeco Ok, rebased and signed-off. Great to see that the project is back to life! 🎉 
I have to check this against the history from the issue, which is why I moved it one milestone further.

But do know it’s on the (now active) backlog. 🍒
https://github.com/getsops/sops/issues/305#issuecomment-1793766331 might be preferable to this solution, since it is more flexible.

Fix patch:
diff --git a/README.rst b/README.rst
index c47ac6e63..3c808f84e 100644
--- a/README.rst
+++ b/README.rst
@@ -170,6 +170,11 @@ Given that, the only command a SOPS user needs is:
 encrypted if modified, and saved back to its original location. All of these
 steps, apart from the actual editing, are transparent to the user.
 
+The order in which available decryption methods are tried can be specified with
+``--decryption-order`` option or **SOPS_DECRYPTION_ORDER** environment variable
+as a comma separated list. The default order is ``age,pgp``. Offline methods are
+tried first and then the remaining ones.
+
 Test with the dev PGP key
 ~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/age/keysource.go b/age/keysource.go
index 9ee6be491..83bdbe0a6 100644
--- a/age/keysource.go
+++ b/age/keysource.go
@@ -12,8 +12,9 @@ import (
 
 	"filippo.io/age"
 	"filippo.io/age/armor"
-	"github.com/getsops/sops/v3/logging"
 	"github.com/sirupsen/logrus"
+
+	"github.com/getsops/sops/v3/logging"
 )
 
 const (
@@ -28,6 +29,8 @@ const (
 	SopsAgeKeyUserConfigPath = "sops/age/keys.txt"
 	// On macOS, os.UserConfigDir() ignores XDG_CONFIG_HOME. So we handle that manually.
 	xdgConfigHome = "XDG_CONFIG_HOME"
+	// KeyTypeIdentifier is the string used to identify an age MasterKey.
+	KeyTypeIdentifier = "age"
 )
 
 // log is the global logger for any age MasterKey.
@@ -225,6 +228,11 @@ func (key *MasterKey) ToMap() map[string]interface{} {
 	return out
 }
 
+// TypeToIdentifier returns the string identifier for the MasterKey type.
+func (key *MasterKey) TypeToIdentifier() string {
+	return KeyTypeIdentifier
+}
+
 func getUserConfigDir() (string, error) {
 	if runtime.GOOS == "darwin" {
 		if userConfigDir, ok := os.LookupEnv(xdgConfigHome); ok && userConfigDir != "" {
diff --git a/azkv/keysource.go b/azkv/keysource.go
index 0646ac561..27e28cca5 100644
--- a/azkv/keysource.go
+++ b/azkv/keysource.go
@@ -22,6 +22,11 @@ import (
 	"github.com/getsops/sops/v3/logging"
 )
 
+const (
+	// KeyTypeIdentifier is the string used to identify an Azure Key Vault MasterKey.
+	KeyTypeIdentifier = "azure_kv"
+)
+
 var (
 	// log is the global logger for any Azure Key Vault MasterKey.
 	log *logrus.Logger
@@ -215,6 +220,11 @@ func (key MasterKey) ToMap() map[string]interface{} {
 	return out
 }
 
+// TypeToIdentifier returns the string identifier for the MasterKey type.
+func (key *MasterKey) TypeToIdentifier() string {
+	return KeyTypeIdentifier
+}
+
 // getTokenCredential returns the tokenCredential of the MasterKey, or
 // azidentity.NewDefaultAzureCredential.
 func (key *MasterKey) getTokenCredential() (azcore.TokenCredential, error) {
diff --git a/cmd/sops/codes/codes.go b/cmd/sops/codes/codes.go
index 7aea67e80..d62605bd0 100644
--- a/cmd/sops/codes/codes.go
+++ b/cmd/sops/codes/codes.go
@@ -25,6 +25,7 @@ const (
 	NoFileSpecified                        int = 100
 	CouldNotRetrieveKey                    int = 128
 	NoEncryptionKeyFound                   int = 111
+	DuplicateDecryptionKeyType             int = 112
 	FileHasNotBeenModified                 int = 200
 	NoEditorFound                          int = 201
 	FailedToCompareVersions                int = 202
diff --git a/cmd/sops/common/common.go b/cmd/sops/common/common.go
index 4960e6e47..eecd34423 100644
--- a/cmd/sops/common/common.go
+++ b/cmd/sops/common/common.go
@@ -72,6 +72,8 @@ type DecryptTreeOpts struct {
 	Tree *sops.Tree
 	// KeyServices are the key services to be used for decryption of the data key
 	KeyServices []keyservice.KeyServiceClient
+	// DecryptionOrder is the order in which available decryption methods are tried
+	DecryptionOrder []string
 	// IgnoreMac is whether or not to ignore the Message Authentication Code included in the SOPS tree
 	IgnoreMac bool
 	// Cipher is the cryptographic cipher to use to decrypt the values inside the tree
@@ -80,7 +82,7 @@ type DecryptTreeOpts struct {
 
 // DecryptTree decrypts the tree passed in through the DecryptTreeOpts and additionally returns the decrypted data key
 func DecryptTree(opts DecryptTreeOpts) (dataKey []byte, err error) {
-	dataKey, err = opts.Tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices)
+	dataKey, err = opts.Tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices, opts.DecryptionOrder)
 	if err != nil {
 		return nil, NewExitError(err, codes.CouldNotRetrieveKey)
 	}
@@ -222,11 +224,12 @@ func GetKMSKeyWithEncryptionCtx(tree *sops.Tree) (keyGroupIndex int, keyIndex in
 
 // GenericDecryptOpts represents decryption options and config
 type GenericDecryptOpts struct {
-	Cipher      sops.Cipher
-	InputStore  sops.Store
-	InputPath   string
-	IgnoreMAC   bool
-	KeyServices []keyservice.KeyServiceClient
+	Cipher          sops.Cipher
+	InputStore      sops.Store
+	InputPath       string
+	IgnoreMAC       bool
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
 }
 
 // LoadEncryptedFileWithBugFixes is a wrapper around LoadEncryptedFile which includes
diff --git a/cmd/sops/decrypt.go b/cmd/sops/decrypt.go
index 037e77f04..09d92106a 100644
--- a/cmd/sops/decrypt.go
+++ b/cmd/sops/decrypt.go
@@ -15,13 +15,14 @@ const notBinaryHint = ("This is likely not an encrypted binary file?" +
 	" If not, use --output-type to select the correct output type.")
 
 type decryptOpts struct {
-	Cipher      sops.Cipher
-	InputStore  sops.Store
-	OutputStore sops.Store
-	InputPath   string
-	IgnoreMAC   bool
-	Extract     []interface{}
-	KeyServices []keyservice.KeyServiceClient
+	Cipher          sops.Cipher
+	InputStore      sops.Store
+	OutputStore     sops.Store
+	InputPath       string
+	IgnoreMAC       bool
+	Extract         []interface{}
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
 }
 
 func decrypt(opts decryptOpts) (decryptedFile []byte, err error) {
@@ -37,10 +38,11 @@ func decrypt(opts decryptOpts) (decryptedFile []byte, err error) {
 	}
 
 	_, err = common.DecryptTree(common.DecryptTreeOpts{
-		Cipher:      opts.Cipher,
-		IgnoreMac:   opts.IgnoreMAC,
-		Tree:        tree,
-		KeyServices: opts.KeyServices,
+		Cipher:          opts.Cipher,
+		IgnoreMac:       opts.IgnoreMAC,
+		Tree:            tree,
+		KeyServices:     opts.KeyServices,
+		DecryptionOrder: opts.DecryptionOrder,
 	})
 	if err != nil {
 		return nil, err
diff --git a/cmd/sops/edit.go b/cmd/sops/edit.go
index d71906f1a..139d6147a 100644
--- a/cmd/sops/edit.go
+++ b/cmd/sops/edit.go
@@ -20,13 +20,14 @@ import (
 )
 
 type editOpts struct {
-	Cipher         sops.Cipher
-	InputStore     common.Store
-	OutputStore    common.Store
-	InputPath      string
-	IgnoreMAC      bool
-	KeyServices    []keyservice.KeyServiceClient
-	ShowMasterKeys bool
+	Cipher          sops.Cipher
+	InputStore      common.Store
+	OutputStore     common.Store
+	InputPath       string
+	IgnoreMAC       bool
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
+	ShowMasterKeys  bool
 }
 
 type editExampleOpts struct {
@@ -96,7 +97,11 @@ func edit(opts editOpts) ([]byte, error) {
 	}
 	// Decrypt the file
 	dataKey, err := common.DecryptTree(common.DecryptTreeOpts{
-		Cipher: opts.Cipher, IgnoreMac: opts.IgnoreMAC, Tree: tree, KeyServices: opts.KeyServices,
+		Cipher:          opts.Cipher,
+		IgnoreMac:       opts.IgnoreMAC,
+		Tree:            tree,
+		KeyServices:     opts.KeyServices,
+		DecryptionOrder: opts.DecryptionOrder,
 	})
 	if err != nil {
 		return nil, err
diff --git a/cmd/sops/main.go b/cmd/sops/main.go
index 26e084c26..c10ca9b4a 100644
--- a/cmd/sops/main.go
+++ b/cmd/sops/main.go
@@ -1,4 +1,4 @@
-package main //import "github.com/getsops/sops/v3/cmd/sops"
+package main // import "github.com/getsops/sops/v3/cmd/sops"
 
 import (
 	"context"
@@ -13,6 +13,11 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/sirupsen/logrus"
+	"github.com/urfave/cli"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+
 	"github.com/getsops/sops/v3"
 	"github.com/getsops/sops/v3/aes"
 	"github.com/getsops/sops/v3/age"
@@ -36,10 +41,6 @@ import (
 	"github.com/getsops/sops/v3/stores/dotenv"
 	"github.com/getsops/sops/v3/stores/json"
 	"github.com/getsops/sops/v3/version"
-	"github.com/sirupsen/logrus"
-	"github.com/urfave/cli"
-	"google.golang.org/grpc"
-	"google.golang.org/grpc/credentials/insecure"
 )
 
 var log *logrus.Logger
@@ -172,13 +173,19 @@ func main() {
 				inputStore := inputStore(c, fileName)
 
 				svcs := keyservices(c)
+
+				order, err := decryptionOrder(c.String("decryption-order"))
+				if err != nil {
+					return toExitError(err)
+				}
 				opts := decryptOpts{
-					OutputStore: &dotenv.Store{},
-					InputStore:  inputStore,
-					InputPath:   fileName,
-					Cipher:      aes.NewCipher(),
-					KeyServices: svcs,
-					IgnoreMAC:   c.Bool("ignore-mac"),
+					OutputStore:     &dotenv.Store{},
+					InputStore:      inputStore,
+					InputPath:       fileName,
+					Cipher:          aes.NewCipher(),
+					KeyServices:     svcs,
+					DecryptionOrder: order,
+					IgnoreMAC:       c.Bool("ignore-mac"),
 				}
 
 				output, err := decrypt(opts)
@@ -241,13 +248,19 @@ func main() {
 				outputStore := outputStore(c, fileName)
 
 				svcs := keyservices(c)
+
+				order, err := decryptionOrder(c.String("decryption-order"))
+				if err != nil {
+					return toExitError(err)
+				}
 				opts := decryptOpts{
-					OutputStore: outputStore,
-					InputStore:  inputStore,
-					InputPath:   fileName,
-					Cipher:      aes.NewCipher(),
-					KeyServices: svcs,
-					IgnoreMAC:   c.Bool("ignore-mac"),
+					OutputStore:     outputStore,
+					InputStore:      inputStore,
+					InputPath:       fileName,
+					Cipher:          aes.NewCipher(),
+					KeyServices:     svcs,
+					DecryptionOrder: order,
+					IgnoreMAC:       c.Bool("ignore-mac"),
 				}
 
 				output, err := decrypt(opts)
@@ -316,21 +329,25 @@ func main() {
 				if info.IsDir() && !c.Bool("recursive") {
 					return fmt.Errorf("can't operate on a directory without --recursive flag.")
 				}
+				order, err := decryptionOrder(c.String("decryption-order"))
+				if err != nil {
+					return toExitError(err)
+				}
 				err = filepath.Walk(path, func(subPath string, info os.FileInfo, err error) error {
 					if err != nil {
 						return toExitError(err)
 					}
 					if !info.IsDir() {
 						err = publishcmd.Run(publishcmd.Opts{
-							ConfigPath:     configPath,
-							InputPath:      subPath,
-							Cipher:         aes.NewCipher(),
-							KeyServices:    keyservices(c),
-							InputStore:     inputStore(c, subPath),
-							Interactive:    !c.Bool("yes"),
-							OmitExtensions: c.Bool("omit-extensions"),
-							Recursive:      c.Bool("recursive"),
-							RootPath:       path,
+							ConfigPath:      configPath,
+							InputPath:       subPath,
+							Cipher:          aes.NewCipher(),
+							KeyServices:     keyservices(c),
+							DecryptionOrder: order,
+							InputStore:      inputStore(c, subPath),
+							Interactive:     !c.Bool("yes"),
+							OmitExtensions:  c.Bool("omit-extensions"),
+							Recursive:       c.Bool("recursive"),
 						})
 						if cliErr, ok := err.(*cli.ExitError); ok && cliErr != nil {
 							return cliErr
@@ -773,6 +790,11 @@ func main() {
 			Name:  "filename-override",
 			Usage: "Use this filename instead of the provided argument for loading configuration, and for determining input type and output type",
 		},
+		cli.StringFlag{
+			Name:   "decryption-order",
+			Usage:  "comma separated list of decryption key types",
+			EnvVar: "SOPS_DECRYPTION_ORDER",
+		},
 	}, keyserviceFlags...)
 
 	app.Action = func(c *cli.Context) error {
@@ -859,6 +881,10 @@ func main() {
 		outputStore := outputStore(c, fileNameOverride)
 		svcs := keyservices(c)
 
+		order, err := decryptionOrder(c.String("decryption-order"))
+		if err != nil {
+			return toExitError(err)
+		}
 		var output []byte
 		if c.Bool("encrypt") {
 			var groups []sops.KeyGroup
@@ -894,13 +920,14 @@ func main() {
 				return common.NewExitError(fmt.Errorf("error parsing --extract path: %s", err), codes.InvalidTreePathFormat)
 			}
 			output, err = decrypt(decryptOpts{
-				OutputStore: outputStore,
-				InputStore:  inputStore,
-				InputPath:   fileName,
-				Cipher:      aes.NewCipher(),
-				Extract:     extract,
-				KeyServices: svcs,
-				IgnoreMAC:   c.Bool("ignore-mac"),
+				OutputStore:     outputStore,
+				InputStore:      inputStore,
+				InputPath:       fileName,
+				Cipher:          aes.NewCipher(),
+				Extract:         extract,
+				KeyServices:     svcs,
+				DecryptionOrder: order,
+				IgnoreMAC:       c.Bool("ignore-mac"),
 			})
 		}
 		if c.Bool("rotate") {
@@ -975,6 +1002,7 @@ func main() {
 				InputPath:        fileName,
 				Cipher:           aes.NewCipher(),
 				KeyServices:      svcs,
+				DecryptionOrder:  order,
 				IgnoreMAC:        c.Bool("ignore-mac"),
 				AddMasterKeys:    addMasterKeys,
 				RemoveMasterKeys: rmMasterKeys,
@@ -994,14 +1022,15 @@ func main() {
 				return toExitError(err)
 			}
 			output, err = set(setOpts{
-				OutputStore: outputStore,
-				InputStore:  inputStore,
-				InputPath:   fileName,
-				Cipher:      aes.NewCipher(),
-				KeyServices: svcs,
-				IgnoreMAC:   c.Bool("ignore-mac"),
-				Value:       value,
-				TreePath:    path,
+				OutputStore:     outputStore,
+				InputStore:      inputStore,
+				InputPath:       fileName,
+				Cipher:          aes.NewCipher(),
+				KeyServices:     svcs,
+				DecryptionOrder: order,
+				IgnoreMAC:       c.Bool("ignore-mac"),
+				Value:           value,
+				TreePath:        path,
 			})
 		}
 
@@ -1010,13 +1039,14 @@ func main() {
 			_, statErr := os.Stat(fileName)
 			fileExists := statErr == nil
 			opts := editOpts{
-				OutputStore:    outputStore,
-				InputStore:     inputStore,
-				InputPath:      fileName,
-				Cipher:         aes.NewCipher(),
-				KeyServices:    svcs,
-				IgnoreMAC:      c.Bool("ignore-mac"),
-				ShowMasterKeys: c.Bool("show-master-keys"),
+				OutputStore:     outputStore,
+				InputStore:      inputStore,
+				InputPath:       fileName,
+				Cipher:          aes.NewCipher(),
+				KeyServices:     svcs,
+				DecryptionOrder: order,
+				IgnoreMAC:       c.Bool("ignore-mac"),
+				ShowMasterKeys:  c.Bool("show-master-keys"),
 			}
 			if fileExists {
 				output, err = edit(opts)
@@ -1351,3 +1381,18 @@ func extractSetArguments(set string) (path []interface{}, valueToInsert interfac
 	}
 	return path, valueToInsert, nil
 }
+
+func decryptionOrder(decryptionOrder string) ([]string, error) {
+	if decryptionOrder == "" {
+		return sops.DefaultDecryptionOrder, nil
+	}
+	orderList := strings.Split(decryptionOrder, ",")
+	unique := make(map[string]struct{})
+	for _, v := range orderList {
+		if _, ok := unique[v]; ok {
+			return nil, common.NewExitError(fmt.Sprintf("Duplicate decryption key type: %s", v), codes.DuplicateDecryptionKeyType)
+		}
+		unique[v] = struct{}{}
+	}
+	return orderList, nil
+}
diff --git a/cmd/sops/rotate.go b/cmd/sops/rotate.go
index 1818adae4..072b558e5 100644
--- a/cmd/sops/rotate.go
+++ b/cmd/sops/rotate.go
@@ -20,15 +20,17 @@ type rotateOpts struct {
 	AddMasterKeys    []keys.MasterKey
 	RemoveMasterKeys []keys.MasterKey
 	KeyServices      []keyservice.KeyServiceClient
+	DecryptionOrder  []string
 }
 
 func rotate(opts rotateOpts) ([]byte, error) {
 	tree, err := common.LoadEncryptedFileWithBugFixes(common.GenericDecryptOpts{
-		Cipher:      opts.Cipher,
-		InputStore:  opts.InputStore,
-		InputPath:   opts.InputPath,
-		IgnoreMAC:   opts.IgnoreMAC,
-		KeyServices: opts.KeyServices,
+		Cipher:          opts.Cipher,
+		InputStore:      opts.InputStore,
+		InputPath:       opts.InputPath,
+		IgnoreMAC:       opts.IgnoreMAC,
+		KeyServices:     opts.KeyServices,
+		DecryptionOrder: opts.DecryptionOrder,
 	})
 	if err != nil {
 		return nil, err
@@ -39,8 +41,11 @@ func rotate(opts rotateOpts) ([]byte, error) {
 	})
 
 	_, err = common.DecryptTree(common.DecryptTreeOpts{
-		Cipher: opts.Cipher, IgnoreMac: opts.IgnoreMAC, Tree: tree,
-		KeyServices: opts.KeyServices,
+		Cipher:          opts.Cipher,
+		IgnoreMac:       opts.IgnoreMAC,
+		Tree:            tree,
+		KeyServices:     opts.KeyServices,
+		DecryptionOrder: opts.DecryptionOrder,
 	})
 	if err != nil {
 		return nil, err
diff --git a/cmd/sops/set.go b/cmd/sops/set.go
index 976a066ac..a6e8ed357 100644
--- a/cmd/sops/set.go
+++ b/cmd/sops/set.go
@@ -10,14 +10,15 @@ import (
 )
 
 type setOpts struct {
-	Cipher      sops.Cipher
-	InputStore  sops.Store
-	OutputStore sops.Store
-	InputPath   string
-	IgnoreMAC   bool
-	TreePath    []interface{}
-	Value       interface{}
-	KeyServices []keyservice.KeyServiceClient
+	Cipher          sops.Cipher
+	InputStore      sops.Store
+	OutputStore     sops.Store
+	InputPath       string
+	IgnoreMAC       bool
+	TreePath        []interface{}
+	Value           interface{}
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
 }
 
 func set(opts setOpts) ([]byte, error) {
@@ -36,10 +37,11 @@ func set(opts setOpts) ([]byte, error) {
 
 	// Decrypt the file
 	dataKey, err := common.DecryptTree(common.DecryptTreeOpts{
-		Cipher:      opts.Cipher,
-		IgnoreMac:   opts.IgnoreMAC,
-		Tree:        tree,
-		KeyServices: opts.KeyServices,
+		Cipher:          opts.Cipher,
+		IgnoreMac:       opts.IgnoreMAC,
+		Tree:            tree,
+		KeyServices:     opts.KeyServices,
+		DecryptionOrder: opts.DecryptionOrder,
 	})
 	if err != nil {
 		return nil, err
diff --git a/cmd/sops/subcommand/groups/add.go b/cmd/sops/subcommand/groups/add.go
index acd6b4619..716d83ea6 100644
--- a/cmd/sops/subcommand/groups/add.go
+++ b/cmd/sops/subcommand/groups/add.go
@@ -10,13 +10,14 @@ import (
 
 // AddOpts are the options for adding a key group to a SOPS file
 type AddOpts struct {
-	InputPath      string
-	InputStore     sops.Store
-	OutputStore    sops.Store
-	Group          sops.KeyGroup
-	GroupThreshold int
-	InPlace        bool
-	KeyServices    []keyservice.KeyServiceClient
+	InputPath       string
+	InputStore      sops.Store
+	OutputStore     sops.Store
+	Group           sops.KeyGroup
+	GroupThreshold  int
+	InPlace         bool
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
 }
 
 // Add adds a key group to a SOPS file
@@ -25,7 +26,7 @@ func Add(opts AddOpts) error {
 	if err != nil {
 		return err
 	}
-	dataKey, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices)
+	dataKey, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices, opts.DecryptionOrder)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/sops/subcommand/groups/delete.go b/cmd/sops/subcommand/groups/delete.go
index 9a101709c..67f32ef25 100644
--- a/cmd/sops/subcommand/groups/delete.go
+++ b/cmd/sops/subcommand/groups/delete.go
@@ -12,13 +12,14 @@ import (
 
 // DeleteOpts are the options for deleting a key group from a SOPS file
 type DeleteOpts struct {
-	InputPath      string
-	InputStore     sops.Store
-	OutputStore    sops.Store
-	Group          uint
-	GroupThreshold int
-	InPlace        bool
-	KeyServices    []keyservice.KeyServiceClient
+	InputPath       string
+	InputStore      sops.Store
+	OutputStore     sops.Store
+	Group           uint
+	GroupThreshold  int
+	InPlace         bool
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
 }
 
 // Delete deletes a key group from a SOPS file
@@ -27,7 +28,7 @@ func Delete(opts DeleteOpts) error {
 	if err != nil {
 		return err
 	}
-	dataKey, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices)
+	dataKey, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices, opts.DecryptionOrder)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/sops/subcommand/publish/publish.go b/cmd/sops/subcommand/publish/publish.go
index dcaf0f31e..aed1118de 100644
--- a/cmd/sops/subcommand/publish/publish.go
+++ b/cmd/sops/subcommand/publish/publish.go
@@ -27,15 +27,16 @@ func init() {
 
 // Opts represents publish options and config
 type Opts struct {
-	Interactive    bool
-	Cipher         sops.Cipher
-	ConfigPath     string
-	InputPath      string
-	KeyServices    []keyservice.KeyServiceClient
-	InputStore     sops.Store
-	OmitExtensions bool
-	Recursive      bool
-	RootPath       string
+	Interactive     bool
+	Cipher          sops.Cipher
+	ConfigPath      string
+	InputPath       string
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
+	InputStore      sops.Store
+	OmitExtensions  bool
+	Recursive       bool
+	RootPath        string
 }
 
 // Run publish operation
@@ -81,10 +82,11 @@ func Run(opts Opts) error {
 		if len(conf.KeyGroups[0]) != 0 {
 			log.Debug("Re-encrypting tree before publishing")
 			_, err = common.DecryptTree(common.DecryptTreeOpts{
-				Cipher:      opts.Cipher,
-				IgnoreMac:   false,
-				Tree:        tree,
-				KeyServices: opts.KeyServices,
+				Cipher:          opts.Cipher,
+				IgnoreMac:       false,
+				Tree:            tree,
+				KeyServices:     opts.KeyServices,
+				DecryptionOrder: opts.DecryptionOrder,
 			})
 			if err != nil {
 				return err
@@ -137,10 +139,11 @@ func Run(opts Opts) error {
 		}
 	case *publish.VaultDestination:
 		_, err = common.DecryptTree(common.DecryptTreeOpts{
-			Cipher:      opts.Cipher,
-			IgnoreMac:   false,
-			Tree:        tree,
-			KeyServices: opts.KeyServices,
+			Cipher:          opts.Cipher,
+			IgnoreMac:       false,
+			Tree:            tree,
+			KeyServices:     opts.KeyServices,
+			DecryptionOrder: opts.DecryptionOrder,
 		})
 		if err != nil {
 			return err
diff --git a/cmd/sops/subcommand/updatekeys/updatekeys.go b/cmd/sops/subcommand/updatekeys/updatekeys.go
index 66ea01e22..c33ee145f 100644
--- a/cmd/sops/subcommand/updatekeys/updatekeys.go
+++ b/cmd/sops/subcommand/updatekeys/updatekeys.go
@@ -14,12 +14,13 @@ import (
 
 // Opts represents key operation options and config
 type Opts struct {
-	InputPath   string
-	GroupQuorum int
-	KeyServices []keyservice.KeyServiceClient
-	Interactive bool
-	ConfigPath  string
-	InputType   string
+	InputPath       string
+	GroupQuorum     int
+	KeyServices     []keyservice.KeyServiceClient
+	DecryptionOrder []string
+	Interactive     bool
+	ConfigPath      string
+	InputType       string
 }
 
 // UpdateKeys update the keys for a given file
@@ -83,7 +84,7 @@ func updateFile(opts Opts) error {
 			return nil
 		}
 	}
-	key, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices)
+	key, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices, opts.DecryptionOrder)
 	if err != nil {
 		return common.NewExitError(err, codes.CouldNotRetrieveKey)
 	}
diff --git a/gcpkms/keysource.go b/gcpkms/keysource.go
index 3ef97b445..8ff51357d 100644
--- a/gcpkms/keysource.go
+++ b/gcpkms/keysource.go
@@ -23,6 +23,8 @@ const (
 	// a path to a credentials file, or directly as the variable's value in JSON
 	// format.
 	SopsGoogleCredentialsEnv = "GOOGLE_CREDENTIALS"
+	// KeyTypeIdentifier is the string used to identify a GCP KMS MasterKey.
+	KeyTypeIdentifier = "gcp_kms"
 )
 
 var (
@@ -196,6 +198,11 @@ func (key MasterKey) ToMap() map[string]interface{} {
 	return out
 }
 
+// TypeToIdentifier returns the string identifier for the MasterKey type.
+func (key *MasterKey) TypeToIdentifier() string {
+	return KeyTypeIdentifier
+}
+
 // newKMSClient returns a GCP KMS client configured with the credentialJSON
 // and/or grpcConn, falling back to environmental defaults.
 // It returns an error if the ResourceID is invalid, or if the setup of the
diff --git a/hcvault/keysource.go b/hcvault/keysource.go
index 128115705..a025413fe 100644
--- a/hcvault/keysource.go
+++ b/hcvault/keysource.go
@@ -21,6 +21,11 @@ import (
 	"github.com/getsops/sops/v3/logging"
 )
 
+const (
+	// KeyTypeIdentifier is the string used to identify a Vault MasterKey.
+	KeyTypeIdentifier = "hc_vault"
+)
+
 func init() {
 	log = logging.NewLogger("VAULT_TRANSIT")
 }
@@ -216,6 +221,11 @@ func (key MasterKey) ToMap() map[string]interface{} {
 	return out
 }
 
+// TypeToIdentifier returns the string identifier for the MasterKey type.
+func (key *MasterKey) TypeToIdentifier() string {
+	return KeyTypeIdentifier
+}
+
 // encryptPath returns the path for Encrypt requests.
 func (key *MasterKey) encryptPath() string {
 	return path.Join(key.EnginePath, "encrypt", key.KeyName)
diff --git a/keys/keys.go b/keys/keys.go
index d0b3f2a73..06f67141d 100644
--- a/keys/keys.go
+++ b/keys/keys.go
@@ -10,4 +10,5 @@ type MasterKey interface {
 	NeedsRotation() bool
 	ToString() string
 	ToMap() map[string]interface{}
+	TypeToIdentifier() string
 }
diff --git a/kms/keysource.go b/kms/keysource.go
index 1749b3455..e1441b492 100644
--- a/kms/keysource.go
+++ b/kms/keysource.go
@@ -3,7 +3,7 @@ Package kms contains an implementation of the github.com/getsops/sops/v3.MasterK
 interface that encrypts and decrypts the data key using AWS KMS with the SDK
 for Go V2.
 */
-package kms //import "github.com/getsops/sops/v3/kms"
+package kms // import "github.com/getsops/sops/v3/kms"
 
 import (
 	"context"
@@ -19,8 +19,9 @@ import (
 	"github.com/aws/aws-sdk-go-v2/credentials"
 	"github.com/aws/aws-sdk-go-v2/service/kms"
 	"github.com/aws/aws-sdk-go-v2/service/sts"
-	"github.com/getsops/sops/v3/logging"
 	"github.com/sirupsen/logrus"
+
+	"github.com/getsops/sops/v3/logging"
 )
 
 const (
@@ -34,6 +35,8 @@ const (
 	roleSessionNameLengthLimit = 64
 	// kmsTTL is the duration after which a MasterKey requires rotation.
 	kmsTTL = time.Hour * 24 * 30 * 6
+	// KeyTypeIdentifier is the string used to identify an AWS KMS MasterKey.
+	KeyTypeIdentifier = "kms"
 )
 
 var (
@@ -297,6 +300,11 @@ func (key MasterKey) ToMap() map[string]interface{} {
 	return out
 }
 
+// TypeToIdentifier returns the string identifier for the MasterKey type.
+func (key *MasterKey) TypeToIdentifier() string {
+	return KeyTypeIdentifier
+}
+
 // createKMSConfig returns an AWS config with the credentialsProvider of the
 // MasterKey, or the default configuration sources.
 func (key MasterKey) createKMSConfig() (*aws.Config, error) {
diff --git a/pgp/keysource.go b/pgp/keysource.go
index a95c4d008..e1eed580d 100644
--- a/pgp/keysource.go
+++ b/pgp/keysource.go
@@ -4,7 +4,7 @@ interface that encrypts and decrypts the data key by first trying with the
 github.com/ProtonMail/go-crypto/openpgp package and if that fails, by calling
 the "gpg" binary.
 */
-package pgp //import "github.com/getsops/sops/v3/pgp"
+package pgp // import "github.com/getsops/sops/v3/pgp"
 
 import (
 	"bytes"
@@ -22,12 +22,15 @@ import (
 	"github.com/ProtonMail/go-crypto/openpgp"
 	"github.com/ProtonMail/go-crypto/openpgp/armor"
 	gpgagent "github.com/getsops/gopgagent"
-	"github.com/getsops/sops/v3/logging"
 	"github.com/sirupsen/logrus"
 	"golang.org/x/term"
+
+	"github.com/getsops/sops/v3/logging"
 )
 
 const (
+	// KeyTypeIdentifier is the string used to identify a PGP MasterKey.
+	KeyTypeIdentifier = "pgp"
 	// SopsGpgExecEnv can be set as an environment variable to overwrite the
 	// GnuPG binary used.
 	SopsGpgExecEnv = "SOPS_GPG_EXEC"
@@ -449,6 +452,11 @@ func (key MasterKey) ToMap() map[string]interface{} {
 	return out
 }
 
+// TypeToIdentifier returns the string identifier for the MasterKey type.
+func (key *MasterKey) TypeToIdentifier() string {
+	return KeyTypeIdentifier
+}
+
 // retrievePubKey attempts to retrieve the public key from the public keyring
 // by Fingerprint.
 func (key *MasterKey) retrievePubKey() (openpgp.Entity, error) {
diff --git a/sops.go b/sops.go
index 827a5ee5b..c144db005 100644
--- a/sops.go
+++ b/sops.go
@@ -34,7 +34,7 @@ be recalculated and compared with the MAC stored in the document to verify that
 fraudulent changes have been applied. The MAC covers keys and values as well as their
 ordering.
 */
-package sops //import "github.com/getsops/sops/v3"
+package sops // import "github.com/getsops/sops/v3"
 
 import (
 	"crypto/rand"
@@ -42,22 +42,28 @@ import (
 	"fmt"
 	"reflect"
 	"regexp"
+	"sort"
 	"strconv"
 	"strings"
 	"time"
 
+	"github.com/sirupsen/logrus"
+	"golang.org/x/net/context"
+
+	"github.com/getsops/sops/v3/age"
 	"github.com/getsops/sops/v3/audit"
 	"github.com/getsops/sops/v3/keys"
 	"github.com/getsops/sops/v3/keyservice"
 	"github.com/getsops/sops/v3/logging"
+	"github.com/getsops/sops/v3/pgp"
 	"github.com/getsops/sops/v3/shamir"
-	"github.com/sirupsen/logrus"
-	"golang.org/x/net/context"
 )
 
 // DefaultUnencryptedSuffix is the default suffix a TreeItem key has to end with for sops to leave its Value unencrypted
 const DefaultUnencryptedSuffix = "_unencrypted"
 
+var DefaultDecryptionOrder = []string{age.KeyTypeIdentifier, pgp.KeyTypeIdentifier}
+
 type sopsError string
 
 func (e sopsError) Error() string {
@@ -648,7 +654,7 @@ func (m *Metadata) UpdateMasterKeys(dataKey []byte) (errs []error) {
 
 // GetDataKeyWithKeyServices retrieves the data key, asking KeyServices to decrypt it with each
 // MasterKey in the Metadata's KeySources until one of them succeeds.
-func (m Metadata) GetDataKeyWithKeyServices(svcs []keyservice.KeyServiceClient) ([]byte, error) {
+func (m Metadata) GetDataKeyWithKeyServices(svcs []keyservice.KeyServiceClient, decryptionOrder []string) ([]byte, error) {
 	if m.DataKey != nil {
 		return m.DataKey, nil
 	}
@@ -658,7 +664,7 @@ func (m Metadata) GetDataKeyWithKeyServices(svcs []keyservice.KeyServiceClient)
 	}
 	var parts [][]byte
 	for i, group := range m.KeyGroups {
-		part, err := decryptKeyGroup(group, svcs)
+		part, err := decryptKeyGroup(group, svcs, decryptionOrder)
 		if err == nil {
 			parts = append(parts, part)
 		}
@@ -688,9 +694,13 @@ func (m Metadata) GetDataKeyWithKeyServices(svcs []keyservice.KeyServiceClient)
 // decryptKeyGroup tries to decrypt the contents of the provided KeyGroup with
 // any of the MasterKeys in the KeyGroup with any of the provided key services,
 // returning as soon as one key service succeeds.
-func decryptKeyGroup(group KeyGroup, svcs []keyservice.KeyServiceClient) ([]byte, error) {
+func decryptKeyGroup(group KeyGroup, svcs []keyservice.KeyServiceClient, decryptionOrder []string) ([]byte, error) {
 	var keyErrs []error
-	for _, key := range group {
+	// Sort MasterKeys in the group so we try them in specific order
+	// Use sorted indices to avoid group slice modification
+	indices := sortKeyGroupIndices(group, decryptionOrder)
+	for _, indexVal := range indices {
+		key := group[indexVal]
 		part, err := decryptKey(key, svcs)
 		if err != nil {
 			keyErrs = append(keyErrs, err)
@@ -701,6 +711,37 @@ func decryptKeyGroup(group KeyGroup, svcs []keyservice.KeyServiceClient) ([]byte
 	return nil, decryptKeyErrors(keyErrs)
 }
 
+// sortKeyGroupIndices returns indices that would sort the KeyGroup
+// according to decryptionOrder
+func sortKeyGroupIndices(group KeyGroup, decryptionOrder []string) []int {
+	priorities := make(map[string]int)
+	// give ordered weights
+	for i, v := range decryptionOrder {
+		priorities[v] = i
+	}
+	maxPriority := len(decryptionOrder)
+	// initialize indices
+	n := len(group)
+	indices := make([]int, n)
+	for i := 0; i < n; i++ {
+		indices[i] = i
+	}
+	sort.SliceStable(indices, func(i, j int) bool {
+		keyTypeI := group[indices[i]].TypeToIdentifier()
+		keyTypeJ := group[indices[j]].TypeToIdentifier()
+		priorityI, ok := priorities[keyTypeI]
+		if !ok {
+			priorityI = maxPriority
+		}
+		priorityJ, ok := priorities[keyTypeJ]
+		if !ok {
+			priorityJ = maxPriority
+		}
+		return priorityI < priorityJ
+	})
+	return indices
+}
+
 // decryptKey tries to decrypt the contents of the provided MasterKey with any
 // of the key services, returning as soon as one key service succeeds.
 func decryptKey(key keys.MasterKey, svcs []keyservice.KeyServiceClient) ([]byte, error) {
@@ -739,7 +780,7 @@ func decryptKey(key keys.MasterKey, svcs []keyservice.KeyServiceClient) ([]byte,
 func (m Metadata) GetDataKey() ([]byte, error) {
 	return m.GetDataKeyWithKeyServices([]keyservice.KeyServiceClient{
 		keyservice.NewLocalClient(),
-	})
+	}, nil)
 }
 
 // ToBytes converts a string, int, float or bool to a byte representation.


Test patch:
diff --git a/sops_test.go b/sops_test.go
index 56a1c3da4..81ad23c99 100644
--- a/sops_test.go
+++ b/sops_test.go
@@ -7,6 +7,10 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+
+	"github.com/getsops/sops/v3/age"
+	"github.com/getsops/sops/v3/hcvault"
+	"github.com/getsops/sops/v3/pgp"
 )
 
 type reverseCipher struct{}
@@ -842,3 +846,61 @@ func TestEmitAsMap(t *testing.T) {
 		assert.Equal(t, expected, data)
 	}
 }
+
+func TestSortKeyGroupIndices(t *testing.T) {
+	t.Run("default order", func(t *testing.T) {
+		group := KeyGroup{&hcvault.MasterKey{}, &age.MasterKey{}, &pgp.MasterKey{}}
+		expected := []int{1, 2, 0}
+		indices := sortKeyGroupIndices(group, DefaultDecryptionOrder)
+		assert.Equal(t, expected, indices)
+	})
+
+	t.Run("different keygroup", func(t *testing.T) {
+		group := KeyGroup{&hcvault.MasterKey{}, &pgp.MasterKey{}, &age.MasterKey{}}
+		expected := []int{2, 1, 0}
+		indices := sortKeyGroupIndices(group, DefaultDecryptionOrder)
+		assert.Equal(t, expected, indices)
+	})
+
+	t.Run("repeated key", func(t *testing.T) {
+		group := KeyGroup{&pgp.MasterKey{}, &hcvault.MasterKey{}, &pgp.MasterKey{}, &age.MasterKey{}}
+		expected := []int{3, 0, 2, 1}
+		indices := sortKeyGroupIndices(group, DefaultDecryptionOrder)
+		assert.Equal(t, expected, indices)
+	})
+
+	t.Run("full order", func(t *testing.T) {
+		group := KeyGroup{&hcvault.MasterKey{}, &pgp.MasterKey{}, &age.MasterKey{}}
+		expected := []int{1, 2, 0}
+		indices := sortKeyGroupIndices(group, []string{"pgp", "age", "hc_vault"})
+		assert.Equal(t, expected, indices)
+	})
+
+	t.Run("empty order", func(t *testing.T) {
+		group := KeyGroup{&hcvault.MasterKey{}, &pgp.MasterKey{}, &age.MasterKey{}}
+		expected := []int{0, 1, 2}
+		indices := sortKeyGroupIndices(group, []string{})
+		assert.Equal(t, expected, indices)
+	})
+
+	t.Run("one match", func(t *testing.T) {
+		group := KeyGroup{&hcvault.MasterKey{}, &pgp.MasterKey{}, &age.MasterKey{}}
+		expected := []int{2, 0, 1}
+		indices := sortKeyGroupIndices(group, []string{"azure_kv", "age"})
+		assert.Equal(t, expected, indices)
+	})
+
+	t.Run("nonmatching order", func(t *testing.T) {
+		group := KeyGroup{&pgp.MasterKey{}, &hcvault.MasterKey{}, &age.MasterKey{}}
+		expected := []int{0, 1, 2}
+		indices := sortKeyGroupIndices(group, []string{"azure_kv"})
+		assert.Equal(t, expected, indices)
+	})
+
+	t.Run("nonexistent keys", func(t *testing.T) {
+		group := KeyGroup{&hcvault.MasterKey{}, &pgp.MasterKey{}, &age.MasterKey{}}
+		expected := []int{2, 1, 0}
+		indices := sortKeyGroupIndices(group, []string{"dummy1", "age", "dummy2", "pgp", "dummy3"})
+		assert.Equal(t, expected, indices)
+	})
+}