diff --git a/azkv/keysource.go b/azkv/keysource.go index 14e6cad31f..134ffd16fd 100644 --- a/azkv/keysource.go +++ b/azkv/keysource.go @@ -9,7 +9,9 @@ import ( "context" "encoding/base64" "fmt" + "os" "regexp" + "strconv" "strings" "time" @@ -25,6 +27,9 @@ import ( const ( // KeyTypeIdentifier is the string used to identify an Azure Key Vault MasterKey. KeyTypeIdentifier = "azure_kv" + // SopsAzureKeyvaultSkipUriVerificationEnv can be set to disable Azure Key Vault + // challenge resource verification (for example: "true", "1", "t"). + SopsAzureKeyvaultSkipUriVerificationEnv = "SOPS_AZURE_KEYVAULT_SKIP_URI_VERIFICATION" ) var ( @@ -162,6 +167,23 @@ func (c ClientOptions) ApplyToMasterKey(key *MasterKey) { key.clientOptions = c.o } +// ApplyDisableChallengeResourceVerification configures the MasterKey to disable challenge resource verification. +// This helper allows callers to avoid importing azkeys directly. +func ApplyDisableChallengeResourceVerification(key *MasterKey) { + NewClientOptions(&azkeys.ClientOptions{DisableChallengeResourceVerification: true}).ApplyToMasterKey(key) +} + +// ClientOptions returns the azkeys.ClientOptions configured on the MasterKey (may be nil). +func (key *MasterKey) ClientOptions() *azkeys.ClientOptions { + return key.clientOptions +} + +// EffectiveClientOptions returns the azkeys.ClientOptions that will be used by +// the Azure client, combining explicit options with environment defaults. +func (key *MasterKey) EffectiveClientOptions() *azkeys.ClientOptions { + return key.effectiveClientOptions() +} + // Encrypt takes a SOPS data key, encrypts it with Azure Key Vault, and stores // the result in the EncryptedKey field. // @@ -183,7 +205,7 @@ func (key *MasterKey) ensureKeyHasVersion(ctx context.Context) error { return fmt.Errorf("failed to get Azure token credential to retrieve key version: %w", err) } - c, err := azkeys.NewClient(key.VaultURL, token, key.clientOptions) + c, err := azkeys.NewClient(key.VaultURL, token, key.effectiveClientOptions()) if err != nil { log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed") return fmt.Errorf("failed to construct Azure Key Vault client to retrieve key version: %w", err) @@ -209,7 +231,7 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error return fmt.Errorf("failed to get Azure token credential to encrypt data: %w", err) } - c, err := azkeys.NewClient(key.VaultURL, token, key.clientOptions) + c, err := azkeys.NewClient(key.VaultURL, token, key.effectiveClientOptions()) if err != nil { log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed") return fmt.Errorf("failed to construct Azure Key Vault client to encrypt data: %w", err) @@ -272,7 +294,7 @@ func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) { return nil, fmt.Errorf("failed to base64 decode Azure Key Vault encrypted key: %w", err) } - c, err := azkeys.NewClient(key.VaultURL, token, key.clientOptions) + c, err := azkeys.NewClient(key.VaultURL, token, key.effectiveClientOptions()) if err != nil { log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Decryption failed") return nil, fmt.Errorf("failed to construct Azure Key Vault client to decrypt data: %w", err) @@ -324,3 +346,25 @@ func (key *MasterKey) getTokenCredential() (azcore.TokenCredential, error) { } return key.tokenCredential, nil } + +func (key *MasterKey) effectiveClientOptions() *azkeys.ClientOptions { + if key.clientOptions != nil { + return key.clientOptions + } + if !shouldSkipChallengeResourceVerificationFromEnv() { + return nil + } + return &azkeys.ClientOptions{DisableChallengeResourceVerification: true} +} + +func shouldSkipChallengeResourceVerificationFromEnv() bool { + raw := os.Getenv(SopsAzureKeyvaultSkipUriVerificationEnv) + if raw == "" { + return false + } + v, err := strconv.ParseBool(raw) + if err != nil { + return false + } + return v +} diff --git a/azkv/keysource_test.go b/azkv/keysource_test.go index cc636f4366..16b49400f7 100644 --- a/azkv/keysource_test.go +++ b/azkv/keysource_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" "github.com/stretchr/testify/assert" ) @@ -231,3 +232,45 @@ func TestMasterKey_getTokenCredential(t *testing.T) { assert.IsType(t, &azidentity.DefaultAzureCredential{}, got) }) } + +func TestShouldSkipChallengeResourceVerificationFromEnv(t *testing.T) { + t.Run("unset", func(t *testing.T) { + t.Setenv(SopsAzureKeyvaultSkipUriVerificationEnv, "") + assert.False(t, shouldSkipChallengeResourceVerificationFromEnv()) + }) + + t.Run("true", func(t *testing.T) { + t.Setenv(SopsAzureKeyvaultSkipUriVerificationEnv, "true") + assert.True(t, shouldSkipChallengeResourceVerificationFromEnv()) + }) + + t.Run("false", func(t *testing.T) { + t.Setenv(SopsAzureKeyvaultSkipUriVerificationEnv, "false") + assert.False(t, shouldSkipChallengeResourceVerificationFromEnv()) + }) + + t.Run("invalid", func(t *testing.T) { + t.Setenv(SopsAzureKeyvaultSkipUriVerificationEnv, "not-a-bool") + assert.False(t, shouldSkipChallengeResourceVerificationFromEnv()) + }) +} + +func TestMasterKey_effectiveClientOptions(t *testing.T) { + t.Run("uses explicit client options first", func(t *testing.T) { + t.Setenv(SopsAzureKeyvaultSkipUriVerificationEnv, "true") + mk := &MasterKey{clientOptions: &azkeys.ClientOptions{DisableChallengeResourceVerification: false}} + opts := mk.effectiveClientOptions() + if assert.NotNil(t, opts) { + assert.False(t, opts.DisableChallengeResourceVerification) + } + }) + + t.Run("uses env var when no explicit options", func(t *testing.T) { + t.Setenv(SopsAzureKeyvaultSkipUriVerificationEnv, "1") + mk := &MasterKey{} + opts := mk.effectiveClientOptions() + if assert.NotNil(t, opts) { + assert.True(t, opts.DisableChallengeResourceVerification) + } + }) +} diff --git a/cmd/sops/main.go b/cmd/sops/main.go index e590e1c82b..9a297cef90 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -131,6 +131,8 @@ func main() { SOPS_AZURE_KEYVAULT_URL environment variable. (Authentication is based on environment variables, see https://docs.microsoft.com/en-us/go/azure/azure-sdk-go-authorization#use-environment-based-authentication. + Set SOPS_AZURE_KEYVAULT_SKIP_URI_VERIFICATION=true to disable + challenge-resource URI verification when required by your environment. The user/sp needs the key/encrypt and key/decrypt permissions.) To encrypt or decrypt using age, specify the recipient in the -a flag, diff --git a/keyservice/server.go b/keyservice/server.go index c1f1e8ce86..0322b9cfca 100644 --- a/keyservice/server.go +++ b/keyservice/server.go @@ -15,6 +15,14 @@ import ( "google.golang.org/grpc/status" ) +var ( + // testHookCaptureAzureKey, when set by tests, receives the Azure KV MasterKey after client options are applied. + testHookCaptureAzureKey func(*azkv.MasterKey) + + // testHookSkipAzureNetwork, when true, causes Azure encrypt/decrypt helpers to skip real network calls. + testHookSkipAzureNetwork bool +) + // Server is a key service server that uses SOPS MasterKeys to fulfill requests type Server struct { // Prompt indicates whether the server should prompt before decrypting or encrypting data @@ -56,6 +64,13 @@ func (ks *Server) encryptWithAzureKeyVault(key *AzureKeyVaultKey, plaintext []by Name: key.Name, Version: key.Version, } + if testHookCaptureAzureKey != nil { + testHookCaptureAzureKey(&azkvKey) + } + if testHookSkipAzureNetwork { + return []byte("dummy"), nil + } + err := azkvKey.Encrypt(plaintext) if err != nil { return nil, err @@ -129,6 +144,12 @@ func (ks *Server) decryptWithAzureKeyVault(key *AzureKeyVaultKey, ciphertext []b Name: key.Name, Version: key.Version, } + if testHookCaptureAzureKey != nil { + testHookCaptureAzureKey(&azkvKey) + } + if testHookSkipAzureNetwork { + return []byte("dummy"), nil + } azkvKey.EncryptedKey = string(ciphertext) plaintext, err := azkvKey.Decrypt() return []byte(plaintext), err diff --git a/keyservice/server_test.go b/keyservice/server_test.go index cc29c45280..7ff70ca4eb 100644 --- a/keyservice/server_test.go +++ b/keyservice/server_test.go @@ -1,6 +1,7 @@ package keyservice import ( + "github.com/getsops/sops/v3/azkv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" @@ -79,3 +80,72 @@ func TestKmsKeyToMasterKey(t *testing.T) { }) } } + +// Azure KV tests for env-driven skip URI validation affecting client options. +func TestAzureKeyVaultClientOptionsAppliedOnEncryptDecrypt(t *testing.T) { + // ensure we don't perform network calls + testHookSkipAzureNetwork = true + t.Cleanup(func() { + testHookSkipAzureNetwork = false + testHookCaptureAzureKey = nil + }) + + for _, tt := range []struct { + name string + envValue string + expectOptionSet bool + }{ + {name: "encrypt sets option when env true", envValue: "true", expectOptionSet: true}, + {name: "encrypt leaves option nil when env false", envValue: "false", expectOptionSet: false}, + {name: "encrypt leaves option nil when env invalid", envValue: "not-a-bool", expectOptionSet: false}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(azkv.SopsAzureKeyvaultSkipUriVerificationEnv, tt.envValue) + + captured := []*azkv.MasterKey{} + testHookCaptureAzureKey = func(mk *azkv.MasterKey) { captured = append(captured, mk) } + server := &Server{} + key := &AzureKeyVaultKey{VaultUrl: "https://vault.example", Name: "keyname", Version: "v1"} + _, err := server.encryptWithAzureKeyVault(key, []byte("secret")) + require.NoError(t, err) + require.Len(t, captured, 1) + + co := captured[0].EffectiveClientOptions() + if tt.expectOptionSet { + require.NotNil(t, co) + assert.True(t, co.DisableChallengeResourceVerification) + } else { + assert.Nil(t, co) + } + }) + } + + for _, tt := range []struct { + name string + envValue string + expectOptionSet bool + }{ + {name: "decrypt sets option when env true", envValue: "1", expectOptionSet: true}, + {name: "decrypt leaves option nil when env false", envValue: "0", expectOptionSet: false}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(azkv.SopsAzureKeyvaultSkipUriVerificationEnv, tt.envValue) + + captured := []*azkv.MasterKey{} + testHookCaptureAzureKey = func(mk *azkv.MasterKey) { captured = append(captured, mk) } + server := &Server{} + key := &AzureKeyVaultKey{VaultUrl: "https://vault.example", Name: "keyname", Version: "v1"} + _, err := server.decryptWithAzureKeyVault(key, []byte("c2VjcmV0")) + require.NoError(t, err) + require.Len(t, captured, 1) + + co := captured[0].EffectiveClientOptions() + if tt.expectOptionSet { + require.NotNil(t, co) + assert.True(t, co.DisableChallengeResourceVerification) + } else { + assert.Nil(t, co) + } + }) + } +}