diff --git a/README.rst b/README.rst index d3cef70b7..073cd3e1a 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ SOPS: Secrets OPerationS ======================== **SOPS** is an editor of encrypted files that supports YAML, JSON, ENV, INI and BINARY -formats and encrypts with AWS KMS, GCP KMS, Azure Key Vault, HuaweiCloud KMS, age, and PGP. +formats and encrypts with AWS KMS, GCP KMS, Azure Key Vault, HuaweiCloud KMS, STACKIT KMS, age, and PGP. (`demo `_) .. image:: https://i.imgur.com/X0TM5NI.gif @@ -604,13 +604,65 @@ You can also configure HuaweiCloud KMS keys in the ``.sops.yaml`` config file: hckms: - tr-west-1:abc12345-6789-0123-4567-890123456789,tr-west-2:def67890-1234-5678-9012-345678901234 +Encrypting using STACKIT KMS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The STACKIT KMS integration uses the +`STACKIT SDK for Go `_ +default credential provider chain which tries several authentication methods, in this order: + +1. Static token or key flow credentials +2. Environment variable ``STACKIT_SERVICE_ACCOUNT_TOKEN`` +3. Credentials file at ``~/.stackit/credentials.json`` +4. Token flow via service account key + +For more details, see the `STACKIT KMS documentation `_. + +STACKIT KMS uses a resource ID in the format: +``projects//regions//keyRings//keys//versions/`` + +You can list your KMS keys using the STACKIT CLI: + +.. code:: + + stackit kms key-ring list --project-id PROJECT_ID --region eu01 + stackit kms key list --project-id PROJECT_ID --region eu01 --key-ring-id KEYRING_ID + stackit kms key version list --project-id PROJECT_ID --region eu01 --key-ring-id KEYRING_ID --key-id KEY_ID + +Now you can encrypt a file using: + +.. code:: sh + + $ sops encrypt --stackit-kms projects/my-project-id/regions/eu01/keyRings/aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb/keys/cccccccc-4444-5555-6666-dddddddddddd/versions/1 test.yaml > test.enc.yaml + +Or using the environment variable: + +.. code:: sh + + $ export SOPS_STACKIT_KMS_IDS="projects/my-project-id/regions/eu01/keyRings/aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb/keys/cccccccc-4444-5555-6666-dddddddddddd/versions/1" + $ sops encrypt test.yaml > test.enc.yaml + +And decrypt it using: + +.. code:: sh + + $ sops decrypt test.enc.yaml + +You can also configure STACKIT KMS keys in the ``.sops.yaml`` config file: + +.. code:: yaml + + creation_rules: + - path_regex: \.stackit\.yaml$ + stackit_kms: projects/my-project-id/regions/eu01/keyRings/aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb/keys/cccccccc-4444-5555-6666-dddddddddddd/versions/1 + Adding and removing keys ~~~~~~~~~~~~~~~~~~~~~~~~ When creating new files, ``sops`` uses the PGP, KMS and GCP KMS defined in the -command line arguments ``--kms``, ``--pgp``, ``--gcp-kms``, ``--hckms`` or ``--azure-kv``, or from +command line arguments ``--kms``, ``--pgp``, ``--gcp-kms``, ``--hckms``, ``--stackit-kms`` or ``--azure-kv``, or from the environment variables ``SOPS_KMS_ARN``, ``SOPS_PGP_FP``, ``SOPS_GCP_KMS_IDS``, -``SOPS_HUAWEICLOUD_KMS_IDS``, ``SOPS_AZURE_KEYVAULT_URLS``. That information is stored in the file under the +``SOPS_HUAWEICLOUD_KMS_IDS``, ``SOPS_STACKIT_KMS_IDS``, ``SOPS_AZURE_KEYVAULT_URLS``. That information is stored in the file under the ``sops`` section, such that decrypting files does not require providing those parameters again. @@ -654,9 +706,9 @@ disabled by supplying the ``-y`` flag. The ``rotate`` command generates a new data encryption key and reencrypt all values with the new key. At the same time, the command line flag ``--add-kms``, ``--add-pgp``, -``--add-gcp-kms``, ``--add-hckms``, ``--add-azure-kv``, ``--rm-kms``, ``--rm-pgp``, ``--rm-gcp-kms``, -``--rm-hckms`` and ``--rm-azure-kv`` can be used to add and remove keys from a file. These flags use -the comma separated syntax as the ``--kms``, ``--pgp``, ``--gcp-kms``, ``--hckms`` and ``--azure-kv`` +``--add-gcp-kms``, ``--add-hckms``, ``--add-stackit-kms``, ``--add-azure-kv``, ``--rm-kms``, ``--rm-pgp``, ``--rm-gcp-kms``, +``--rm-hckms``, ``--rm-stackit-kms`` and ``--rm-azure-kv`` can be used to add and remove keys from a file. These flags use +the comma separated syntax as the ``--kms``, ``--pgp``, ``--gcp-kms``, ``--hckms``, ``--stackit-kms`` and ``--azure-kv`` arguments when creating new files. Use ``updatekeys`` if you want to add a key without rotating the data key. @@ -832,7 +884,7 @@ stdout. Using .sops.yaml conf to select KMS, PGP and age for new files ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -It is often tedious to specify the ``--kms`` ``--gcp-kms`` ``--hckms`` ``--pgp`` and ``--age`` parameters for creation +It is often tedious to specify the ``--kms`` ``--gcp-kms`` ``--hckms`` ``--stackit-kms`` ``--pgp`` and ``--age`` parameters for creation of all new files. If your secrets are stored under a specific directory, like a ``git`` repository, you can create a ``.sops.yaml`` configuration file at the root directory to define which keys are used for which filename. @@ -878,6 +930,10 @@ can manage the three sets of configurations for the three types of files: - path_regex: \.hckms\.yaml$ hckms: tr-west-1:abc12345-6789-0123-4567-890123456789,tr-west-2:def67890-1234-5678-9012-345678901234 + # stackit files using STACKIT KMS + - path_regex: \.stackit\.yaml$ + stackit_kms: projects/my-project-id/regions/eu01/keyRings/aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb/keys/cccccccc-4444-5555-6666-dddddddddddd/versions/1 + # Finally, if the rules above have not matched, this one is a # catchall that will encrypt the file using KMS set C as well as PGP # The absence of a path_regex means it will match everything @@ -1883,6 +1939,16 @@ To directly specify a single key group, you can use the following keys: - tr-west-1:abc12345-6789-0123-4567-890123456789 - tr-west-1:def67890-1234-5678-9012-345678901234 +* ``stackit_kms`` (comma-separated string, or list of strings): list of STACKIT KMS resource IDs + (format: ``projects//regions//keyRings//keys//versions/``). + Example: + + .. code:: yaml + + creation_rules: + - stackit_kms: + - projects/my-project-id/regions/eu01/keyRings/aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb/keys/cccccccc-4444-5555-6666-dddddddddddd/versions/1 + To specify a list of key groups, you can use the following key: * ``key_groups`` (list of key group objects): a list of key group objects. @@ -2000,6 +2066,17 @@ A key group supports the following keys: - key_id: tr-west-1:abc12345-6789-0123-4567-890123456789 +* ``stackit_kms`` (list of objects): list of STACKIT KMS resource IDs. + Every object must have the following key: + + * ``resource_id`` (string): the resource ID in format ``projects//regions//keyRings//keys//versions/``. + + Example: + + .. code:: yaml + + - resource_id: projects/my-project-id/regions/eu01/keyRings/aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb/keys/cccccccc-4444-5555-6666-dddddddddddd/versions/1 + * ``age`` (list of strings): list of Age public keys. * ``pgp`` (list of strings): list of PGP/GPG key fingerprints. diff --git a/cmd/sops/main.go b/cmd/sops/main.go index fca10f303..bdf73db26 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -36,6 +36,7 @@ import ( "github.com/getsops/sops/v3/gcpkms" "github.com/getsops/sops/v3/hckms" "github.com/getsops/sops/v3/hcvault" + "github.com/getsops/sops/v3/stackitkms" "github.com/getsops/sops/v3/keys" "github.com/getsops/sops/v3/keyservice" "github.com/getsops/sops/v3/kms" @@ -91,14 +92,14 @@ func main() { }, } app.Name = "sops" - app.Usage = "sops - encrypted file editor with AWS KMS, GCP KMS, HuaweiCloud KMS, Azure Key Vault, age, and GPG support" + app.Usage = "sops - encrypted file editor with AWS KMS, GCP KMS, HuaweiCloud KMS, STACKIT KMS, Azure Key Vault, age, and GPG support" app.ArgsUsage = "sops [options] file" app.Version = version.Version app.Authors = []cli.Author{ {Name: "CNCF Maintainers"}, } - app.UsageText = `sops is an editor of encrypted files that supports AWS KMS, GCP, HuaweiCloud KMS, AZKV, - PGP, and Age + app.UsageText = `sops is an editor of encrypted files that supports AWS KMS, GCP, HuaweiCloud KMS, STACKIT KMS, + AZKV, PGP, and Age To encrypt or decrypt a document with AWS KMS, specify the KMS ARN in the -k flag or in the SOPS_KMS_ARN environment variable. @@ -117,6 +118,12 @@ func main() { HUAWEICLOUD_SDK_AK, HUAWEICLOUD_SDK_SK, HUAWEICLOUD_SDK_PROJECT_ID, or use credentials file at ~/.huaweicloud/credentials) + To encrypt or decrypt a document with STACKIT KMS, specify the + STACKIT KMS resource ID in the --stackit-kms flag or in the + SOPS_STACKIT_KMS_IDS environment variable. + (Authentication is handled by the STACKIT SDK via environment variables, + service account key files, or credentials file at ~/.stackit/credentials.json) + To encrypt or decrypt a document with HashiCorp Vault's Transit Secret Engine, specify the Vault key URI name in the --hc-vault-transit flag or in the SOPS_VAULT_URIS environment variable (for example @@ -142,12 +149,12 @@ func main() { To use multiple KMS or PGP keys, separate them by commas. For example: $ sops -p "10F2...0A, 85D...B3F21" file.yaml - The -p, -k, --gcp-kms, --hckms, --hc-vault-transit, and --azure-kv flags are only + The -p, -k, --gcp-kms, --hckms, --stackit-kms, --hc-vault-transit, and --azure-kv flags are only used to encrypt new documents. Editing or decrypting existing documents can be done with "sops file" or "sops decrypt file" respectively. The KMS and PGP keys listed in the encrypted documents are used then. To manage master - keys in existing documents, use the "add-{kms,pgp,gcp-kms,hckms,azure-kv,hc-vault-transit}" - and "rm-{kms,pgp,gcp-kms,hckms,azure-kv,hc-vault-transit}" flags with --rotate + keys in existing documents, use the "add-{kms,pgp,gcp-kms,hckms,stackit-kms,azure-kv,hc-vault-transit}" + and "rm-{kms,pgp,gcp-kms,hckms,stackit-kms,azure-kv,hc-vault-transit}" flags with --rotate or the updatekeys command. To use a different GPG binary than the one in your PATH, set SOPS_GPG_EXEC. @@ -582,6 +589,10 @@ func main() { Name: "hckms", Usage: "the HuaweiCloud KMS key ID (format: region:key-uuid) the new group should contain. Can be specified more than once", }, + cli.StringSliceFlag{ + Name: "stackit-kms", + Usage: "the STACKIT KMS resource ID the new group should contain. Can be specified more than once", + }, cli.StringSliceFlag{ Name: "azure-kv", Usage: "the Azure Key Vault key URL the new group should contain. Can be specified more than once", @@ -635,6 +646,15 @@ func main() { } group = append(group, k) } + stackitKmsIds := c.StringSlice("stackit-kms") + for _, resID := range stackitKmsIds { + k, err := stackitkms.NewMasterKey(resID) + if err != nil { + log.WithError(err).Error("Failed to add key") + continue + } + group = append(group, k) + } for _, url := range azkvs { k, err := azkv.NewMasterKeyFromURL(url) if err != nil { @@ -950,6 +970,11 @@ func main() { Usage: "comma separated list of HuaweiCloud KMS key IDs (format: region:key-uuid)", EnvVar: "SOPS_HUAWEICLOUD_KMS_IDS", }, + cli.StringFlag{ + Name: "stackit-kms", + Usage: "comma separated list of STACKIT KMS resource IDs", + EnvVar: "SOPS_STACKIT_KMS_IDS", + }, cli.StringFlag{ Name: "azure-kv", Usage: "comma separated list of Azure Key Vault URLs", @@ -1143,6 +1168,14 @@ func main() { Name: "rm-hckms", Usage: "remove the provided comma-separated list of HuaweiCloud KMS key IDs (format: region:key-uuid) from the list of master keys on the given file", }, + cli.StringFlag{ + Name: "add-stackit-kms", + Usage: "add the provided comma-separated list of STACKIT KMS resource IDs to the list of master keys on the given file", + }, + cli.StringFlag{ + Name: "rm-stackit-kms", + Usage: "remove the provided comma-separated list of STACKIT KMS resource IDs from the list of master keys on the given file", + }, cli.StringFlag{ Name: "add-azure-kv", Usage: "add the provided comma-separated list of Azure Key Vault key URLs to the list of master keys on the given file", @@ -1209,8 +1242,8 @@ func main() { return toExitError(err) } if _, err := os.Stat(fileName); os.IsNotExist(err) { - if c.String("add-kms") != "" || c.String("add-pgp") != "" || c.String("add-gcp-kms") != "" || c.String("add-hckms") != "" || c.String("add-hc-vault-transit") != "" || c.String("add-azure-kv") != "" || c.String("add-age") != "" || - c.String("rm-kms") != "" || c.String("rm-pgp") != "" || c.String("rm-gcp-kms") != "" || c.String("rm-hckms") != "" || c.String("rm-hc-vault-transit") != "" || c.String("rm-azure-kv") != "" || c.String("rm-age") != "" { + if c.String("add-kms") != "" || c.String("add-pgp") != "" || c.String("add-gcp-kms") != "" || c.String("add-hckms") != "" || c.String("add-stackit-kms") != "" || c.String("add-hc-vault-transit") != "" || c.String("add-azure-kv") != "" || c.String("add-age") != "" || + c.String("rm-kms") != "" || c.String("rm-pgp") != "" || c.String("rm-gcp-kms") != "" || c.String("rm-hckms") != "" || c.String("rm-stackit-kms") != "" || c.String("rm-hc-vault-transit") != "" || c.String("rm-azure-kv") != "" || c.String("rm-age") != "" { return common.NewExitError(fmt.Sprintf("Error: cannot add or remove keys on non-existent file %q, use the `edit` subcommand instead.", fileName), codes.CannotChangeKeysFromNonExistentFile) } } @@ -1301,6 +1334,11 @@ func main() { Usage: "comma separated list of HuaweiCloud KMS key IDs (format: region:key-uuid)", EnvVar: "SOPS_HUAWEICLOUD_KMS_IDS", }, + cli.StringFlag{ + Name: "stackit-kms", + Usage: "comma separated list of STACKIT KMS resource IDs", + EnvVar: "SOPS_STACKIT_KMS_IDS", + }, cli.StringFlag{ Name: "azure-kv", Usage: "comma separated list of Azure Key Vault URLs", @@ -1714,6 +1752,11 @@ func main() { Usage: "comma separated list of HuaweiCloud KMS key IDs (format: region:key-uuid)", EnvVar: "SOPS_HUAWEICLOUD_KMS_IDS", }, + cli.StringFlag{ + Name: "stackit-kms", + Usage: "comma separated list of STACKIT KMS resource IDs", + EnvVar: "SOPS_STACKIT_KMS_IDS", + }, cli.StringFlag{ Name: "azure-kv", Usage: "comma separated list of Azure Key Vault URLs", @@ -1770,6 +1813,14 @@ func main() { Name: "rm-hckms", Usage: "remove the provided comma-separated list of HuaweiCloud KMS key IDs (format: region:key-uuid) from the list of master keys on the given file", }, + cli.StringFlag{ + Name: "add-stackit-kms", + Usage: "add the provided comma-separated list of STACKIT KMS resource IDs to the list of master keys on the given file", + }, + cli.StringFlag{ + Name: "rm-stackit-kms", + Usage: "remove the provided comma-separated list of STACKIT KMS resource IDs from the list of master keys on the given file", + }, cli.StringFlag{ Name: "add-azure-kv", Usage: "add the provided comma-separated list of Azure Key Vault key URLs to the list of master keys on the given file", @@ -2235,7 +2286,7 @@ func getEncryptConfig(c *cli.Context, fileName string, inputStore common.Store, }, nil } -func getMasterKeys(c *cli.Context, kmsEncryptionContext map[string]*string, kmsOptionName string, pgpOptionName string, gcpKmsOptionName string, hckmsOptionName string, azureKvOptionName string, hcVaultTransitOptionName string, ageOptionName string) ([]keys.MasterKey, error) { +func getMasterKeys(c *cli.Context, kmsEncryptionContext map[string]*string, kmsOptionName string, pgpOptionName string, gcpKmsOptionName string, hckmsOptionName string, stackitKmsOptionName string, azureKvOptionName string, hcVaultTransitOptionName string, ageOptionName string) ([]keys.MasterKey, error) { var masterKeys []keys.MasterKey for _, k := range kms.MasterKeysFromArnString(c.String(kmsOptionName), kmsEncryptionContext, c.String("aws-profile")) { masterKeys = append(masterKeys, k) @@ -2253,6 +2304,13 @@ func getMasterKeys(c *cli.Context, kmsEncryptionContext map[string]*string, kmsO for _, k := range hckmsKeys { masterKeys = append(masterKeys, k) } + stackitKmsKeys, err := stackitkms.NewMasterKeyFromResourceIDString(c.String(stackitKmsOptionName)) + if err != nil { + return nil, err + } + for _, k := range stackitKmsKeys { + masterKeys = append(masterKeys, k) + } azureKeys, err := azkv.MasterKeysFromURLs(c.String(azureKvOptionName)) if err != nil { return nil, err @@ -2279,11 +2337,11 @@ func getMasterKeys(c *cli.Context, kmsEncryptionContext map[string]*string, kmsO func getRotateOpts(c *cli.Context, fileName string, inputStore common.Store, outputStore common.Store, svcs []keyservice.KeyServiceClient, decryptionOrder []string) (rotateOpts, error) { kmsEncryptionContext := kms.ParseKMSContext(c.String("encryption-context")) - addMasterKeys, err := getMasterKeys(c, kmsEncryptionContext, "add-kms", "add-pgp", "add-gcp-kms", "add-hckms", "add-azure-kv", "add-hc-vault-transit", "add-age") + addMasterKeys, err := getMasterKeys(c, kmsEncryptionContext, "add-kms", "add-pgp", "add-gcp-kms", "add-hckms", "add-stackit-kms", "add-azure-kv", "add-hc-vault-transit", "add-age") if err != nil { return rotateOpts{}, err } - rmMasterKeys, err := getMasterKeys(c, kmsEncryptionContext, "rm-kms", "rm-pgp", "rm-gcp-kms", "rm-hckms", "rm-azure-kv", "rm-hc-vault-transit", "rm-age") + rmMasterKeys, err := getMasterKeys(c, kmsEncryptionContext, "rm-kms", "rm-pgp", "rm-gcp-kms", "rm-hckms", "rm-stackit-kms", "rm-azure-kv", "rm-hc-vault-transit", "rm-age") if err != nil { return rotateOpts{}, err } @@ -2432,6 +2490,7 @@ func keyGroups(c *cli.Context, file string, optionalConfig *config.Config) ([]so var azkvKeys []keys.MasterKey var hcVaultMkKeys []keys.MasterKey var hckmsMkKeys []keys.MasterKey + var stackitKmsMkKeys []keys.MasterKey var ageMasterKeys []keys.MasterKey kmsEncryptionContext := kms.ParseKMSContext(c.String("encryption-context")) if c.String("encryption-context") != "" && kmsEncryptionContext == nil { @@ -2456,6 +2515,15 @@ func keyGroups(c *cli.Context, file string, optionalConfig *config.Config) ([]so hckmsMkKeys = append(hckmsMkKeys, k) } } + if c.String("stackit-kms") != "" { + stackitKmsKeys, err := stackitkms.NewMasterKeyFromResourceIDString(c.String("stackit-kms")) + if err != nil { + return nil, err + } + for _, k := range stackitKmsKeys { + stackitKmsMkKeys = append(stackitKmsMkKeys, k) + } + } if c.String("azure-kv") != "" { azureKeys, err := azkv.MasterKeysFromURLs(c.String("azure-kv")) if err != nil { @@ -2488,7 +2556,7 @@ func keyGroups(c *cli.Context, file string, optionalConfig *config.Config) ([]so ageMasterKeys = append(ageMasterKeys, k) } } - if c.String("kms") == "" && c.String("pgp") == "" && c.String("gcp-kms") == "" && c.String("hckms") == "" && c.String("azure-kv") == "" && c.String("hc-vault-transit") == "" && c.String("age") == "" { + if c.String("kms") == "" && c.String("pgp") == "" && c.String("gcp-kms") == "" && c.String("hckms") == "" && c.String("stackit-kms") == "" && c.String("azure-kv") == "" && c.String("hc-vault-transit") == "" && c.String("age") == "" { conf := optionalConfig var err error if conf == nil { @@ -2508,6 +2576,7 @@ func keyGroups(c *cli.Context, file string, optionalConfig *config.Config) ([]so group = append(group, kmsKeys...) group = append(group, cloudKmsKeys...) group = append(group, hckmsMkKeys...) + group = append(group, stackitKmsMkKeys...) group = append(group, azkvKeys...) group = append(group, pgpKeys...) group = append(group, hcVaultMkKeys...) diff --git a/config/config.go b/config/config.go index 511df1bc1..c2b7c7ae5 100644 --- a/config/config.go +++ b/config/config.go @@ -19,6 +19,7 @@ import ( "github.com/getsops/sops/v3/hcvault" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/stackitkms" "github.com/getsops/sops/v3/publish" "go.yaml.in/yaml/v3" ) @@ -130,14 +131,15 @@ type configFile struct { } type keyGroup struct { - Merge []keyGroup `yaml:"merge"` - KMS []kmsKey `yaml:"kms"` - GCPKMS []gcpKmsKey `yaml:"gcp_kms"` - HCKms []hckmsKey `yaml:"hckms"` - AzureKV []azureKVKey `yaml:"azure_keyvault"` - Vault []string `yaml:"hc_vault"` - Age []string `yaml:"age"` - PGP []string `yaml:"pgp"` + Merge []keyGroup `yaml:"merge"` + KMS []kmsKey `yaml:"kms"` + GCPKMS []gcpKmsKey `yaml:"gcp_kms"` + HCKms []hckmsKey `yaml:"hckms"` + StackitKms []stackitKmsKey `yaml:"stackit_kms"` + AzureKV []azureKVKey `yaml:"azure_keyvault"` + Vault []string `yaml:"hc_vault"` + Age []string `yaml:"age"` + PGP []string `yaml:"pgp"` } type gcpKmsKey struct { @@ -161,6 +163,10 @@ type hckmsKey struct { KeyID string `yaml:"key_id"` } +type stackitKmsKey struct { + ResourceID string `yaml:"resource_id"` +} + type destinationRule struct { PathRegex string `yaml:"path_regex"` S3Bucket string `yaml:"s3_bucket"` @@ -183,6 +189,7 @@ type creationRule struct { PGP interface{} `yaml:"pgp"` // string or []string GCPKMS interface{} `yaml:"gcp_kms"` // string or []string HCKms []string `yaml:"hckms"` + StackitKms interface{} `yaml:"stackit_kms"` // string or []string AzureKeyVault interface{} `yaml:"azure_keyvault"` // string or []string VaultURI interface{} `yaml:"hc_vault_transit_uri"` // string or []string KeyGroups []keyGroup `yaml:"key_groups"` @@ -213,6 +220,10 @@ func (c *creationRule) GetGCPKMSKeys() ([]string, error) { return parseKeyField(c.GCPKMS, "gcp_kms") } +func (c *creationRule) GetStackitKmsKeys() ([]string, error) { + return parseKeyField(c.StackitKms, "stackit_kms") +} + func (c *creationRule) GetAzureKeyVaultKeys() ([]string, error) { return parseKeyField(c.AzureKeyVault, "azure_keyvault") } @@ -343,6 +354,13 @@ func extractMasterKeys(group keyGroup) (sops.KeyGroup, error) { } keyGroup = append(keyGroup, key) } + for _, k := range group.StackitKms { + key, err := stackitkms.NewMasterKey(k.ResourceID) + if err != nil { + return nil, err + } + keyGroup = append(keyGroup, key) + } for _, k := range group.AzureKV { if key, err := azkv.NewMasterKeyWithOptionalVersion(k.VaultURL, k.Key, k.Version); err == nil { keyGroup = append(keyGroup, key) @@ -423,6 +441,17 @@ func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[ for _, k := range hckmsMasterKeys { keyGroup = append(keyGroup, k) } + stackitKmsKeys, err := getKeysWithValidation(cRule.GetStackitKmsKeys, "stackit_kms") + if err != nil { + return nil, err + } + stackitKmsMasterKeys, err := stackitkms.NewMasterKeyFromResourceIDString(strings.Join(stackitKmsKeys, ",")) + if err != nil { + return nil, err + } + for _, k := range stackitKmsMasterKeys { + keyGroup = append(keyGroup, k) + } azKeys, err := getKeysWithValidation(cRule.GetAzureKeyVaultKeys, "azure_keyvault") if err != nil { return nil, err diff --git a/go.mod b/go.mod index da1430456..82503a55b 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,8 @@ require ( github.com/ory/dockertest/v3 v3.12.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.4 + github.com/stackitcloud/stackit-sdk-go/core v0.22.0 + github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.2 github.com/stretchr/testify v1.11.1 github.com/urfave/cli v1.22.17 go.yaml.in/yaml/v3 v3.0.4 @@ -103,7 +105,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.9.8 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect diff --git a/go.sum b/go.sum index 5a1c8a173..869313903 100644 --- a/go.sum +++ b/go.sum @@ -184,8 +184,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.9.8 h1:5gMyLUeU1/6zl+WFfR1hN7D2kf+1/eRGa7DFtToiBvQ= github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -318,6 +318,10 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stackitcloud/stackit-sdk-go/core v0.22.0 h1:6rViz7GnNwXSh51Lur5xuDzO8EWSZfN9J0HvEkBKq6c= +github.com/stackitcloud/stackit-sdk-go/core v0.22.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.2 h1:2ulSL2IkIAKND59eAjbEhVkOoBMyvm48ojwz1a3t0U0= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.2/go.mod h1:cuIaMMiHeHQsbvy7BOFMutoV3QtN+ZBx7Tg3GmYUw7s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/keyservice/keyservice.go b/keyservice/keyservice.go index 04125f751..18af9b856 100644 --- a/keyservice/keyservice.go +++ b/keyservice/keyservice.go @@ -15,6 +15,7 @@ import ( "github.com/getsops/sops/v3/keys" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/stackitkms" ) // KeyFromMasterKey converts a SOPS internal MasterKey to an RPC Key that can be serialized with Protocol Buffers @@ -87,6 +88,14 @@ func KeyFromMasterKey(mk keys.MasterKey) Key { }, }, } + case *stackitkms.MasterKey: + return Key{ + KeyType: &Key_StackitKmsKey{ + StackitKmsKey: &StackitKmsKey{ + ResourceId: mk.ResourceID, + }, + }, + } default: panic(fmt.Sprintf("Tried to convert unknown MasterKey type %T to keyservice.Key", mk)) } diff --git a/keyservice/keyservice.proto b/keyservice/keyservice.proto index 3a471a34f..55b50f97b 100644 --- a/keyservice/keyservice.proto +++ b/keyservice/keyservice.proto @@ -11,6 +11,7 @@ message Key { VaultKey vault_key = 5; AgeKey age_key = 6; HckmsKey hckms_key = 7; + StackitKmsKey stackit_kms_key = 8; } } @@ -49,6 +50,10 @@ message HckmsKey { string key_id = 1; } +message StackitKmsKey { + string resource_id = 1; +} + message EncryptRequest { Key key = 1; bytes plaintext = 2; diff --git a/keyservice/server.go b/keyservice/server.go index c1f1e8ce8..eb26bb88d 100644 --- a/keyservice/server.go +++ b/keyservice/server.go @@ -10,6 +10,7 @@ import ( "github.com/getsops/sops/v3/hcvault" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/stackitkms" "golang.org/x/net/context" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -88,6 +89,18 @@ func (ks *Server) encryptWithVault(key *VaultKey, plaintext []byte) ([]byte, err return []byte(vaultKey.EncryptedKey), nil } +func (ks *Server) encryptWithStackitKms(key *StackitKmsKey, plaintext []byte) ([]byte, error) { + stackitKmsKey, err := stackitkms.NewMasterKey(key.ResourceId) + if err != nil { + return nil, err + } + err = stackitKmsKey.Encrypt(plaintext) + if err != nil { + return nil, err + } + return []byte(stackitKmsKey.EncryptedKey), nil +} + func (ks *Server) encryptWithAge(key *AgeKey, plaintext []byte) ([]byte, error) { ageKey := age.MasterKey{ Recipient: key.Recipient, @@ -158,6 +171,19 @@ func (ks *Server) decryptWithVault(key *VaultKey, ciphertext []byte) ([]byte, er return []byte(plaintext), err } +func (ks *Server) decryptWithStackitKms(key *StackitKmsKey, ciphertext []byte) ([]byte, error) { + stackitKmsKey, err := stackitkms.NewMasterKey(key.ResourceId) + if err != nil { + return nil, err + } + stackitKmsKey.EncryptedKey = string(ciphertext) + plaintext, err := stackitKmsKey.Decrypt() + if err != nil { + return nil, err + } + return plaintext, nil +} + func (ks *Server) decryptWithAge(key *AgeKey, ciphertext []byte) ([]byte, error) { ageKey := age.MasterKey{ Recipient: key.Recipient, @@ -230,6 +256,14 @@ func (ks Server) Encrypt(ctx context.Context, response = &EncryptResponse{ Ciphertext: ciphertext, } + case *Key_StackitKmsKey: + ciphertext, err := ks.encryptWithStackitKms(k.StackitKmsKey, req.Plaintext) + if err != nil { + return nil, err + } + response = &EncryptResponse{ + Ciphertext: ciphertext, + } case nil: return nil, status.Errorf(codes.NotFound, "Must provide a key") default: @@ -258,6 +292,8 @@ func keyToString(key *Key) string { return fmt.Sprintf("Hashicorp Vault key with URI %s/v1/%s/keys/%s", k.VaultKey.VaultAddress, k.VaultKey.EnginePath, k.VaultKey.KeyName) case *Key_HckmsKey: return fmt.Sprintf("HuaweiCloud KMS key with ID %s", k.HckmsKey.KeyId) + case *Key_StackitKmsKey: + return fmt.Sprintf("STACKIT KMS key with resource ID %s", k.StackitKmsKey.ResourceId) default: return "Unknown key type" } @@ -342,6 +378,14 @@ func (ks Server) Decrypt(ctx context.Context, response = &DecryptResponse{ Plaintext: plaintext, } + case *Key_StackitKmsKey: + plaintext, err := ks.decryptWithStackitKms(k.StackitKmsKey, req.Ciphertext) + if err != nil { + return nil, err + } + response = &DecryptResponse{ + Plaintext: plaintext, + } case nil: return nil, status.Errorf(codes.NotFound, "Must provide a key") default: diff --git a/keyservice/stackitkms.go b/keyservice/stackitkms.go new file mode 100644 index 000000000..174e7c192 --- /dev/null +++ b/keyservice/stackitkms.go @@ -0,0 +1,30 @@ +package keyservice + +// StackitKmsKey is used to serialize a STACKIT KMS key over the keyservice protocol. +// This is defined separately from the protobuf-generated types to avoid +// requiring protobuf regeneration. It follows the same pattern as other key types. +type StackitKmsKey struct { + ResourceId string `protobuf:"bytes,1,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` +} + +func (x *StackitKmsKey) GetResourceId() string { + if x != nil { + return x.ResourceId + } + return "" +} + +// Key_StackitKmsKey is a wrapper for StackitKmsKey to be used in Key.KeyType oneof. +type Key_StackitKmsKey struct { + StackitKmsKey *StackitKmsKey `protobuf:"bytes,8,opt,name=stackit_kms_key,json=stackitKmsKey,proto3,oneof"` +} + +func (*Key_StackitKmsKey) isKey_KeyType() {} + +// GetStackitKmsKey returns the StackitKmsKey from the Key, if set. +func (x *Key) GetStackitKmsKey() *StackitKmsKey { + if x, ok := x.GetKeyType().(*Key_StackitKmsKey); ok { + return x.StackitKmsKey + } + return nil +} diff --git a/stackitkms/keysource.go b/stackitkms/keysource.go new file mode 100644 index 000000000..97e1fe04f --- /dev/null +++ b/stackitkms/keysource.go @@ -0,0 +1,274 @@ +/* +Package stackitkms contains an implementation of the github.com/getsops/sops/v3/keys.MasterKey +interface that encrypts and decrypts the data key using STACKIT KMS. +*/ +package stackitkms // import "github.com/getsops/sops/v3/stackitkms" + +import ( + "context" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" + + "github.com/sirupsen/logrus" + stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/getsops/sops/v3/logging" +) + +const ( + // KeyTypeIdentifier is the string used to identify a STACKIT KMS MasterKey. + KeyTypeIdentifier = "stackit_kms" + // stackitKmsTTL is the duration after which a MasterKey requires rotation. + stackitKmsTTL = time.Hour * 24 * 30 * 6 +) + +var ( + // log is the global logger for any STACKIT KMS MasterKey. + log *logrus.Logger +) + +func init() { + log = logging.NewLogger("STACKITKMS") +} + +// MasterKey is a STACKIT KMS key used to encrypt and decrypt SOPS' data key. +type MasterKey struct { + // ResourceID is the full resource identifier in format: + // projects//regions//keyRings//keys//versions/ + ResourceID string + // ProjectID is the STACKIT project UUID. + ProjectID string + // RegionID is the STACKIT region (e.g., "eu01"). + RegionID string + // KeyRingID is the key ring UUID. + KeyRingID string + // KeyID is the key UUID. + KeyID string + // VersionNumber is the key version number. + VersionNumber int64 + // EncryptedKey stores the data key in its encrypted form. + EncryptedKey string + // CreationDate is when this MasterKey was created. + CreationDate time.Time + + // configOpts holds STACKIT SDK configuration options. + // They can be injected by a (local) keyservice.KeyServiceServer using + // Credentials.ApplyToMasterKey. + // If nil, the default credential provider chain is used. + configOpts []stackitconfig.ConfigurationOption +} + +// NewMasterKey creates a new MasterKey from a resource ID string, setting +// the creation date to the current date. +func NewMasterKey(resourceID string) (*MasterKey, error) { + projectID, regionID, keyRingID, keyID, versionNumber, err := parseResourceID(resourceID) + if err != nil { + return nil, err + } + return &MasterKey{ + ResourceID: resourceID, + ProjectID: projectID, + RegionID: regionID, + KeyRingID: keyRingID, + KeyID: keyID, + VersionNumber: versionNumber, + CreationDate: time.Now().UTC(), + }, nil +} + +// NewMasterKeyFromResourceIDString takes a comma separated list of STACKIT KMS +// resource IDs and returns a slice of new MasterKeys. +func NewMasterKeyFromResourceIDString(resourceID string) ([]*MasterKey, error) { + var keys []*MasterKey + if resourceID == "" { + return keys, nil + } + for _, s := range strings.Split(resourceID, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + k, err := NewMasterKey(s) + if err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, nil +} + +// parseResourceID parses a resource ID in format: +// projects//regions//keyRings//keys//versions/ +func parseResourceID(resourceID string) (projectID, regionID, keyRingID, keyID string, versionNumber int64, err error) { + resourceID = strings.TrimSpace(resourceID) + parts := strings.Split(resourceID, "/") + if len(parts) != 10 { + return "", "", "", "", 0, fmt.Errorf("invalid STACKIT KMS resource ID format: expected 'projects//regions//keyRings//keys//versions/', got %q", resourceID) + } + if parts[0] != "projects" || parts[2] != "regions" || parts[4] != "keyRings" || parts[6] != "keys" || parts[8] != "versions" { + return "", "", "", "", 0, fmt.Errorf("invalid STACKIT KMS resource ID format: expected 'projects//regions//keyRings//keys//versions/', got %q", resourceID) + } + projectID = parts[1] + regionID = parts[3] + keyRingID = parts[5] + keyID = parts[7] + versionNumber, err = strconv.ParseInt(parts[9], 10, 64) + if err != nil { + return "", "", "", "", 0, fmt.Errorf("invalid version number in STACKIT KMS resource ID %q: %w", resourceID, err) + } + if projectID == "" || regionID == "" || keyRingID == "" || keyID == "" { + return "", "", "", "", 0, fmt.Errorf("all components must be non-empty in STACKIT KMS resource ID %q", resourceID) + } + return projectID, regionID, keyRingID, keyID, versionNumber, nil +} + +// Credentials is a wrapper around STACKIT SDK configuration options used +// for authentication towards STACKIT KMS. +type Credentials struct { + opts []stackitconfig.ConfigurationOption +} + +// NewCredentials returns a Credentials object with the provided configuration options. +func NewCredentials(opts ...stackitconfig.ConfigurationOption) *Credentials { + return &Credentials{opts: opts} +} + +// ApplyToMasterKey configures the credentials on the provided key. +func (c Credentials) ApplyToMasterKey(key *MasterKey) { + key.configOpts = c.opts +} + +// Encrypt takes a SOPS data key, encrypts it with STACKIT KMS and stores the result +// in the EncryptedKey field. +func (key *MasterKey) Encrypt(dataKey []byte) error { + return key.EncryptContext(context.Background(), dataKey) +} + +// EncryptContext takes a SOPS data key, encrypts it with STACKIT KMS and stores the result +// in the EncryptedKey field. +func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error { + client, err := key.createKMSClient() + if err != nil { + log.WithField("resourceID", key.ResourceID).Info("Encryption failed") + return fmt.Errorf("failed to create STACKIT KMS client: %w", err) + } + + plaintext := base64.StdEncoding.EncodeToString(dataKey) + plaintextBytes := []byte(plaintext) + + result, err := client.Encrypt(ctx, key.ProjectID, key.RegionID, key.KeyRingID, key.KeyID, key.VersionNumber). + EncryptPayload(kms.EncryptPayload{ + Data: &plaintextBytes, + }).Execute() + if err != nil { + log.WithField("resourceID", key.ResourceID).Info("Encryption failed") + return fmt.Errorf("failed to encrypt sops data key with STACKIT KMS: %w", err) + } + + encryptedData := result.GetData() + if len(encryptedData) == 0 { + return fmt.Errorf("encryption response missing ciphertext") + } + key.EncryptedKey = base64.StdEncoding.EncodeToString(encryptedData) + log.WithField("resourceID", key.ResourceID).Info("Encryption succeeded") + return nil +} + +// 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 nil +} + +// EncryptedDataKey returns the encrypted 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. +func (key *MasterKey) SetEncryptedDataKey(enc []byte) { + key.EncryptedKey = string(enc) +} + +// Decrypt decrypts the EncryptedKey with STACKIT KMS and returns the result. +func (key *MasterKey) Decrypt() ([]byte, error) { + return key.DecryptContext(context.Background()) +} + +// DecryptContext decrypts the EncryptedKey with STACKIT KMS and returns the result. +func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { + client, err := key.createKMSClient() + if err != nil { + log.WithField("resourceID", key.ResourceID).Info("Decryption failed") + return nil, fmt.Errorf("failed to create STACKIT KMS client: %w", err) + } + + encryptedBytes, err := base64.StdEncoding.DecodeString(key.EncryptedKey) + if err != nil { + log.WithField("resourceID", key.ResourceID).Info("Decryption failed") + return nil, fmt.Errorf("failed to base64 decode encrypted data key: %w", err) + } + + result, err := client.Decrypt(ctx, key.ProjectID, key.RegionID, key.KeyRingID, key.KeyID, key.VersionNumber). + DecryptPayload(kms.DecryptPayload{ + Data: &encryptedBytes, + }).Execute() + if err != nil { + log.WithField("resourceID", key.ResourceID).Info("Decryption failed") + return nil, fmt.Errorf("failed to decrypt sops data key with STACKIT KMS: %w", err) + } + + decryptedData := result.GetData() + if len(decryptedData) == 0 { + return nil, fmt.Errorf("decryption response missing plaintext") + } + + plaintext, err := base64.StdEncoding.DecodeString(string(decryptedData)) + if err != nil { + log.WithField("resourceID", key.ResourceID).Info("Decryption failed") + return nil, fmt.Errorf("failed to base64 decode decrypted data key: %w", err) + } + + log.WithField("resourceID", key.ResourceID).Info("Decryption succeeded") + return plaintext, nil +} + +// NeedsRotation returns whether the data key needs to be rotated or not. +func (key *MasterKey) NeedsRotation() bool { + return time.Since(key.CreationDate) > stackitKmsTTL +} + +// ToString converts the key to a string representation. +func (key *MasterKey) ToString() string { + return key.ResourceID +} + +// ToMap converts the MasterKey to a map for serialization purposes. +func (key MasterKey) ToMap() map[string]interface{} { + out := make(map[string]interface{}) + out["resource_id"] = key.ResourceID + out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339) + out["enc"] = key.EncryptedKey + return out +} + +// TypeToIdentifier returns the string identifier for the MasterKey type. +func (key *MasterKey) TypeToIdentifier() string { + return KeyTypeIdentifier +} + +// createKMSClient creates a STACKIT KMS client with the appropriate credentials. +func (key *MasterKey) createKMSClient() (*kms.APIClient, error) { + client, err := kms.NewAPIClient(key.configOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create STACKIT KMS API client: %w", err) + } + return client, nil +} diff --git a/stackitkms/keysource_test.go b/stackitkms/keysource_test.go new file mode 100644 index 000000000..ae530db96 --- /dev/null +++ b/stackitkms/keysource_test.go @@ -0,0 +1,188 @@ +package stackitkms + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + testResourceID1 = "projects/test-project-id/regions/eu01/keyRings/test-keyring-id/keys/test-key-id/versions/1" + testResourceID2 = "projects/test-project-id/regions/eu01/keyRings/test-keyring-id/keys/test-key-id-2/versions/2" +) + +func TestNewMasterKey(t *testing.T) { + tests := []struct { + name string + resID string + expectErr bool + expectKey MasterKey + }{ + { + name: "valid resource ID", + resID: testResourceID1, + expectKey: MasterKey{ + ResourceID: testResourceID1, + ProjectID: "test-project-id", + RegionID: "eu01", + KeyRingID: "test-keyring-id", + KeyID: "test-key-id", + VersionNumber: 1, + }, + }, + { + name: "invalid format - too few parts", + resID: "projects/foo/regions/bar", + expectErr: true, + }, + { + name: "invalid format - wrong prefix", + resID: "proj/foo/regions/bar/keyRings/baz/keys/qux/versions/1", + expectErr: true, + }, + { + name: "invalid format - bad version number", + resID: "projects/foo/regions/bar/keyRings/baz/keys/qux/versions/abc", + expectErr: true, + }, + { + name: "invalid format - empty project", + resID: "projects//regions/bar/keyRings/baz/keys/qux/versions/1", + expectErr: true, + }, + { + name: "invalid format - empty string", + resID: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, err := NewMasterKey(tt.resID) + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, key) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expectKey.ResourceID, key.ResourceID) + assert.Equal(t, tt.expectKey.ProjectID, key.ProjectID) + assert.Equal(t, tt.expectKey.RegionID, key.RegionID) + assert.Equal(t, tt.expectKey.KeyRingID, key.KeyRingID) + assert.Equal(t, tt.expectKey.KeyID, key.KeyID) + assert.Equal(t, tt.expectKey.VersionNumber, key.VersionNumber) + assert.NotNil(t, key.CreationDate) + }) + } +} + +func TestNewMasterKeyFromResourceIDString(t *testing.T) { + tests := []struct { + name string + resIDString string + expectErr bool + expectKeyCount int + }{ + { + name: "single resource ID", + resIDString: testResourceID1, + expectKeyCount: 1, + }, + { + name: "multiple resource IDs", + resIDString: testResourceID1 + "," + testResourceID2, + expectKeyCount: 2, + }, + { + name: "multiple with spaces", + resIDString: " " + testResourceID1 + " , " + testResourceID2 + " ", + expectKeyCount: 2, + }, + { + name: "empty string", + resIDString: "", + expectKeyCount: 0, + }, + { + name: "invalid resource ID in list", + resIDString: testResourceID1 + ",invalid-id", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keys, err := NewMasterKeyFromResourceIDString(tt.resIDString) + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, keys) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expectKeyCount, len(keys)) + }) + } +} + +func TestMasterKey_ToString(t *testing.T) { + key, err := NewMasterKey(testResourceID1) + assert.NoError(t, err) + assert.Equal(t, testResourceID1, key.ToString()) +} + +func TestMasterKey_ToMap(t *testing.T) { + key, err := NewMasterKey(testResourceID1) + assert.NoError(t, err) + key.EncryptedKey = "test-encrypted-key" + key.CreationDate = time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + + m := key.ToMap() + assert.Equal(t, testResourceID1, m["resource_id"]) + assert.Equal(t, "test-encrypted-key", m["enc"]) + assert.Equal(t, "2025-01-01T12:00:00Z", m["created_at"]) +} + +func TestMasterKey_TypeToIdentifier(t *testing.T) { + key, err := NewMasterKey(testResourceID1) + assert.NoError(t, err) + assert.Equal(t, KeyTypeIdentifier, key.TypeToIdentifier()) +} + +func TestMasterKey_NeedsRotation(t *testing.T) { + key, err := NewMasterKey(testResourceID1) + assert.NoError(t, err) + + // New key should not need rotation + assert.False(t, key.NeedsRotation()) + + // Key older than TTL should need rotation + key.CreationDate = time.Now().UTC().Add(-stackitKmsTTL - time.Hour) + assert.True(t, key.NeedsRotation()) +} + +func TestMasterKey_EncryptedDataKey(t *testing.T) { + key, err := NewMasterKey(testResourceID1) + assert.NoError(t, err) + key.EncryptedKey = "test-encrypted-data" + assert.Equal(t, []byte("test-encrypted-data"), key.EncryptedDataKey()) +} + +func TestMasterKey_SetEncryptedDataKey(t *testing.T) { + key, err := NewMasterKey(testResourceID1) + assert.NoError(t, err) + key.SetEncryptedDataKey([]byte("test-encrypted-data")) + assert.Equal(t, "test-encrypted-data", key.EncryptedKey) +} + +func TestMasterKey_EncryptIfNeeded(t *testing.T) { + key, err := NewMasterKey(testResourceID1) + assert.NoError(t, err) + + // Key with encrypted data should not attempt encryption + key.EncryptedKey = "already-encrypted" + err = key.EncryptIfNeeded([]byte("test-data")) + assert.NoError(t, err) + assert.Equal(t, "already-encrypted", key.EncryptedKey) +} diff --git a/stores/stores.go b/stores/stores.go index 11e362a5d..d926d24a9 100644 --- a/stores/stores.go +++ b/stores/stores.go @@ -23,6 +23,7 @@ import ( "github.com/getsops/sops/v3/hcvault" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" + "github.com/getsops/sops/v3/stackitkms" ) const ( @@ -46,10 +47,11 @@ type SopsFile struct { type Metadata struct { ShamirThreshold int `yaml:"shamir_threshold,omitempty" json:"shamir_threshold,omitempty"` KeyGroups []keygroup `yaml:"key_groups,omitempty" json:"key_groups,omitempty"` - KMSKeys []kmskey `yaml:"kms,omitempty" json:"kms,omitempty"` - GCPKMSKeys []gcpkmskey `yaml:"gcp_kms,omitempty" json:"gcp_kms,omitempty"` - HCKmsKeys []hckmskey `yaml:"hckms,omitempty" json:"hckms,omitempty"` - AzureKeyVaultKeys []azkvkey `yaml:"azure_kv,omitempty" json:"azure_kv,omitempty"` + KMSKeys []kmskey `yaml:"kms,omitempty" json:"kms,omitempty"` + GCPKMSKeys []gcpkmskey `yaml:"gcp_kms,omitempty" json:"gcp_kms,omitempty"` + HCKmsKeys []hckmskey `yaml:"hckms,omitempty" json:"hckms,omitempty"` + StackitKmsKeys []stackitkmskey `yaml:"stackit_kms,omitempty" json:"stackit_kms,omitempty"` + AzureKeyVaultKeys []azkvkey `yaml:"azure_kv,omitempty" json:"azure_kv,omitempty"` VaultKeys []vaultkey `yaml:"hc_vault,omitempty" json:"hc_vault,omitempty"` AgeKeys []agekey `yaml:"age,omitempty" json:"age,omitempty"` LastModified string `yaml:"lastmodified" json:"lastmodified"` @@ -66,13 +68,14 @@ type Metadata struct { } type keygroup struct { - PGPKeys []pgpkey `yaml:"pgp,omitempty" json:"pgp,omitempty"` - KMSKeys []kmskey `yaml:"kms,omitempty" json:"kms,omitempty"` - GCPKMSKeys []gcpkmskey `yaml:"gcp_kms,omitempty" json:"gcp_kms,omitempty"` - HCKmsKeys []hckmskey `yaml:"hckms,omitempty" json:"hckms,omitempty"` - AzureKeyVaultKeys []azkvkey `yaml:"azure_kv,omitempty" json:"azure_kv,omitempty"` - VaultKeys []vaultkey `yaml:"hc_vault" json:"hc_vault"` - AgeKeys []agekey `yaml:"age" json:"age"` + PGPKeys []pgpkey `yaml:"pgp,omitempty" json:"pgp,omitempty"` + KMSKeys []kmskey `yaml:"kms,omitempty" json:"kms,omitempty"` + GCPKMSKeys []gcpkmskey `yaml:"gcp_kms,omitempty" json:"gcp_kms,omitempty"` + HCKmsKeys []hckmskey `yaml:"hckms,omitempty" json:"hckms,omitempty"` + StackitKmsKeys []stackitkmskey `yaml:"stackit_kms,omitempty" json:"stackit_kms,omitempty"` + AzureKeyVaultKeys []azkvkey `yaml:"azure_kv,omitempty" json:"azure_kv,omitempty"` + VaultKeys []vaultkey `yaml:"hc_vault" json:"hc_vault"` + AgeKeys []agekey `yaml:"age" json:"age"` } type pgpkey struct { @@ -123,6 +126,12 @@ type hckmskey struct { EncryptedDataKey string `yaml:"enc" json:"enc"` } +type stackitkmskey struct { + ResourceID string `yaml:"resource_id" json:"resource_id"` + CreatedAt string `yaml:"created_at" json:"created_at"` + EncryptedDataKey string `yaml:"enc" json:"enc"` +} + // MetadataFromInternal converts an internal SOPS metadata representation to a representation appropriate for storage func MetadataFromInternal(sopsMetadata sops.Metadata) Metadata { var m Metadata @@ -143,6 +152,7 @@ func MetadataFromInternal(sopsMetadata sops.Metadata) Metadata { m.KMSKeys = kmsKeysFromGroup(group) m.GCPKMSKeys = gcpkmsKeysFromGroup(group) m.HCKmsKeys = hckmsKeysFromGroup(group) + m.StackitKmsKeys = stackitKmsKeysFromGroup(group) m.VaultKeys = vaultKeysFromGroup(group) m.AzureKeyVaultKeys = azkvKeysFromGroup(group) m.AgeKeys = ageKeysFromGroup(group) @@ -153,6 +163,7 @@ func MetadataFromInternal(sopsMetadata sops.Metadata) Metadata { PGPKeys: pgpKeysFromGroup(group), GCPKMSKeys: gcpkmsKeysFromGroup(group), HCKmsKeys: hckmsKeysFromGroup(group), + StackitKmsKeys: stackitKmsKeysFromGroup(group), VaultKeys: vaultKeysFromGroup(group), AzureKeyVaultKeys: azkvKeysFromGroup(group), AgeKeys: ageKeysFromGroup(group), @@ -252,6 +263,20 @@ func ageKeysFromGroup(group sops.KeyGroup) (keys []agekey) { return } +func stackitKmsKeysFromGroup(group sops.KeyGroup) (keys []stackitkmskey) { + for _, key := range group { + switch key := key.(type) { + case *stackitkms.MasterKey: + keys = append(keys, stackitkmskey{ + ResourceID: key.ResourceID, + CreatedAt: key.CreationDate.Format(time.RFC3339), + EncryptedDataKey: key.EncryptedKey, + }) + } + } + return +} + func hckmsKeysFromGroup(group sops.KeyGroup) (keys []hckmskey) { for _, key := range group { switch key := key.(type) { @@ -320,7 +345,7 @@ func (m *Metadata) ToInternal() (sops.Metadata, error) { }, nil } -func internalGroupFrom(kmsKeys []kmskey, pgpKeys []pgpkey, gcpKmsKeys []gcpkmskey, hckmsKeys []hckmskey, azkvKeys []azkvkey, vaultKeys []vaultkey, ageKeys []agekey) (sops.KeyGroup, error) { +func internalGroupFrom(kmsKeys []kmskey, pgpKeys []pgpkey, gcpKmsKeys []gcpkmskey, hckmsKeys []hckmskey, stackitKmsKeys []stackitkmskey, azkvKeys []azkvkey, vaultKeys []vaultkey, ageKeys []agekey) (sops.KeyGroup, error) { var internalGroup sops.KeyGroup for _, kmsKey := range kmsKeys { k, err := kmsKey.toInternal() @@ -343,6 +368,13 @@ func internalGroupFrom(kmsKeys []kmskey, pgpKeys []pgpkey, gcpKmsKeys []gcpkmske } internalGroup = append(internalGroup, k) } + for _, stackitKmsKey := range stackitKmsKeys { + k, err := stackitKmsKey.toInternal() + if err != nil { + return nil, err + } + internalGroup = append(internalGroup, k) + } for _, azkvKey := range azkvKeys { k, err := azkvKey.toInternal() if err != nil { @@ -376,8 +408,8 @@ func internalGroupFrom(kmsKeys []kmskey, pgpKeys []pgpkey, gcpKmsKeys []gcpkmske func (m *Metadata) internalKeygroups() ([]sops.KeyGroup, error) { var internalGroups []sops.KeyGroup - if len(m.PGPKeys) > 0 || len(m.KMSKeys) > 0 || len(m.GCPKMSKeys) > 0 || len(m.HCKmsKeys) > 0 || len(m.AzureKeyVaultKeys) > 0 || len(m.VaultKeys) > 0 || len(m.AgeKeys) > 0 { - internalGroup, err := internalGroupFrom(m.KMSKeys, m.PGPKeys, m.GCPKMSKeys, m.HCKmsKeys, m.AzureKeyVaultKeys, m.VaultKeys, m.AgeKeys) + if len(m.PGPKeys) > 0 || len(m.KMSKeys) > 0 || len(m.GCPKMSKeys) > 0 || len(m.HCKmsKeys) > 0 || len(m.StackitKmsKeys) > 0 || len(m.AzureKeyVaultKeys) > 0 || len(m.VaultKeys) > 0 || len(m.AgeKeys) > 0 { + internalGroup, err := internalGroupFrom(m.KMSKeys, m.PGPKeys, m.GCPKMSKeys, m.HCKmsKeys, m.StackitKmsKeys, m.AzureKeyVaultKeys, m.VaultKeys, m.AgeKeys) if err != nil { return nil, err } @@ -385,7 +417,7 @@ func (m *Metadata) internalKeygroups() ([]sops.KeyGroup, error) { return internalGroups, nil } else if len(m.KeyGroups) > 0 { for _, group := range m.KeyGroups { - internalGroup, err := internalGroupFrom(group.KMSKeys, group.PGPKeys, group.GCPKMSKeys, group.HCKmsKeys, group.AzureKeyVaultKeys, group.VaultKeys, group.AgeKeys) + internalGroup, err := internalGroupFrom(group.KMSKeys, group.PGPKeys, group.GCPKMSKeys, group.HCKmsKeys, group.StackitKmsKeys, group.AzureKeyVaultKeys, group.VaultKeys, group.AgeKeys) if err != nil { return nil, err } @@ -471,6 +503,20 @@ func (ageKey *agekey) toInternal() (*age.MasterKey, error) { }, nil } +func (stackitKmsKey *stackitkmskey) toInternal() (*stackitkms.MasterKey, error) { + creationDate, err := time.Parse(time.RFC3339, stackitKmsKey.CreatedAt) + if err != nil { + return nil, err + } + key, err := stackitkms.NewMasterKey(stackitKmsKey.ResourceID) + if err != nil { + return nil, err + } + key.EncryptedKey = stackitKmsKey.EncryptedDataKey + key.CreationDate = creationDate + return key, nil +} + func (hckmsKey *hckmskey) toInternal() (*hckms.MasterKey, error) { creationDate, err := time.Parse(time.RFC3339, hckmsKey.CreatedAt) if err != nil {