From f080bdc4176740eddab33e39b50b4d68beeb0176 Mon Sep 17 00:00:00 2001 From: rkthtrifork Date: Thu, 19 Mar 2026 14:57:01 +0100 Subject: [PATCH] Add offline Azure Key Vault encryption support Signed-off-by: rkthtrifork --- README.rst | 25 +++ azkv/keysource.go | 155 ++++++++++++++++++- azkv/keysource_test.go | 93 +++++++++++ cmd/sops/subcommand/keyservice/keyservice.go | 2 +- config/config.go | 121 +++++++++++++-- config/config_test.go | 40 +++++ keyservice/client.go | 2 +- keyservice/keyservice.go | 7 +- keyservice/keyservice.pb.go | 81 +++++----- keyservice/keyservice.proto | 1 + keyservice/keyservice_grpc.pb.go | 8 +- keyservice/server.go | 15 +- keyservice/server_test.go | 28 ++++ stores/stores.go | 11 ++ stores/stores_test.go | 38 ++++- 15 files changed, 553 insertions(+), 74 deletions(-) diff --git a/README.rst b/README.rst index d3cef70b72..b7e97e9d0e 100644 --- a/README.rst +++ b/README.rst @@ -426,6 +426,24 @@ or, without the version:: $ sops encrypt --azure-kv https://sops.vault.azure.net/keys/sops-key/ test.yaml > test.enc.yaml +For offline encryption, SOPS can encrypt the data key locally with a downloaded +Azure Key Vault RSA public key while still using Azure Key Vault for +decryption. Configure the Azure key as an object in ``.sops.yaml`` and provide +``publicKeyFile``. In this mode, ``version`` must be set because SOPS does not +contact Azure to resolve the latest version during encryption. SOPS persists the +public key in file metadata so later offline edits and rotations can keep +rewrapping the data key without network access. + +.. code:: yaml + + creation_rules: + - path_regex: \.prod\.yaml$ + azure_keyvault: + - vaultUrl: https://sops.vault.azure.net + key: sops-key + version: some-string + publicKeyFile: ./keys/sops-key.pub + And decrypt it using:: $ sops decrypt test.enc.yaml @@ -1976,6 +1994,13 @@ A key group supports the following keys: * ``version`` (string, can be empty): the version of the key to use. If empty, the latest key will be used on encryption. + Optional keys: + + * ``publicKeyFile`` (string): local path to an Azure Key Vault RSA public + key file for offline encryption. When set, encryption uses the local public + key and decryption still calls Azure Key Vault. ``version`` must be set in + this mode. + Example: .. code:: yaml diff --git a/azkv/keysource.go b/azkv/keysource.go index d7a73547f5..ac51cf54a3 100644 --- a/azkv/keysource.go +++ b/azkv/keysource.go @@ -7,8 +7,16 @@ package azkv // import "github.com/getsops/sops/v3/azkv" import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" "encoding/base64" + "encoding/json" + "encoding/pem" "fmt" + "math/big" + "os" "regexp" "strings" "time" @@ -54,6 +62,9 @@ type MasterKey struct { // CreationDate of the MasterKey, used to determine if the EncryptedKey // needs rotation. CreationDate time.Time + // PublicKey contains an optional locally provided public key used for + // offline encryption. Decryption still uses Azure Key Vault. + PublicKey []byte // tokenCredential contains the azcore.TokenCredential used by the Azure // client. It can be injected by a (local) keyservice.KeyServiceServer @@ -64,6 +75,18 @@ type MasterKey struct { clientOptions *azkeys.ClientOptions } +type jsonWebKeyEnvelope struct { + Key *jsonWebKey `json:"key"` +} + +type jsonWebKey struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + X5c []string `json:"x5c"` +} + // newMasterKey creates a new MasterKey from a URL, key name and version, // setting the creation date to the current date. func newMasterKey(vaultURL string, keyName string, keyVersion string) *MasterKey { @@ -91,6 +114,30 @@ func NewMasterKeyWithOptionalVersion(vaultURL string, keyName string, keyVersion return key, nil } +// NewMasterKeyWithPublicKey creates a new MasterKey that encrypts data keys +// offline with the provided public key and decrypts them through Azure Key Vault. +func NewMasterKeyWithPublicKey(vaultURL string, keyName string, keyVersion string, publicKey []byte) (*MasterKey, error) { + key := newMasterKey(vaultURL, keyName, keyVersion) + key.PublicKey = append([]byte(nil), publicKey...) + + if key.Version == "" { + return nil, fmt.Errorf("azure key vault offline encryption requires a key version") + } + if _, err := parsePublicKey(key.PublicKey); err != nil { + return nil, err + } + return key, nil +} + +// NewMasterKeyWithPublicKeyFile creates a new MasterKey from a local public key file. +func NewMasterKeyWithPublicKeyFile(vaultURL string, keyName string, keyVersion string, publicKeyPath string) (*MasterKey, error) { + publicKey, err := os.ReadFile(publicKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read Azure Key Vault public key file %q: %w", publicKeyPath, err) + } + return NewMasterKeyWithPublicKey(vaultURL, keyName, keyVersion, publicKey) +} + // NewMasterKeyFromURL takes an Azure Key Vault key URL, and returns a new // MasterKey. The URL format is {vaultUrl}/keys/{keyName}/{keyVersion}. func NewMasterKeyFromURL(url string) (*MasterKey, error) { @@ -171,7 +218,7 @@ func (key *MasterKey) Encrypt(dataKey []byte) error { } func (key *MasterKey) ensureKeyHasVersion(ctx context.Context) error { - if (key.Version != "") { + if key.Version != "" { // Nothing to do return nil } @@ -203,6 +250,10 @@ func (key *MasterKey) ensureKeyHasVersion(ctx context.Context) error { // EncryptContext takes a SOPS data key, encrypts it with Azure Key Vault, and stores // the result in the EncryptedKey field. func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error { + if len(key.PublicKey) > 0 { + return key.encryptOffline(dataKey) + } + token, err := key.getTokenCredential() if err != nil { log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed") @@ -230,6 +281,23 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error return nil } +func (key *MasterKey) encryptOffline(dataKey []byte) error { + publicKey, err := parsePublicKey(key.PublicKey) + if err != nil { + log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed") + return err + } + + encryptedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, dataKey, nil) + if err != nil { + log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed") + return fmt.Errorf("failed to encrypt sops data key locally with Azure Key Vault public key '%s': %w", key.ToString(), err) + } + key.SetEncryptedDataKey([]byte(base64.RawURLEncoding.EncodeToString(encryptedKey))) + log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Offline encryption succeeded") + return nil +} + // EncryptedDataKey returns the encrypted data key this master key holds. func (key *MasterKey) EncryptedDataKey() []byte { return []byte(key.EncryptedKey) @@ -306,6 +374,9 @@ func (key MasterKey) ToMap() map[string]interface{} { out["vaultUrl"] = key.VaultURL out["key"] = key.Name out["version"] = key.Version + if len(key.PublicKey) > 0 { + out["public_key"] = base64.StdEncoding.EncodeToString(key.PublicKey) + } out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339) out["enc"] = key.EncryptedKey return out @@ -324,3 +395,85 @@ func (key *MasterKey) getTokenCredential() (azcore.TokenCredential, error) { } return key.tokenCredential, nil } + +func parsePublicKey(raw []byte) (*rsa.PublicKey, error) { + if jwk, err := parseJSONWebKey(raw); err == nil { + return jwk, nil + } + if pemKey, err := parsePEMPublicKey(raw); err == nil { + return pemKey, nil + } + return nil, fmt.Errorf("failed to parse Azure Key Vault public key: expected RSA JWK or PEM") +} + +func parseJSONWebKey(raw []byte) (*rsa.PublicKey, error) { + var key jsonWebKey + if err := json.Unmarshal(raw, &key); err != nil { + var envelope jsonWebKeyEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Key == nil { + return nil, fmt.Errorf("invalid JSON Web Key") + } + key = *envelope.Key + } + if key.N == "" || key.E == "" { + return nil, fmt.Errorf("json web key is missing modulus or exponent") + } + if key.Kty != "" && key.Kty != "RSA" && key.Kty != "RSA-HSM" { + return nil, fmt.Errorf("unsupported Azure Key Vault key type %q", key.Kty) + } + + modulusBytes, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + return nil, fmt.Errorf("failed to decode JSON Web Key modulus: %w", err) + } + exponentBytes, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + return nil, fmt.Errorf("failed to decode JSON Web Key exponent: %w", err) + } + exponent := 0 + for _, b := range exponentBytes { + exponent = exponent<<8 + int(b) + } + if exponent == 0 { + return nil, fmt.Errorf("json web key exponent is invalid") + } + + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(modulusBytes), + E: exponent, + }, nil +} + +func parsePEMPublicKey(raw []byte) (*rsa.PublicKey, error) { + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + + switch block.Type { + case "PUBLIC KEY": + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + rsaKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("PEM public key is not RSA") + } + return rsaKey, nil + case "RSA PUBLIC KEY": + return x509.ParsePKCS1PublicKey(block.Bytes) + case "CERTIFICATE": + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + rsaKey, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("certificate public key is not RSA") + } + return rsaKey, nil + default: + return nil, fmt.Errorf("unsupported PEM block type %q", block.Type) + } +} diff --git a/azkv/keysource_test.go b/azkv/keysource_test.go index cc636f4366..8dd5321807 100644 --- a/azkv/keysource_test.go +++ b/azkv/keysource_test.go @@ -1,11 +1,21 @@ package azkv import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( @@ -178,6 +188,23 @@ func TestMasterKey_EncryptIfNeeded(t *testing.T) { assert.NoError(t, key.EncryptIfNeeded([]byte("other data"))) assert.Equal(t, encryptedKey, key.EncryptedKey) }) + + t.Run("offline with public key", func(t *testing.T) { + privateKey := mustGenerateRSAKey(t) + publicKey := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: mustMarshalPKIXPublicKey(t, &privateKey.PublicKey), + }) + + key, err := NewMasterKeyWithPublicKey("https://test.vault.azure.net", "test-key", "test-version", publicKey) + require.NoError(t, err) + + require.NoError(t, key.EncryptIfNeeded([]byte("other data"))) + + plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, mustDecodeRawURL(t, key.EncryptedKey), nil) + require.NoError(t, err) + assert.Equal(t, []byte("other data"), plaintext) + }) } func TestMasterKey_NeedsRotation(t *testing.T) { @@ -210,6 +237,72 @@ func TestMasterKey_ToMap(t *testing.T) { }, key.ToMap()) } +func TestNewMasterKeyWithPublicKeyFile(t *testing.T) { + privateKey := mustGenerateRSAKey(t) + publicKeyPath := filepath.Join(t.TempDir(), "pub.pem") + err := os.WriteFile(publicKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: mustMarshalPKIXPublicKey(t, &privateKey.PublicKey), + }), 0o600) + require.NoError(t, err) + + key, err := NewMasterKeyWithPublicKeyFile("https://test.vault.azure.net", "test-key", "test-version", publicKeyPath) + require.NoError(t, err) + assert.Equal(t, "test-version", key.Version) + assert.NotEmpty(t, key.PublicKey) +} + +func TestMasterKey_EncryptOfflineWithJWK(t *testing.T) { + privateKey := mustGenerateRSAKey(t) + jwkBytes, err := json.Marshal(map[string]string{ + "kty": "RSA", + "n": base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(bigEndianBytes(privateKey.E)), + }) + require.NoError(t, err) + + key, err := NewMasterKeyWithPublicKey("https://test.vault.azure.net", "test-key", "test-version", jwkBytes) + require.NoError(t, err) + require.NoError(t, key.Encrypt([]byte("secret"))) + + plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, mustDecodeRawURL(t, key.EncryptedKey), nil) + require.NoError(t, err) + assert.Equal(t, []byte("secret"), plaintext) +} + +func mustGenerateRSAKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return privateKey +} + +func mustMarshalPKIXPublicKey(t *testing.T, publicKey *rsa.PublicKey) []byte { + t.Helper() + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + require.NoError(t, err) + return publicKeyDER +} + +func mustDecodeRawURL(t *testing.T, value string) []byte { + t.Helper() + decoded, err := base64.RawURLEncoding.DecodeString(value) + require.NoError(t, err) + return decoded +} + +func bigEndianBytes(v int) []byte { + if v == 0 { + return []byte{0} + } + var out []byte + for v > 0 { + out = append([]byte{byte(v & 0xff)}, out...) + v >>= 8 + } + return out +} + func TestMasterKey_getTokenCredential(t *testing.T) { t.Run("with TokenCredential", func(t *testing.T) { credential, err := azidentity.NewUsernamePasswordCredential("tenant", "client", "username", "password", nil) diff --git a/cmd/sops/subcommand/keyservice/keyservice.go b/cmd/sops/subcommand/keyservice/keyservice.go index c28f636904..b69e999084 100644 --- a/cmd/sops/subcommand/keyservice/keyservice.go +++ b/cmd/sops/subcommand/keyservice/keyservice.go @@ -34,7 +34,7 @@ func Run(opts Opts) error { } defer lis.Close() grpcServer := grpc.NewServer() - keyservice.RegisterKeyServiceServer(grpcServer, keyservice.Server{ + keyservice.RegisterKeyServiceServer(grpcServer, &keyservice.Server{ Prompt: opts.Prompt, }) log.Infof("Listening on %s://%s", opts.Network, opts.Address) diff --git a/config/config.go b/config/config.go index 511df1bc15..4d15e39e06 100644 --- a/config/config.go +++ b/config/config.go @@ -152,9 +152,10 @@ type kmsKey struct { } type azureKVKey struct { - VaultURL string `yaml:"vaultUrl"` - Key string `yaml:"key"` - Version string `yaml:"version"` + VaultURL string `yaml:"vaultUrl"` + Key string `yaml:"key"` + Version string `yaml:"version"` + PublicKeyFile string `yaml:"publicKeyFile"` } type hckmsKey struct { @@ -217,6 +218,33 @@ func (c *creationRule) GetAzureKeyVaultKeys() ([]string, error) { return parseKeyField(c.AzureKeyVault, "azure_keyvault") } +func (c *creationRule) GetAzureKeyVaultMasterKeys(configDir string) ([]*azkv.MasterKey, error) { + switch v := c.AzureKeyVault.(type) { + case nil: + return nil, nil + case string, []string: + keys, err := c.GetAzureKeyVaultKeys() + if err != nil { + return nil, err + } + return azkv.MasterKeysFromURLs(strings.Join(keys, ",")) + case []interface{}: + if len(v) == 0 { + return nil, nil + } + if _, ok := v[0].(string); ok { + keys, err := c.GetAzureKeyVaultKeys() + if err != nil { + return nil, err + } + return azkv.MasterKeysFromURLs(strings.Join(keys, ",")) + } + return azureMasterKeysFromInterfaceList(v, configDir) + default: + return nil, fmt.Errorf("invalid azure_keyvault key configuration: expected string, []string, []object, or nil, got %T", c.AzureKeyVault) + } +} + func (c *creationRule) GetVaultURIs() ([]string, error) { return parseKeyField(c.VaultURI, "hc_vault_transit_uri") } @@ -308,10 +336,10 @@ func deduplicateKeygroup(group sops.KeyGroup) sops.KeyGroup { return deduplicatedKeygroup } -func extractMasterKeys(group keyGroup) (sops.KeyGroup, error) { +func extractMasterKeys(group keyGroup, configDir string) (sops.KeyGroup, error) { var keyGroup sops.KeyGroup for _, k := range group.Merge { - subKeyGroup, err := extractMasterKeys(k) + subKeyGroup, err := extractMasterKeys(k, configDir) if err != nil { return nil, err } @@ -344,7 +372,8 @@ func extractMasterKeys(group keyGroup) (sops.KeyGroup, error) { keyGroup = append(keyGroup, key) } for _, k := range group.AzureKV { - if key, err := azkv.NewMasterKeyWithOptionalVersion(k.VaultURL, k.Key, k.Version); err == nil { + key, err := newAzureMasterKeyFromConfig(k, configDir) + if err == nil { keyGroup = append(keyGroup, key) } else { return nil, err @@ -369,10 +398,14 @@ func getKeysWithValidation(getKeysFunc func() ([]string, error), keyType string) } func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[string]*string) ([]sops.KeyGroup, error) { + return getKeyGroupsFromCreationRuleWithConfigDir(cRule, kmsEncryptionContext, "") +} + +func getKeyGroupsFromCreationRuleWithConfigDir(cRule *creationRule, kmsEncryptionContext map[string]*string, configDir string) ([]sops.KeyGroup, error) { var groups []sops.KeyGroup if len(cRule.KeyGroups) > 0 { for _, group := range cRule.KeyGroups { - keyGroup, err := extractMasterKeys(group) + keyGroup, err := extractMasterKeys(group, configDir) if err != nil { return nil, err } @@ -423,13 +456,9 @@ func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[ for _, k := range hckmsMasterKeys { keyGroup = append(keyGroup, k) } - azKeys, err := getKeysWithValidation(cRule.GetAzureKeyVaultKeys, "azure_keyvault") + azureKeys, err := cRule.GetAzureKeyVaultMasterKeys(configDir) if err != nil { - return nil, err - } - azureKeys, err := azkv.MasterKeysFromURLs(strings.Join(azKeys, ",")) - if err != nil { - return nil, err + return nil, fmt.Errorf("invalid azure_keyvault key configuration: %w", err) } for _, k := range azureKeys { keyGroup = append(keyGroup, k) @@ -450,6 +479,64 @@ func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[ return groups, nil } +func newAzureMasterKeyFromConfig(k azureKVKey, configDir string) (*azkv.MasterKey, error) { + if k.PublicKeyFile != "" { + return azkv.NewMasterKeyWithPublicKeyFile(k.VaultURL, k.Key, k.Version, resolveConfigPath(configDir, k.PublicKeyFile)) + } + return azkv.NewMasterKeyWithOptionalVersion(k.VaultURL, k.Key, k.Version) +} + +func azureMasterKeysFromInterfaceList(items []interface{}, configDir string) ([]*azkv.MasterKey, error) { + var out []*azkv.MasterKey + for _, item := range items { + cfg, err := azureKVKeyFromInterface(item) + if err != nil { + return nil, err + } + key, err := newAzureMasterKeyFromConfig(cfg, configDir) + if err != nil { + return nil, err + } + out = append(out, key) + } + return out, nil +} + +func resolveConfigPath(configDir string, p string) string { + if p == "" || filepath.IsAbs(p) || configDir == "" { + return p + } + return filepath.Join(configDir, p) +} + +func azureKVKeyFromInterface(item interface{}) (azureKVKey, error) { + asMap, ok := item.(map[interface{}]interface{}) + if ok { + normalized := make(map[string]interface{}, len(asMap)) + for k, v := range asMap { + key, ok := k.(string) + if !ok { + return azureKVKey{}, fmt.Errorf("invalid azure_keyvault key configuration: expected string map key, got %T", k) + } + normalized[key] = v + } + item = normalized + } + + raw, err := yaml.Marshal(item) + if err != nil { + return azureKVKey{}, err + } + var cfg azureKVKey + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return azureKVKey{}, err + } + if cfg.VaultURL == "" || cfg.Key == "" { + return azureKVKey{}, fmt.Errorf("invalid azure_keyvault key configuration: vaultUrl and key are required") + } + return cfg, nil +} + func loadConfigFile(confPath string) (*configFile, error) { confBytes, err := os.ReadFile(confPath) if err != nil { @@ -464,7 +551,7 @@ func loadConfigFile(confPath string) (*configFile, error) { return conf, nil } -func configFromRule(rule *creationRule, kmsEncryptionContext map[string]*string) (*Config, error) { +func configFromRule(rule *creationRule, kmsEncryptionContext map[string]*string, configDir string) (*Config, error) { cryptRuleCount := 0 if rule.UnencryptedSuffix != "" { cryptRuleCount++ @@ -489,7 +576,7 @@ 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") } - groups, err := getKeyGroupsFromCreationRule(rule, kmsEncryptionContext) + groups, err := getKeyGroupsFromCreationRuleWithConfigDir(rule, kmsEncryptionContext, configDir) if err != nil { return nil, err } @@ -557,7 +644,7 @@ func parseDestinationRuleForFile(conf *configFile, filePath string, kmsEncryptio dest = publish.NewVaultDestination(dRule.VaultAddress, dRule.VaultPath, dRule.VaultKVMountName, dRule.VaultKVVersion) } - config, err := configFromRule(rule, kmsEncryptionContext) + config, err := configFromRule(rule, kmsEncryptionContext, "") if err != nil { return nil, err } @@ -602,7 +689,7 @@ func parseCreationRuleForFile(conf *configFile, confPath, filePath string, kmsEn return nil, fmt.Errorf("error loading config: no matching creation rules found") } - config, err := configFromRule(rule, kmsEncryptionContext) + config, err := configFromRule(rule, kmsEncryptionContext, configDir) if err != nil { return nil, err } diff --git a/config/config_test.go b/config/config_test.go index 04bed7f564..4f5fa9594d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,13 +1,20 @@ package config import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "fmt" "os" "path" + "path/filepath" "testing" + "github.com/getsops/sops/v3/azkv" "github.com/getsops/sops/v3/keys" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type mockFS struct { @@ -768,6 +775,39 @@ creation_rules: assert.Equal(t, 1, keyTypeCounts["hc_vault"]) } +func TestCreationRuleAzureKeyVaultObjectListSupportsOfflinePublicKey(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + publicKeyPath := filepath.Join(t.TempDir(), "azure.pub.pem") + err = os.WriteFile(publicKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }), 0o600) + require.NoError(t, err) + + sampleConfigWithAzureKeyVaultObjectList := []byte(fmt.Sprintf(` +creation_rules: + - path_regex: "" + azure_keyvault: + - vaultUrl: https://foo.vault.azure.net + key: foo-key + version: fooversion + publicKeyFile: %s +`, publicKeyPath)) + + conf, err := parseCreationRuleForFile(parseConfigFile(sampleConfigWithAzureKeyVaultObjectList, t), "/conf/path", "whatever", nil) + require.NoError(t, err) + require.Len(t, conf.KeyGroups, 1) + require.Len(t, conf.KeyGroups[0], 1) + + key, ok := conf.KeyGroups[0][0].(*azkv.MasterKey) + require.True(t, ok) + assert.Equal(t, "https://foo.vault.azure.net/keys/foo-key/fooversion", key.ToString()) + assert.NotEmpty(t, key.PublicKey) +} + // Test configurations with multiple destinations should fail var sampleConfigWithS3GCSConflict = []byte(` destination_rules: diff --git a/keyservice/client.go b/keyservice/client.go index f0a29fd06a..6ba02e64fc 100644 --- a/keyservice/client.go +++ b/keyservice/client.go @@ -13,7 +13,7 @@ type LocalClient struct { // NewLocalClient creates a new local client func NewLocalClient() LocalClient { - return LocalClient{Server{}} + return LocalClient{&Server{}} } // NewCustomLocalClient creates a new local client with a non-default backing diff --git a/keyservice/keyservice.go b/keyservice/keyservice.go index 04125f7510..eb21b396f9 100644 --- a/keyservice/keyservice.go +++ b/keyservice/keyservice.go @@ -65,9 +65,10 @@ func KeyFromMasterKey(mk keys.MasterKey) Key { return Key{ KeyType: &Key_AzureKeyvaultKey{ AzureKeyvaultKey: &AzureKeyVaultKey{ - VaultUrl: mk.VaultURL, - Name: mk.Name, - Version: mk.Version, + VaultUrl: mk.VaultURL, + Name: mk.Name, + Version: mk.Version, + PublicKey: append([]byte(nil), mk.PublicKey...), }, }, } diff --git a/keyservice/keyservice.pb.go b/keyservice/keyservice.pb.go index 6314929c7d..61fcba2e98 100644 --- a/keyservice/keyservice.pb.go +++ b/keyservice/keyservice.pb.go @@ -7,11 +7,10 @@ package keyservice import ( - reflect "reflect" - sync "sync" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" ) const ( @@ -395,9 +394,10 @@ type AzureKeyVaultKey struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - VaultUrl string `protobuf:"bytes,1,opt,name=vault_url,json=vaultUrl,proto3" json:"vault_url,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + VaultUrl string `protobuf:"bytes,1,opt,name=vault_url,json=vaultUrl,proto3" json:"vault_url,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + PublicKey []byte `protobuf:"bytes,4,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` } func (x *AzureKeyVaultKey) Reset() { @@ -451,6 +451,13 @@ func (x *AzureKeyVaultKey) GetVersion() string { return "" } +func (x *AzureKeyVaultKey) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + type AgeKey struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -786,41 +793,43 @@ var file_keyservice_keyservice_proto_rawDesc = []byte{ 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x6b, 0x65, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x5d, 0x0a, 0x10, 0x41, 0x7a, 0x75, 0x72, 0x65, + 0x6b, 0x65, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x7c, 0x0a, 0x10, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x55, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x26, 0x0a, 0x06, 0x41, 0x67, 0x65, 0x4b, 0x65, 0x79, - 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x21, - 0x0a, 0x08, 0x48, 0x63, 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, - 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, - 0x64, 0x22, 0x46, 0x0a, 0x0e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x04, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x70, - 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, - 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x31, 0x0a, 0x0f, 0x45, 0x6e, 0x63, - 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1e, 0x0a, 0x0a, - 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x48, 0x0a, 0x0e, - 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x04, 0x2e, 0x4b, 0x65, - 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, - 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63, 0x69, 0x70, 0x68, - 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x2f, 0x0a, 0x0f, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x6c, 0x61, - 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x6c, - 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x32, 0x6c, 0x0a, 0x0a, 0x4b, 0x65, 0x79, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x12, 0x0f, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x10, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x07, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x12, 0x0f, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x10, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x0e, 0x5a, 0x0c, 0x2e, 0x2f, 0x6b, 0x65, 0x79, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, + 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, + 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x26, 0x0a, 0x06, 0x41, 0x67, 0x65, 0x4b, 0x65, 0x79, 0x12, + 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x21, 0x0a, + 0x08, 0x48, 0x63, 0x6b, 0x6d, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, + 0x22, 0x46, 0x0a, 0x0e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x16, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x04, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x6c, + 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, + 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x31, 0x0a, 0x0f, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x63, + 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x48, 0x0a, 0x0e, 0x44, + 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x04, 0x2e, 0x4b, 0x65, 0x79, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, + 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, + 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x2f, 0x0a, 0x0f, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x6c, 0x61, 0x69, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x6c, 0x61, + 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x32, 0x6c, 0x0a, 0x0a, 0x4b, 0x65, 0x79, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x12, + 0x0f, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x10, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x07, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x12, + 0x0f, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x10, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x42, 0x0e, 0x5a, 0x0c, 0x2e, 0x2f, 0x6b, 0x65, 0x79, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/keyservice/keyservice.proto b/keyservice/keyservice.proto index 3a471a34fd..22da6edbd7 100644 --- a/keyservice/keyservice.proto +++ b/keyservice/keyservice.proto @@ -39,6 +39,7 @@ message AzureKeyVaultKey { string vault_url = 1; string name = 2; string version = 3; + bytes public_key = 4; } message AgeKey { diff --git a/keyservice/keyservice_grpc.pb.go b/keyservice/keyservice_grpc.pb.go index d278b82d97..65df261425 100644 --- a/keyservice/keyservice_grpc.pb.go +++ b/keyservice/keyservice_grpc.pb.go @@ -60,14 +60,15 @@ func (c *keyServiceClient) Decrypt(ctx context.Context, in *DecryptRequest, opts } // KeyServiceServer is the server API for KeyService service. -// All implementations should embed UnimplementedKeyServiceServer +// All implementations must embed UnimplementedKeyServiceServer // for forward compatibility. type KeyServiceServer interface { Encrypt(context.Context, *EncryptRequest) (*EncryptResponse, error) Decrypt(context.Context, *DecryptRequest) (*DecryptResponse, error) + mustEmbedUnimplementedKeyServiceServer() } -// UnimplementedKeyServiceServer should be embedded to have +// UnimplementedKeyServiceServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil @@ -80,7 +81,8 @@ func (UnimplementedKeyServiceServer) Encrypt(context.Context, *EncryptRequest) ( func (UnimplementedKeyServiceServer) Decrypt(context.Context, *DecryptRequest) (*DecryptResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Decrypt not implemented") } -func (UnimplementedKeyServiceServer) testEmbeddedByValue() {} +func (UnimplementedKeyServiceServer) mustEmbedUnimplementedKeyServiceServer() {} +func (UnimplementedKeyServiceServer) testEmbeddedByValue() {} // UnsafeKeyServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to KeyServiceServer will diff --git a/keyservice/server.go b/keyservice/server.go index c1f1e8ce86..eb356129c5 100644 --- a/keyservice/server.go +++ b/keyservice/server.go @@ -17,6 +17,7 @@ import ( // Server is a key service server that uses SOPS MasterKeys to fulfill requests type Server struct { + UnimplementedKeyServiceServer // Prompt indicates whether the server should prompt before decrypting or encrypting data Prompt bool } @@ -52,9 +53,10 @@ func (ks *Server) encryptWithGcpKms(key *GcpKmsKey, plaintext []byte) ([]byte, e func (ks *Server) encryptWithAzureKeyVault(key *AzureKeyVaultKey, plaintext []byte) ([]byte, error) { azkvKey := azkv.MasterKey{ - VaultURL: key.VaultUrl, - Name: key.Name, - Version: key.Version, + VaultURL: key.VaultUrl, + Name: key.Name, + Version: key.Version, + PublicKey: append([]byte(nil), key.PublicKey...), } err := azkvKey.Encrypt(plaintext) if err != nil { @@ -125,9 +127,10 @@ func (ks *Server) decryptWithGcpKms(key *GcpKmsKey, ciphertext []byte) ([]byte, func (ks *Server) decryptWithAzureKeyVault(key *AzureKeyVaultKey, ciphertext []byte) ([]byte, error) { azkvKey := azkv.MasterKey{ - VaultURL: key.VaultUrl, - Name: key.Name, - Version: key.Version, + VaultURL: key.VaultUrl, + Name: key.Name, + Version: key.Version, + PublicKey: append([]byte(nil), key.PublicKey...), } azkvKey.EncryptedKey = string(ciphertext) plaintext, err := azkvKey.Decrypt() diff --git a/keyservice/server_test.go b/keyservice/server_test.go index cc29c45280..c2218e5d74 100644 --- a/keyservice/server_test.go +++ b/keyservice/server_test.go @@ -1,9 +1,15 @@ package keyservice import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" + + "github.com/getsops/sops/v3/azkv" ) func TestKmsKeyToMasterKey(t *testing.T) { @@ -79,3 +85,25 @@ func TestKmsKeyToMasterKey(t *testing.T) { }) } } + +func TestKeyFromMasterKeyPreservesAzureOfflineFields(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + publicKey := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }) + + key := KeyFromMasterKey(&azkv.MasterKey{ + VaultURL: "https://test.vault.azure.net", + Name: "test-key", + Version: "test-version", + PublicKey: publicKey, + }) + + azureKey := key.GetAzureKeyvaultKey() + require.NotNil(t, azureKey) + assert.Equal(t, publicKey, azureKey.PublicKey) +} diff --git a/stores/stores.go b/stores/stores.go index 11e362a5da..a49ff53b78 100644 --- a/stores/stores.go +++ b/stores/stores.go @@ -10,6 +10,7 @@ of the purpose of this package is to make it easy to change the SOPS file format package stores import ( + "encoding/base64" "fmt" "strconv" "strings" @@ -108,6 +109,7 @@ type azkvkey struct { VaultURL string `yaml:"vault_url" json:"vault_url"` Name string `yaml:"name" json:"name"` Version string `yaml:"version" json:"version"` + PublicKey string `yaml:"public_key,omitempty" json:"public_key,omitempty"` CreatedAt string `yaml:"created_at" json:"created_at"` EncryptedDataKey string `yaml:"enc" json:"enc"` } @@ -231,6 +233,7 @@ func azkvKeysFromGroup(group sops.KeyGroup) (keys []azkvkey) { VaultURL: key.VaultURL, Name: key.Name, Version: key.Version, + PublicKey: base64.StdEncoding.EncodeToString(key.PublicKey), CreatedAt: key.CreationDate.Format(time.RFC3339), EncryptedDataKey: key.EncryptedKey, }) @@ -429,10 +432,18 @@ func (azkvKey *azkvkey) toInternal() (*azkv.MasterKey, error) { if err != nil { return nil, err } + var publicKey []byte + if azkvKey.PublicKey != "" { + publicKey, err = base64.StdEncoding.DecodeString(azkvKey.PublicKey) + if err != nil { + return nil, err + } + } return &azkv.MasterKey{ VaultURL: azkvKey.VaultURL, Name: azkvKey.Name, Version: azkvKey.Version, + PublicKey: publicKey, EncryptedKey: azkvKey.EncryptedDataKey, CreationDate: creationDate, }, nil diff --git a/stores/stores_test.go b/stores/stores_test.go index 31ced210aa..d9f4565c62 100644 --- a/stores/stores_test.go +++ b/stores/stores_test.go @@ -1,27 +1,53 @@ package stores import ( + "encoding/base64" "testing" "time" + "github.com/getsops/sops/v3" + "github.com/getsops/sops/v3/azkv" "github.com/stretchr/testify/assert" ) - func TestValToString(t *testing.T) { assert.Equal(t, "1", ValToString(1)) assert.Equal(t, "1.0", ValToString(1.0)) assert.Equal(t, "1.1", ValToString(1.10)) assert.Equal(t, "1.23", ValToString(1.23)) assert.Equal(t, "1.2345678901234567", ValToString(1.234567890123456789)) - assert.Equal(t, "200000.0", ValToString(2E5)) - assert.Equal(t, "-2E+10", ValToString(-2E10)) - assert.Equal(t, "2E-10", ValToString(2E-10)) - assert.Equal(t, "1.2345E+100", ValToString(1.2345E100)) - assert.Equal(t, "1.2345E-100", ValToString(1.2345E-100)) + assert.Equal(t, "200000.0", ValToString(2e5)) + assert.Equal(t, "-2E+10", ValToString(-2e10)) + assert.Equal(t, "2E-10", ValToString(2e-10)) + assert.Equal(t, "1.2345E+100", ValToString(1.2345e100)) + assert.Equal(t, "1.2345E-100", ValToString(1.2345e-100)) assert.Equal(t, "true", ValToString(true)) assert.Equal(t, "false", ValToString(false)) ts, _ := time.Parse(time.RFC3339, "2025-01-02T03:04:05Z") assert.Equal(t, "2025-01-02T03:04:05Z", ValToString(ts)) assert.Equal(t, "a string", ValToString("a string")) } + +func TestAZKVKeyRoundTripPreservesPublicKey(t *testing.T) { + internal, err := (&azkvkey{ + VaultURL: "https://test.vault.azure.net", + Name: "test-key", + Version: "test-version", + PublicKey: base64.StdEncoding.EncodeToString([]byte("public-key")), + CreatedAt: "2025-01-02T03:04:05Z", + EncryptedDataKey: "ciphertext", + }).toInternal() + assert.NoError(t, err) + assert.Equal(t, []byte("public-key"), internal.PublicKey) + + keys := azkvKeysFromGroup(sops.KeyGroup{&azkv.MasterKey{ + VaultURL: "https://test.vault.azure.net", + Name: "test-key", + Version: "test-version", + PublicKey: []byte("public-key"), + CreationDate: time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC), + EncryptedKey: "ciphertext", + }}) + assert.Len(t, keys, 1) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("public-key")), keys[0].PublicKey) +}