From a85f076e0cca709c22f095c87b01d6ce93411ad3 Mon Sep 17 00:00:00 2001 From: Michael Hatcher Date: Wed, 22 Oct 2025 08:39:15 -0400 Subject: [PATCH] initial commit Signed-off-by: Michael Hatcher --- azkv/keysource.go | 11 +++ cmd/sops/main.go | 9 ++- cmd/sops/subcommand/keyservice/keyservice.go | 2 + keyservice/server.go | 33 ++++++++ keyservice/server_test.go | 85 ++++++++++++++++++++ 5 files changed, 139 insertions(+), 1 deletion(-) diff --git a/azkv/keysource.go b/azkv/keysource.go index d7a73547f..9fcdfbcd7 100644 --- a/azkv/keysource.go +++ b/azkv/keysource.go @@ -162,6 +162,17 @@ 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 +} + // Encrypt takes a SOPS data key, encrypts it with Azure Key Vault, and stores // the result in the EncryptedKey field. // diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 62d11f162..53f88fe45 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -1837,6 +1837,10 @@ func main() { Usage: "comma separated list of decryption key types", EnvVar: "SOPS_DECRYPTION_ORDER", }, + cli.BoolFlag{ + Name: "azure-kv-skip-uri-validation", + Usage: "skip Azure Key Vault URI validation", + }, }, keyserviceFlags...) app.Action = func(c *cli.Context) error { @@ -2262,7 +2266,10 @@ func toExitError(err error) error { func keyservices(c *cli.Context) (svcs []keyservice.KeyServiceClient) { if c.Bool("enable-local-keyservice") { - svcs = append(svcs, keyservice.NewLocalClient()) + // propagate azure-kv-skip-uri-validation flag to local keyservice server instance + skipAzureKvUriValidation := c.Bool("azure-kv-skip-uri-validation") || c.GlobalBool("azure-kv-skip-uri-validation") + local := keyservice.NewCustomLocalClient(keyservice.Server{Prompt: false, SkipAzureKvUriValidation: skipAzureKvUriValidation}) + svcs = append(svcs, local) } uris := c.StringSlice("keyservice") for _, uri := range uris { diff --git a/cmd/sops/subcommand/keyservice/keyservice.go b/cmd/sops/subcommand/keyservice/keyservice.go index c28f63690..633051f03 100644 --- a/cmd/sops/subcommand/keyservice/keyservice.go +++ b/cmd/sops/subcommand/keyservice/keyservice.go @@ -36,6 +36,8 @@ func Run(opts Opts) error { grpcServer := grpc.NewServer() keyservice.RegisterKeyServiceServer(grpcServer, keyservice.Server{ Prompt: opts.Prompt, + // remote keyservice currently does not receive CLI flags; default to false. + SkipAzureKvUriValidation: false, }) log.Infof("Listening on %s://%s", opts.Network, opts.Address) diff --git a/keyservice/server.go b/keyservice/server.go index 9f2b486a6..cc0ad6a0a 100644 --- a/keyservice/server.go +++ b/keyservice/server.go @@ -14,10 +14,20 @@ 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 Prompt bool + // SkipAzureKvUriValidation indicates whether Azure Key Vault URI/challenge resource validation should be skipped + SkipAzureKvUriValidation bool } func (ks *Server) encryptWithPgp(key *PgpKey, plaintext []byte) ([]byte, error) { @@ -55,6 +65,18 @@ func (ks *Server) encryptWithAzureKeyVault(key *AzureKeyVaultKey, plaintext []by Name: key.Name, Version: key.Version, } + + // only disable challenge resource (URI) verification if flag was provided. + if ks.SkipAzureKvUriValidation { + azkv.ApplyDisableChallengeResourceVerification(&azkvKey) + } + if testHookCaptureAzureKey != nil { + testHookCaptureAzureKey(&azkvKey) + } + if testHookSkipAzureNetwork { + return []byte("dummy"), nil + } + err := azkvKey.Encrypt(plaintext) if err != nil { return nil, err @@ -116,6 +138,17 @@ func (ks *Server) decryptWithAzureKeyVault(key *AzureKeyVaultKey, ciphertext []b Name: key.Name, Version: key.Version, } + + // only disable challenge resource (URI) verification if flag was provided. + if ks.SkipAzureKvUriValidation { + azkv.ApplyDisableChallengeResourceVerification(&azkvKey) + } + 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 cc29c4528..a16f986a5 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,87 @@ func TestKmsKeyToMasterKey(t *testing.T) { }) } } + +// Azure KV tests for skip URI validation flag affecting client options. +func TestAzureKeyVaultClientOptionsAppliedOnEncryptDecrypt(t *testing.T) { + // ensure we don't perform network calls + testHookSkipAzureNetwork = true + + t.Run("encrypt applies option when flag true", func(t *testing.T) { + captured := []*azkv.MasterKey{} + testHookCaptureAzureKey = func(mk *azkv.MasterKey) { captured = append(captured, mk) } + server := &Server{SkipAzureKvUriValidation: true} + key := &AzureKeyVaultKey{VaultUrl: "https://vault.example", Name: "keyname", Version: "v1"} + _, err := server.encryptWithAzureKeyVault(key, []byte("secret")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(captured) != 1 { + t.Fatalf("expected 1 captured key, got %d", len(captured)) + } + co := captured[0].ClientOptions() + if co == nil { + t.Fatalf("expected clientOptions to be set when flag true") + } + if !co.DisableChallengeResourceVerification { + t.Fatalf("expected DisableChallengeResourceVerification=true") + } + }) + + t.Run("encrypt leaves option nil when flag false", func(t *testing.T) { + captured := []*azkv.MasterKey{} + testHookCaptureAzureKey = func(mk *azkv.MasterKey) { captured = append(captured, mk) } + server := &Server{SkipAzureKvUriValidation: false} + key := &AzureKeyVaultKey{VaultUrl: "https://vault.example", Name: "keyname", Version: "v1"} + _, err := server.encryptWithAzureKeyVault(key, []byte("secret")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(captured) != 1 { + t.Fatalf("expected 1 captured key, got %d", len(captured)) + } + co := captured[0].ClientOptions() + if co != nil { + t.Fatalf("expected clientOptions to be nil when flag false, got %#v", co) + } + }) + + t.Run("decrypt applies option when flag true", func(t *testing.T) { + captured := []*azkv.MasterKey{} + testHookCaptureAzureKey = func(mk *azkv.MasterKey) { captured = append(captured, mk) } + server := &Server{SkipAzureKvUriValidation: true} + key := &AzureKeyVaultKey{VaultUrl: "https://vault.example", Name: "keyname", Version: "v1"} + _, err := server.decryptWithAzureKeyVault(key, []byte("c2VjcmV0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(captured) != 1 { + t.Fatalf("expected 1 captured key, got %d", len(captured)) + } + co := captured[0].ClientOptions() + if co == nil { + t.Fatalf("expected clientOptions to be set when flag true (decrypt)") + } + if !co.DisableChallengeResourceVerification { + t.Fatalf("expected DisableChallengeResourceVerification=true (decrypt)") + } + }) + + t.Run("decrypt leaves option nil when flag false", func(t *testing.T) { + captured := []*azkv.MasterKey{} + testHookCaptureAzureKey = func(mk *azkv.MasterKey) { captured = append(captured, mk) } + server := &Server{SkipAzureKvUriValidation: false} + key := &AzureKeyVaultKey{VaultUrl: "https://vault.example", Name: "keyname", Version: "v1"} + _, err := server.decryptWithAzureKeyVault(key, []byte("c2VjcmV0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(captured) != 1 { + t.Fatalf("expected 1 captured key, got %d", len(captured)) + } + co := captured[0].ClientOptions() + if co != nil { + t.Fatalf("expected clientOptions to be nil when flag false (decrypt), got %#v", co) + } + }) +}