mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 14:30:46 +02:00
* kms/azure: fix encrypt/decrypt round trip for Azure Key Vault The provider stored the wrapped data key as string(encryptResult.Result) in the JSON envelope, sent the encryption context as AAD to an RSA-OAEP key, and passed a full key URL to the azkeys client in the name position. Each of those breaks a round trip on its own. - split the key URL into the (name, version) pair azkeys expects before Encrypt, Decrypt and GetKey; a plain name still resolves to the latest version, and decrypt keeps the stored version so objects stay readable after a key rotation - store the wrapped key base64-encoded and decode it strictly, so the JSON envelope no longer replaces the raw bytes with U+FFFD - stop sending AAD: Key Vault rejects it on RSA-OAEP with BadParameter, so the encryption context is logged and dropped instead * kms/azure: reject a key URL that names another vault splitKeyID dropped the host of a full Key Vault URL, so a key ID from a different vault silently resolved to this vault's same-named key. Return an error instead of encrypting under a key the caller did not ask for. * kms/azure: bind encryption context via an envelope digest RSA-OAEP rejects AAD, so removing it left the encryption context unauthenticated: a wrapped key could be decrypted under a different object's context. Record a sha256 digest of the marshaled context in the envelope's provider_specific field on encrypt and verify it before calling Decrypt, restoring the binding without AAD. * kms/azure: treat an explicit :443 port as the same vault * kms/azure: accept an absent context digest only for empty contexts * kms/azure: normalize both hosts when comparing key URLs to the vault splitKeyID stripped :443 and a trailing dot from the configured vault but only :443 from the key URL, so a key URL naming the same vault with a trailing DNS dot was rejected before Azure was ever called. Compare both sides through vaultHost so they are normalized identically. * kms/azure: reject non-key vault URLs in splitKeyID, document digest limits A Key Vault URL that does not name a key under /keys/, has an empty key name, or carries extra path segments now fails fast instead of being passed to the client as a key name, where it would surface as a confusing vault-side error. Also note that the envelope context digest is a client-side mismatch check, not vault-authenticated AAD, and only allocate providerSpecific when a context is present. * ci: compile and test azurekms-gated code weed/kms/azure is excluded from every default build, so nothing in CI compiled it; that is how the provider shipped unregistered. Build the tree and run the kms tests with -tags azurekms on every Go change. --------- Co-authored-by: Yi-111-a <> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
364 lines
11 KiB
Go
364 lines
11 KiB
Go
//go:build azurekms
|
|
|
|
package azure
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys"
|
|
|
|
seaweedkms "github.com/seaweedfs/seaweedfs/weed/kms"
|
|
)
|
|
|
|
func TestSplitKeyID(t *testing.T) {
|
|
provider := &AzureKMSProvider{vaultURL: "https://myvault.vault.azure.net"}
|
|
|
|
tests := []struct {
|
|
name string
|
|
keyID string
|
|
wantName string
|
|
wantVersion string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "plain key name",
|
|
keyID: "my-key",
|
|
wantName: "my-key",
|
|
},
|
|
{
|
|
name: "key url without version",
|
|
keyID: "https://myvault.vault.azure.net/keys/my-key",
|
|
wantName: "my-key",
|
|
},
|
|
{
|
|
name: "key url with version",
|
|
keyID: "https://myvault.vault.azure.net/keys/my-key/abc123",
|
|
wantName: "my-key",
|
|
wantVersion: "abc123",
|
|
},
|
|
{
|
|
name: "key url with trailing slash",
|
|
keyID: "https://myvault.vault.azure.net/keys/my-key/",
|
|
wantName: "my-key",
|
|
},
|
|
{
|
|
name: "key url with a mixed case host",
|
|
keyID: "https://MyVault.vault.azure.net/keys/my-key",
|
|
wantName: "my-key",
|
|
},
|
|
{
|
|
name: "key url with a non keys path",
|
|
keyID: "https://myvault.vault.azure.net/secrets/my-secret",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "key url with extra path segments",
|
|
keyID: "https://myvault.vault.azure.net/keys/my-key/abc123/extra",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "key url with an empty key name",
|
|
keyID: "https://myvault.vault.azure.net/keys//abc123",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "name that only looks like a url",
|
|
keyID: "my-key:abc123",
|
|
wantName: "my-key:abc123",
|
|
},
|
|
{
|
|
name: "key url with explicit default port",
|
|
keyID: "https://myvault.vault.azure.net:443/keys/my-key",
|
|
wantName: "my-key",
|
|
},
|
|
{
|
|
name: "key url with non-default port",
|
|
keyID: "https://myvault.vault.azure.net:8443/keys/my-key",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "key url from another vault",
|
|
keyID: "https://othervault.vault.azure.net/keys/my-key",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
name, version, err := provider.splitKeyID(test.keyID)
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("splitKeyID(%q) succeeded, want error", test.keyID)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("splitKeyID(%q): %v", test.keyID, err)
|
|
}
|
|
if name != test.wantName {
|
|
t.Fatalf("name = %q, want %q", name, test.wantName)
|
|
}
|
|
if version != test.wantVersion {
|
|
t.Fatalf("version = %q, want %q", version, test.wantVersion)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The client addresses only the vault it was built for, so a key URL naming a
|
|
// different vault must not resolve to this vault's same-named key.
|
|
func TestSplitKeyIDExplicitDefaultPort(t *testing.T) {
|
|
provider := &AzureKMSProvider{vaultURL: "https://myvault.vault.azure.net:443"}
|
|
name, _, err := provider.splitKeyID("https://myvault.vault.azure.net/keys/my-key")
|
|
if err != nil || name != "my-key" {
|
|
t.Fatalf("splitKeyID = %q, %v; want my-key, nil", name, err)
|
|
}
|
|
}
|
|
|
|
func TestSplitKeyIDRejectsForeignVault(t *testing.T) {
|
|
provider := &AzureKMSProvider{vaultURL: "https://myvault.vault.azure.net/"}
|
|
|
|
if _, _, err := provider.splitKeyID("https://evil.vault.azure.net/keys/my-key/abc123"); err == nil {
|
|
t.Fatal("splitKeyID accepted a key URL from another vault")
|
|
}
|
|
}
|
|
|
|
// A trailing DNS dot names the same vault, so both sides of the comparison are
|
|
// normalized the same way.
|
|
func TestSplitKeyIDTrailingDot(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
vaultURL string
|
|
keyID string
|
|
}{
|
|
{
|
|
name: "trailing dot on the key url",
|
|
vaultURL: "https://myvault.vault.azure.net",
|
|
keyID: "https://myvault.vault.azure.net./keys/my-key/abc123",
|
|
},
|
|
{
|
|
name: "trailing dot on the configured vault",
|
|
vaultURL: "https://myvault.vault.azure.net./",
|
|
keyID: "https://myvault.vault.azure.net/keys/my-key/abc123",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
provider := &AzureKMSProvider{vaultURL: tt.vaultURL}
|
|
name, version, err := provider.splitKeyID(tt.keyID)
|
|
if err != nil || name != "my-key" || version != "abc123" {
|
|
t.Fatalf("splitKeyID = %q, %q, %v; want my-key, abc123, nil", name, version, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The envelope is JSON, so a raw binary wrapped key would be replaced by
|
|
// U+FFFD on the way in and could never be decrypted.
|
|
func TestCiphertextEnvelopeRoundTrip(t *testing.T) {
|
|
wrapped := make([]byte, 256)
|
|
if _, err := rand.Read(wrapped); err != nil {
|
|
t.Fatalf("generate wrapped key: %v", err)
|
|
}
|
|
|
|
keyID := "https://myvault.vault.azure.net/keys/my-key/abc123"
|
|
envelopeBlob, err := seaweedkms.CreateEnvelope("azure", keyID, encodeCiphertext(wrapped), nil)
|
|
if err != nil {
|
|
t.Fatalf("create envelope: %v", err)
|
|
}
|
|
|
|
envelope, err := seaweedkms.ParseEnvelope(envelopeBlob)
|
|
if err != nil {
|
|
t.Fatalf("parse envelope: %v", err)
|
|
}
|
|
if envelope.KeyID != keyID {
|
|
t.Fatalf("key id = %q, want %q", envelope.KeyID, keyID)
|
|
}
|
|
|
|
decoded, err := decodeCiphertext(envelope.Ciphertext)
|
|
if err != nil {
|
|
t.Fatalf("decode ciphertext: %v", err)
|
|
}
|
|
if !bytes.Equal(decoded, wrapped) {
|
|
t.Fatal("decoded wrapped key differs from the encrypted one")
|
|
}
|
|
}
|
|
|
|
func TestDecodeCiphertextRejectsInvalidInput(t *testing.T) {
|
|
for _, ciphertext := range []string{"", "not base64!!", "a"} {
|
|
if _, err := decodeCiphertext(ciphertext); err == nil {
|
|
t.Fatalf("decodeCiphertext(%q) succeeded, want error", ciphertext)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RSA-OAEP cannot authenticate the encryption context, so the context is
|
|
// bound to the wrapped key via a digest in the envelope.
|
|
func TestEncryptionContextBinding(t *testing.T) {
|
|
context := map[string]string{"aws:s3:bucket": "bucket", "aws:s3:object": "key"}
|
|
envelopeBlob, err := seaweedkms.CreateEnvelope("azure", "my-key", "d3JhcHBlZA==", map[string]interface{}{
|
|
"encryption_context_sha256": contextDigest(context),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create envelope: %v", err)
|
|
}
|
|
envelope, err := seaweedkms.ParseEnvelope(envelopeBlob)
|
|
if err != nil {
|
|
t.Fatalf("parse envelope: %v", err)
|
|
}
|
|
|
|
if err := checkContext(envelope, context); err != nil {
|
|
t.Fatalf("matching context rejected: %v", err)
|
|
}
|
|
if err := checkContext(envelope, map[string]string{"aws:s3:object": "other"}); err == nil {
|
|
t.Fatal("mismatched context accepted")
|
|
}
|
|
if err := checkContext(envelope, nil); err == nil {
|
|
t.Fatal("missing context accepted for a context-bound key")
|
|
}
|
|
}
|
|
|
|
type fakeCredential struct{}
|
|
|
|
func (fakeCredential) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
|
return azcore.AccessToken{Token: "token", ExpiresOn: time.Now().Add(time.Hour)}, nil
|
|
}
|
|
|
|
type fakeTransport struct {
|
|
do func(*http.Request) (*http.Response, error)
|
|
}
|
|
|
|
func (t fakeTransport) Do(req *http.Request) (*http.Response, error) {
|
|
return t.do(req)
|
|
}
|
|
|
|
func jsonResponse(status int, body string) *http.Response {
|
|
return &http.Response{
|
|
StatusCode: status,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
}
|
|
}
|
|
|
|
// Drive GenerateDataKey and Decrypt through a fake Key Vault transport so the
|
|
// whole path — key ID split, request body shape, envelope encoding — is
|
|
// exercised, not just the helpers.
|
|
func TestGenerateDataKeyDecryptRoundTrip(t *testing.T) {
|
|
vaultURL := "https://myvault.vault.azure.net"
|
|
var wrapped []byte
|
|
var sawAAD bool
|
|
|
|
client, err := azkeys.NewClient(vaultURL, fakeCredential{}, &azkeys.ClientOptions{
|
|
ClientOptions: azcore.ClientOptions{
|
|
Transport: fakeTransport{do: func(req *http.Request) (*http.Response, error) {
|
|
if req.Header.Get("Authorization") == "" {
|
|
// Key Vault answers an unauthenticated request with the
|
|
// challenge the client then satisfies.
|
|
resp := jsonResponse(401, `{"error":{"code":"Unauthorized"}}`)
|
|
resp.Header.Set("WWW-Authenticate", `Bearer authorization="https://login.windows.net/tenant", resource="https://vault.azure.net"`)
|
|
return resp, nil
|
|
}
|
|
var body []byte
|
|
if rc, err := req.GetBody(); err == nil {
|
|
body, _ = io.ReadAll(rc)
|
|
rc.Close()
|
|
} else if req.Body != nil {
|
|
body, _ = io.ReadAll(req.Body)
|
|
}
|
|
sawAAD = bytes.Contains(body, []byte(`"aad"`))
|
|
var params struct {
|
|
Value string `json:"value"`
|
|
}
|
|
if err := json.Unmarshal(body, ¶ms); err != nil {
|
|
return nil, err
|
|
}
|
|
value, err := base64.RawURLEncoding.DecodeString(params.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch {
|
|
case strings.HasSuffix(req.URL.Path, "/encrypt"):
|
|
wrapped = value
|
|
return jsonResponse(200, `{"kid":"`+vaultURL+`/keys/my-key/abc123","value":"`+base64.RawURLEncoding.EncodeToString(wrapped)+`"}`), nil
|
|
case strings.HasSuffix(req.URL.Path, "/decrypt"):
|
|
if !bytes.Equal(value, wrapped) {
|
|
return jsonResponse(400, `{"error":{"code":"BadParameter"}}`), nil
|
|
}
|
|
return jsonResponse(200, `{"kid":"`+vaultURL+`/keys/my-key/abc123","value":"`+params.Value+`"}`), nil
|
|
default:
|
|
return jsonResponse(404, `{"error":{"code":"NotFound"}}`), nil
|
|
}
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
|
|
provider := &AzureKMSProvider{client: client, vaultURL: vaultURL}
|
|
contextMap := map[string]string{"aws:s3:bucket": "bucket"}
|
|
|
|
resp, err := provider.GenerateDataKey(context.Background(), &seaweedkms.GenerateDataKeyRequest{
|
|
KeyID: vaultURL + "/keys/my-key",
|
|
KeySpec: seaweedkms.KeySpecAES256,
|
|
EncryptionContext: contextMap,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateDataKey: %v", err)
|
|
}
|
|
if sawAAD {
|
|
t.Fatal("encrypt request carried AAD")
|
|
}
|
|
|
|
decrypted, err := provider.Decrypt(context.Background(), &seaweedkms.DecryptRequest{
|
|
CiphertextBlob: resp.CiphertextBlob,
|
|
EncryptionContext: contextMap,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Decrypt: %v", err)
|
|
}
|
|
if !bytes.Equal(decrypted.Plaintext, resp.Plaintext) {
|
|
t.Fatal("decrypted key differs from generated key")
|
|
}
|
|
|
|
if _, err := provider.Decrypt(context.Background(), &seaweedkms.DecryptRequest{
|
|
CiphertextBlob: resp.CiphertextBlob,
|
|
EncryptionContext: map[string]string{"aws:s3:bucket": "other"},
|
|
}); err == nil {
|
|
t.Fatal("Decrypt succeeded with a different encryption context")
|
|
}
|
|
}
|
|
|
|
func TestCheckContextEmpty(t *testing.T) {
|
|
// Keys wrapped without a context carry no digest; decrypting them with
|
|
// no context must succeed, and with a context must fail.
|
|
blob, err := seaweedkms.CreateEnvelope("azure", "my-key", "d3JhcHBlZA==", nil)
|
|
if err != nil {
|
|
t.Fatalf("create envelope: %v", err)
|
|
}
|
|
envelope, err := seaweedkms.ParseEnvelope(blob)
|
|
if err != nil {
|
|
t.Fatalf("parse envelope: %v", err)
|
|
}
|
|
for _, context := range []map[string]string{nil, {}} {
|
|
if err := checkContext(envelope, context); err != nil {
|
|
t.Fatalf("empty context rejected for an unbound key: %v", err)
|
|
}
|
|
}
|
|
if err := checkContext(envelope, map[string]string{"k": "v"}); err == nil {
|
|
t.Fatal("context accepted for an unbound key")
|
|
}
|
|
}
|