From f6d9ac7e998f0ab6c47ce009369dd4e8355e613e Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Fri, 24 Mar 2023 19:05:37 -0700 Subject: [PATCH] Crypto component for Azure Key Vault (#2692) Signed-off-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com> --- .github/scripts/test-info.mjs | 9 + crypto/azure/keyvault/algorithms.go | 70 +++ crypto/azure/keyvault/component.go | 437 ++++++++++++++++++ crypto/azure/keyvault/jwk.go | 142 ++++++ crypto/azure/keyvault/metadata.go | 84 ++++ crypto/pubkey_cache.go | 71 +++ go.mod | 2 + go.sum | 4 + .../crypto/azure/keyvault/azure-keyvault.yaml | 15 + tests/config/crypto/tests.yml | 16 + tests/conformance/common.go | 3 + 11 files changed, 853 insertions(+) create mode 100644 crypto/azure/keyvault/algorithms.go create mode 100644 crypto/azure/keyvault/component.go create mode 100644 crypto/azure/keyvault/jwk.go create mode 100644 crypto/azure/keyvault/metadata.go create mode 100644 crypto/pubkey_cache.go create mode 100644 tests/config/crypto/azure/keyvault/azure-keyvault.yaml diff --git a/.github/scripts/test-info.mjs b/.github/scripts/test-info.mjs index 9123979ba..9588a5df3 100644 --- a/.github/scripts/test-info.mjs +++ b/.github/scripts/test-info.mjs @@ -161,6 +161,15 @@ const components = { 'configuration.redis': { certification: true, }, + 'crypto.azure.keyvault': { + conformance: true, + requiredSecrets: [ + 'AzureKeyVaultName', + 'AzureKeyVaultTenantId', + 'AzureKeyVaultServicePrincipalClientId', + 'AzureKeyVaultServicePrincipalClientSecret', + ], + }, 'crypto.localstorage': { conformance: true, }, diff --git a/crypto/azure/keyvault/algorithms.go b/crypto/azure/keyvault/algorithms.go new file mode 100644 index 000000000..e451fcf86 --- /dev/null +++ b/crypto/azure/keyvault/algorithms.go @@ -0,0 +1,70 @@ +/* +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keyvault + +import ( + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys" + + internals "github.com/dapr/kit/crypto" +) + +var ( + validEncryptionAlgs map[string]struct{} + validSignatureAlgs map[string]struct{} +) + +// GetJWKEncryptionAlgorithm returns a JSONWebKeyEncryptionAlgorithm constant is the algorithm is a supported one. +func GetJWKEncryptionAlgorithm(algorithm string) *azkeys.JSONWebKeyEncryptionAlgorithm { + // Special case for AES-CBC, since we treat A[NNN]CBC as having PKCS#7 padding, and A[NNN]CBC-NOPAD as not using padding + switch algorithm { + case internals.Algorithm_A128CBC, internals.Algorithm_A192CBC, internals.Algorithm_A256CBC: + // Append "PAD", e.g. "A128CBCPAD" + algorithm += "PAD" + case internals.Algorithm_A128CBC_NOPAD, internals.Algorithm_A192CBC_NOPAD, internals.Algorithm_A256CBC_NOPAD: + // Remove the "-NOPAD" suffix, e.g. "A128CBC" + algorithm = algorithm[:len(algorithm)-6] + } + + if _, ok := validEncryptionAlgs[algorithm]; ok { + return to.Ptr(azkeys.JSONWebKeyEncryptionAlgorithm(algorithm)) + } else { + return nil + } +} + +// GetJWKSignatureAlgorithm returns a JSONWebKeySignatureAlgorithm constant is the algorithm is a supported one. +func GetJWKSignatureAlgorithm(algorithm string) *azkeys.JSONWebKeySignatureAlgorithm { + if _, ok := validSignatureAlgs[algorithm]; ok { + return to.Ptr(azkeys.JSONWebKeySignatureAlgorithm(algorithm)) + } else { + return nil + } +} + +type algorithms interface { + azkeys.JSONWebKeyEncryptionAlgorithm | azkeys.JSONWebKeySignatureAlgorithm +} + +// IsAlgorithmAsymmetric returns true if the algorithm identifier is asymmetric. +func IsAlgorithmAsymmetric[T algorithms](algorithm T) bool { + algStr := string(algorithm) + switch algStr[0:2] { + case "RS", "ES", "PS": + // RSNULL is a reserved keyword + return algStr != "RSNULL" + default: + return false + } +} diff --git a/crypto/azure/keyvault/component.go b/crypto/azure/keyvault/component.go new file mode 100644 index 000000000..280306efb --- /dev/null +++ b/crypto/azure/keyvault/component.go @@ -0,0 +1,437 @@ +/* +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keyvault + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "sync" + + "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" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + + contribCrypto "github.com/dapr/components-contrib/crypto" + contribMetadata "github.com/dapr/components-contrib/metadata" + internals "github.com/dapr/kit/crypto" + "github.com/dapr/kit/logger" +) + +var ( + errKeyNotFound = errors.New("key not found in the vault") + + // Used to initialize validEncryptionAlgs and validSignatureAlgs lazily when the first component of this kind is initialized + algsParsed sync.Once +) + +type keyvaultCrypto struct { + keyCache *contribCrypto.PubKeyCache + md keyvaultMetadata + vaultClient *azkeys.Client + logger logger.Logger +} + +// NewAzureKeyvaultCrypto returns a new Azure Key Vault crypto provider. +func NewAzureKeyvaultCrypto(logger logger.Logger) contribCrypto.SubtleCrypto { + return &keyvaultCrypto{ + logger: logger, + } +} + +// Init creates a Azure Key Vault client. +func (k *keyvaultCrypto) Init(_ context.Context, metadata contribCrypto.Metadata) error { + // Convert from data from the Azure SDK, which returns a slice, into a map + // We perform the initialization here, lazily, when the first component of this kind is initialized + // (These functions do not make network calls) + algsParsed.Do(func() { + listEncryption := azkeys.PossibleJSONWebKeyEncryptionAlgorithmValues() + validEncryptionAlgs = make(map[string]struct{}, len(listEncryption)) + for _, v := range listEncryption { + validEncryptionAlgs[string(v)] = struct{}{} + } + + listSignature := azkeys.PossibleJSONWebKeySignatureAlgorithmValues() + validSignatureAlgs = make(map[string]struct{}, len(listSignature)) + for _, v := range listSignature { + validSignatureAlgs[string(v)] = struct{}{} + } + }) + + // Init the metadata + err := k.md.InitWithMetadata(metadata) + if err != nil { + return fmt.Errorf("failed to load metadata: %w", err) + } + + // Create a cache for keys + k.keyCache = contribCrypto.NewPubKeyCache(k.getKeyCacheFn) + + // Init the Azure SDK client + k.vaultClient, err = azkeys.NewClient(k.getVaultURI(), k.md.cred, &azkeys.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Telemetry: policy.TelemetryOptions{ + ApplicationID: "dapr-" + logger.DaprVersion, + }, + }, + }) + if err != nil { + return err + } + + return nil +} + +// Features returns the features available in this crypto provider. +func (k *keyvaultCrypto) Features() []contribCrypto.Feature { + return []contribCrypto.Feature{} // No Feature supported. +} + +// GetKey returns the public part of a key stored in the vault. +// This method returns an error if the key is symmetric. +// The key argument can be in the format "name" or "name/version". +func (k *keyvaultCrypto) GetKey(parentCtx context.Context, key string) (pubKey jwk.Key, err error) { + kid := newKeyID(key) + + // If the key is cacheable, get it from the cache + if kid.Cacheable() { + return k.keyCache.GetKey(parentCtx, key) + } + + return k.getKeyFromVault(parentCtx, kid) +} + +func (k *keyvaultCrypto) getKeyFromVault(parentCtx context.Context, kid keyID) (pubKey jwk.Key, err error) { + ctx, cancel := context.WithTimeout(parentCtx, k.md.RequestTimeout) + res, err := k.vaultClient.GetKey(ctx, kid.Name, kid.Version, nil) + cancel() + if err != nil { + return nil, fmt.Errorf("failed to get key from Key Vault: %w", err) + } + + return KeyBundleToKey(&res.KeyBundle) +} + +// Handler for the getKeyCacheFn method +func (k *keyvaultCrypto) getKeyCacheFn(key string) func(resolve func(jwk.Key), reject func(error)) { + kid := newKeyID(key) + parentCtx := context.Background() + return func(resolve func(jwk.Key), reject func(error)) { + pk, err := k.getKeyFromVault(parentCtx, kid) + if err != nil { + reject(err) + return + } + resolve(pk) + } +} + +// Encrypt a small message and returns the ciphertext. +// The key argument can be in the format "name" or "name/version". +func (k *keyvaultCrypto) Encrypt(parentCtx context.Context, plaintext []byte, algorithmStr string, key string, nonce []byte, associatedData []byte) (ciphertext []byte, tag []byte, err error) { + kid := newKeyID(key) + + algorithm := GetJWKEncryptionAlgorithm(algorithmStr) + if algorithm == nil { + return nil, nil, fmt.Errorf("invalid algorithm: %s", algorithmStr) + } + + // Encrypting with symmetric or non-cacheable keys must happen in the vault + if !kid.Cacheable() || !IsAlgorithmAsymmetric(*algorithm) { + return k.encryptInVault(parentCtx, plaintext, algorithm, kid, nonce, associatedData) + } + + // Using a cacheable, asymmetric key, we can encrypt the data directly here + pk, err := k.keyCache.GetKey(parentCtx, key) + if err != nil { + return nil, nil, fmt.Errorf("failed to retrieve public key: %w", err) + } + + // If the key has expired, we cannot use that to encrypt data + if dpk, ok := pk.(*contribCrypto.Key); ok && !dpk.IsValid() { + return nil, nil, errors.New("the key is outside of its time validity bounds") + } + + ciphertext, err = internals.EncryptPublicKey(plaintext, algorithmStr, pk, associatedData) + if err != nil { + return nil, nil, fmt.Errorf("failed to encrypt data: %w", err) + } + return ciphertext, nil, nil +} + +func (k *keyvaultCrypto) encryptInVault(parentCtx context.Context, plaintext []byte, algorithm *azkeys.JSONWebKeyEncryptionAlgorithm, kid keyID, nonce []byte, associatedData []byte) (ciphertext []byte, tag []byte, err error) { + ctx, cancel := context.WithTimeout(parentCtx, k.md.RequestTimeout) + res, err := k.vaultClient.Encrypt(ctx, kid.Name, kid.Version, azkeys.KeyOperationsParameters{ + Algorithm: algorithm, + Value: plaintext, + IV: nonce, + AAD: associatedData, + }, nil) + cancel() + if err != nil { + return nil, nil, fmt.Errorf("error from Key Vault: %w", err) + } + + if res.Result == nil { + return nil, nil, errors.New("response from Key Vault does not contain a valid ciphertext") + } + + return res.Result, res.AuthenticationTag, nil +} + +// Decrypt a small message and returns the plaintext. +// The key argument can be in the format "name" or "name/version". +func (k *keyvaultCrypto) Decrypt(parentCtx context.Context, ciphertext []byte, algorithmStr string, key string, nonce []byte, tag []byte, associatedData []byte) (plaintext []byte, err error) { + kid := newKeyID(key) + + algorithm := GetJWKEncryptionAlgorithm(algorithmStr) + if algorithm == nil { + return nil, fmt.Errorf("invalid algorithm: %s", algorithmStr) + } + + ctx, cancel := context.WithTimeout(parentCtx, k.md.RequestTimeout) + res, err := k.vaultClient.Decrypt(ctx, kid.Name, kid.Version, azkeys.KeyOperationsParameters{ + Algorithm: algorithm, + Value: ciphertext, + IV: nonce, + Tag: tag, + AAD: associatedData, + }, nil) + cancel() + if err != nil { + return nil, fmt.Errorf("error from Key Vault: %w", err) + } + + if res.Result == nil { + return nil, errors.New("response from Key Vault does not contain a valid plaintext") + } + + return res.Result, nil +} + +// WrapKey wraps a symmetric key. +// The key argument can be in the format "name" or "name/version". +func (k *keyvaultCrypto) WrapKey(parentCtx context.Context, plaintextKey jwk.Key, algorithmStr string, key string, nonce []byte, associatedData []byte) (wrappedKey []byte, tag []byte, err error) { + // Azure Key Vault does not support wrapping asymmetric keys + if plaintextKey.KeyType() != jwa.OctetSeq { + return nil, nil, errors.New("cannot wrap asymmetric keys") + } + plaintext, err := internals.SerializeKey(plaintextKey) + if err != nil { + return nil, nil, fmt.Errorf("cannot serialize key: %w", err) + } + + kid := newKeyID(key) + + algorithm := GetJWKEncryptionAlgorithm(algorithmStr) + if algorithm == nil { + return nil, nil, fmt.Errorf("invalid algorithm: %s", algorithmStr) + } + + // Encrypting with symmetric or non-cacheable keys must happen in the vault + if !kid.Cacheable() || !IsAlgorithmAsymmetric(*algorithm) { + return k.wrapKeyInVault(parentCtx, plaintext, algorithm, kid, nonce, associatedData) + } + + // Using a cacheable, asymmetric key, we can encrypt the data directly here + pk, err := k.keyCache.GetKey(parentCtx, key) + if err != nil { + return nil, nil, fmt.Errorf("failed to retrieve public key: %w", err) + } + + // If the key has expired, we cannot use that to encrypt data + if dpk, ok := pk.(*contribCrypto.Key); ok && !dpk.IsValid() { + return nil, nil, errors.New("the key is outside of its time validity bounds") + } + + wrappedKey, err = internals.EncryptPublicKey(plaintext, algorithmStr, pk, associatedData) + if err != nil { + return nil, nil, fmt.Errorf("failed to wrap key: %w", err) + } + return wrappedKey, nil, nil +} + +func (k *keyvaultCrypto) wrapKeyInVault(parentCtx context.Context, plaintextKey []byte, algorithm *azkeys.JSONWebKeyEncryptionAlgorithm, kid keyID, nonce []byte, associatedData []byte) (wrappedKey []byte, tag []byte, err error) { + ctx, cancel := context.WithTimeout(parentCtx, k.md.RequestTimeout) + res, err := k.vaultClient.WrapKey(ctx, kid.Name, kid.Version, azkeys.KeyOperationsParameters{ + Algorithm: algorithm, + Value: plaintextKey, + IV: nonce, + AAD: associatedData, + }, nil) + cancel() + if err != nil { + return nil, nil, fmt.Errorf("error from Key Vault: %w", err) + } + + if res.Result == nil { + return nil, nil, errors.New("response from Key Vault does not contain a valid wrapped key") + } + + return res.Result, res.AuthenticationTag, nil +} + +// UnwrapKey unwraps a key. +// The key argument can be in the format "name" or "name/version". +func (k *keyvaultCrypto) UnwrapKey(parentCtx context.Context, wrappedKey []byte, algorithmStr string, key string, nonce []byte, tag []byte, associatedData []byte) (plaintextKey jwk.Key, err error) { + kid := newKeyID(key) + + algorithm := GetJWKEncryptionAlgorithm(algorithmStr) + if algorithm == nil { + return nil, fmt.Errorf("invalid algorithm: %s", algorithmStr) + } + + ctx, cancel := context.WithTimeout(parentCtx, k.md.RequestTimeout) + res, err := k.vaultClient.UnwrapKey(ctx, kid.Name, kid.Version, azkeys.KeyOperationsParameters{ + Algorithm: algorithm, + Value: wrappedKey, + IV: nonce, + Tag: tag, + AAD: associatedData, + }, nil) + cancel() + if err != nil { + return nil, fmt.Errorf("error from Key Vault: %w", err) + } + + if res.Result == nil { + return nil, errors.New("response from Key Vault does not contain a valid unwrapped key") + } + + // Key Vault allows wrapping/unwrapping only symmetric keys, so no need to try and decode an ASN.1 DER-encoded sequence + plaintextKey, err = jwk.FromRaw(res.Result) + if err != nil { + return nil, fmt.Errorf("failed to create JWK from raw key: %w", err) + } + + return plaintextKey, nil +} + +// Sign a digest. +// The key argument can be in the format "name" or "name/version". +func (k *keyvaultCrypto) Sign(parentCtx context.Context, digest []byte, algorithmStr string, key string) (signature []byte, err error) { + kid := newKeyID(key) + + algorithm := GetJWKSignatureAlgorithm(algorithmStr) + if algorithm == nil { + return nil, fmt.Errorf("invalid algorithm: %s", algorithmStr) + } + + ctx, cancel := context.WithTimeout(parentCtx, k.md.RequestTimeout) + res, err := k.vaultClient.Sign(ctx, kid.Name, kid.Version, azkeys.SignParameters{ + Algorithm: algorithm, + Value: digest, + }, nil) + cancel() + if err != nil { + return nil, fmt.Errorf("error from Key Vault: %w", err) + } + + if res.Result == nil { + return nil, errors.New("response from Key Vault does not contain a valid signature") + } + + return res.Result, nil +} + +// Verify a signature. +// The key argument can be in the format "name" or "name/version". +func (k *keyvaultCrypto) Verify(parentCtx context.Context, digest []byte, signature []byte, algorithmStr string, key string) (valid bool, err error) { + kid := newKeyID(key) + + algorithm := GetJWKSignatureAlgorithm(algorithmStr) + if algorithm == nil { + return false, fmt.Errorf("invalid algorithm: %s", algorithmStr) + } + + // Verifying with non-cacheable keys must happen in the vault + if !kid.Cacheable() { + return k.verifyInVault(parentCtx, digest, signature, algorithm, kid) + } + + // Using a cacheable, asymmetric key, we can verify the data directly here + pk, err := k.keyCache.GetKey(parentCtx, key) + if err != nil { + return false, fmt.Errorf("failed to retrieve public key: %w", err) + } + + valid, err = internals.VerifyPublicKey(digest, signature, algorithmStr, pk) + if err != nil { + return false, fmt.Errorf("failed to verify signature: %w", err) + } + return valid, nil +} + +func (k *keyvaultCrypto) verifyInVault(parentCtx context.Context, digest []byte, signature []byte, algorithm *azkeys.JSONWebKeySignatureAlgorithm, kid keyID) (valid bool, err error) { + ctx, cancel := context.WithTimeout(parentCtx, k.md.RequestTimeout) + res, err := k.vaultClient.Verify(ctx, kid.Name, kid.Version, azkeys.VerifyParameters{ + Algorithm: algorithm, + Digest: digest, + Signature: signature, + }, nil) + cancel() + if err != nil { + return false, fmt.Errorf("error from Key Vault: %w", err) + } + + if res.Value == nil { + return false, errors.New("response from Key Vault does not contain a valid response") + } + + return *res.Value, nil +} + +// getVaultURI returns Azure Key Vault URI. +func (k *keyvaultCrypto) getVaultURI() string { + return fmt.Sprintf("https://%s.%s", k.md.VaultName, k.md.vaultDNSSuffix) +} + +func (keyvaultCrypto) GetComponentMetadata() map[string]string { + metadataStruct := keyvaultMetadata{} + metadataInfo := map[string]string{} + contribMetadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo) + return metadataInfo +} + +type keyID struct { + Version string + Name string +} + +func newKeyID(val string) keyID { + obj := keyID{} + idx := strings.IndexRune(val, '/') + // Can't be on position 0, because the key name must be at least 1 character + if idx > 0 { + obj.Version = val[idx+1:] + obj.Name = val[:idx] + } else { + obj.Name = val + } + return obj +} + +// Cacheable returns true if the key can be cached locally. +func (id keyID) Cacheable() bool { + switch strings.ToLower(id.Version) { + case "", "latest": + return false + default: + return true + } +} diff --git a/crypto/azure/keyvault/jwk.go b/crypto/azure/keyvault/jwk.go new file mode 100644 index 000000000..30c6ad572 --- /dev/null +++ b/crypto/azure/keyvault/jwk.go @@ -0,0 +1,142 @@ +/* +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keyvault + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "errors" + "fmt" + "math/big" + + "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys" + "github.com/lestrrat-go/jwx/v2/jwk" + + contribCrypto "github.com/dapr/components-contrib/crypto" +) + +// KeyBundleToKey converts an azkeys.KeyBundle object to a contribCrypto.Key object, containing only the public part of the asymmetric key. +func KeyBundleToKey(bundle *azkeys.KeyBundle) (*contribCrypto.Key, error) { + if bundle == nil || + bundle.Key == nil || bundle.Key.KID == nil || + bundle.Attributes == nil || bundle.Attributes.Enabled == nil || *bundle.Attributes.Enabled == false { + return nil, errKeyNotFound + } + + // Get the key ID + kid := bundle.Key.KID.Name() + if ver := bundle.Key.KID.Version(); ver != "" { + kid += "/" + ver + } + + // Extract the public key and create a jwk.Key from that + pk, err := JSONWebKey{*bundle.Key}.Public() + if err != nil { + return nil, fmt.Errorf("failed to extract public key as crypto.PublicKey: %w", err) + } + jwkObj, err := jwk.FromRaw(pk) + if err != nil { + return nil, fmt.Errorf("failed to create jwk.Key: %w", err) + } + + // Convert to daprcrypto.Key + return contribCrypto.NewKey(jwkObj, kid, bundle.Attributes.Expires, bundle.Attributes.NotBefore), nil +} + +// JSONWebKey extends azkeys.JSONWebKey to add methods to export the key. +type JSONWebKey struct { + azkeys.JSONWebKey +} + +// Public returns the public key included the object, as a crypto.PublicKey object. +// This method returns an error if it's invoked on a JSONWebKey object representing a symmetric key. +func (key JSONWebKey) Public() (crypto.PublicKey, error) { + if key.Kty == nil { + return nil, errors.New("property Kty is nil") + } + + switch { + case IsRSAKey(*key.Kty): + return key.publicRSA() + case IsECKey(*key.Kty): + return key.publicEC() + } + + return nil, errors.New("unsupported key type") +} + +func (key JSONWebKey) publicRSA() (*rsa.PublicKey, error) { + res := &rsa.PublicKey{} + + // N = modulus + if len(key.N) == 0 { + return nil, errors.New("property N is empty") + } + res.N = &big.Int{} + res.N.SetBytes(key.N) + + // e = public exponent + if len(key.E) == 0 { + return nil, errors.New("property e is empty") + } + res.E = int(big.NewInt(0).SetBytes(key.E).Uint64()) + + return res, nil +} + +func (key JSONWebKey) publicEC() (*ecdsa.PublicKey, error) { + res := &ecdsa.PublicKey{} + + if key.Crv == nil { + return nil, errors.New("property Crv is nil") + } + switch *key.Crv { + case azkeys.JSONWebKeyCurveNameP256: + res.Curve = elliptic.P256() + case azkeys.JSONWebKeyCurveNameP384: + res.Curve = elliptic.P384() + case azkeys.JSONWebKeyCurveNameP521: + res.Curve = elliptic.P521() + case azkeys.JSONWebKeyCurveNameP256K: + return nil, errors.New("curves of type P-256K are not supported by this method") + } + + // X coordinate + if len(key.X) == 0 { + return nil, errors.New("property X is empty") + } + res.X = &big.Int{} + res.X.SetBytes(key.X) + + // Y coordinate + if len(key.Y) == 0 { + return nil, errors.New("property Y is empty") + } + res.Y = &big.Int{} + res.Y.SetBytes(key.Y) + + return res, nil +} + +// IsRSAKey returns true if the key is an RSA key (RSA or RSA-HSM). +func IsRSAKey(kt azkeys.JSONWebKeyType) bool { + return kt == azkeys.JSONWebKeyTypeRSA || kt == azkeys.JSONWebKeyTypeRSAHSM +} + +// IsECKey returns true if the key is an EC key (EC or EC-HSM). +func IsECKey(kt azkeys.JSONWebKeyType) bool { + return kt == azkeys.JSONWebKeyTypeEC || kt == azkeys.JSONWebKeyTypeECHSM +} diff --git a/crypto/azure/keyvault/metadata.go b/crypto/azure/keyvault/metadata.go new file mode 100644 index 000000000..95ecc574d --- /dev/null +++ b/crypto/azure/keyvault/metadata.go @@ -0,0 +1,84 @@ +/* +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keyvault + +import ( + "errors" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + + contribCrypto "github.com/dapr/components-contrib/crypto" + azauth "github.com/dapr/components-contrib/internal/authentication/azure" + "github.com/dapr/components-contrib/metadata" +) + +const defaultRequestTimeout = 30 * time.Second + +type keyvaultMetadata struct { + // Name of the Azure Key Vault resource (required). + VaultName string `json:"vaultName" mapstructure:"vaultName"` + + // Timeout for network requests, as a Go duration string (e.g. "30s") + // Defaults to "30s". + RequestTimeout time.Duration `json:"requestTimeout" mapstructure:"requestTimeout"` + + // Internal properties + vaultDNSSuffix string + cred azcore.TokenCredential +} + +func (m *keyvaultMetadata) InitWithMetadata(meta contribCrypto.Metadata) error { + m.reset() + + // Decode the metadata + err := metadata.DecodeMetadata(meta.Properties, &m) + if err != nil { + return err + } + + // Vault name + if m.VaultName == "" { + return errors.New("metadata property 'vaultName' is required") + } + + // Set default requestTimeout if empty + if m.RequestTimeout < time.Second { + m.RequestTimeout = defaultRequestTimeout + } + + // Get the DNS suffix + settings, err := azauth.NewEnvironmentSettings(meta.Properties) + if err != nil { + return err + } + m.vaultDNSSuffix = settings.EndpointSuffix(azauth.ServiceAzureKeyVault) + + // Get the credentials object + m.cred, err = settings.GetTokenCredential() + if err != nil { + return err + } + + return nil +} + +// Reset the object +func (m *keyvaultMetadata) reset() { + m.VaultName = "" + m.RequestTimeout = defaultRequestTimeout + + m.vaultDNSSuffix = "" + m.cred = nil +} diff --git a/crypto/pubkey_cache.go b/crypto/pubkey_cache.go new file mode 100644 index 000000000..d8baeef8e --- /dev/null +++ b/crypto/pubkey_cache.go @@ -0,0 +1,71 @@ +/* +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package crypto + +import ( + "context" + "sync" + + "github.com/chebyrash/promise" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +// GetKeyFn is the type of the getKeyFn function used by the PubKeyCache. +type GetKeyFn = func(key string) func(resolve func(jwk.Key), reject func(error)) + +// PubKeyCache implements GetKey with a local cache. +type PubKeyCache struct { + getKeyFn GetKeyFn + + pubKeys map[string]*promise.Promise[jwk.Key] + pubKeysLock *sync.Mutex +} + +// NewPubKeyCache returns a new PubKeyCache object +func NewPubKeyCache(getKeyFn GetKeyFn) *PubKeyCache { + return &PubKeyCache{ + getKeyFn: getKeyFn, + pubKeys: map[string]*promise.Promise[jwk.Key]{}, + pubKeysLock: &sync.Mutex{}, + } +} + +// GetKey returns a public key from the cache, or uses getKeyFn to request it +func (kc *PubKeyCache) GetKey(parentCtx context.Context, key string) (pubKey jwk.Key, err error) { + timeoutPromise := promise.New(func(_ func(jwk.Key), reject func(error)) { + <-parentCtx.Done() + reject(parentCtx.Err()) + }) + + // Check if the key is in the cache already + kc.pubKeysLock.Lock() + p, ok := kc.pubKeys[key] + if ok { + kc.pubKeysLock.Unlock() + return promise.Race(p, timeoutPromise).Await() + } + + // Create a new promise, which resolves with a background context + p = promise.New(kc.getKeyFn(key)) + p = promise.Catch(p, func(err error) error { + kc.pubKeysLock.Lock() + delete(kc.pubKeys, key) + kc.pubKeysLock.Unlock() + return err + }) + kc.pubKeys[key] = p + kc.pubKeysLock.Unlock() + + return promise.Race(p, timeoutPromise).Await() +} diff --git a/go.mod b/go.mod index a156ff960..22b687060 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v0.5.0 github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v0.3.3 github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1 + github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0 github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.11.0 github.com/Azure/azure-sdk-for-go/sdk/messaging/azeventhubs v0.5.0 github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.2.1 @@ -42,6 +43,7 @@ require ( github.com/bradfitz/gomemcache v0.0.0-20230124162541-5f7a7d875746 github.com/camunda/zeebe/clients/go/v8 v8.1.8 github.com/cenkalti/backoff/v4 v4.2.0 + github.com/chebyrash/promise v0.0.0-20220530143319-1123826567d6 github.com/cinience/go_rocketmq v0.0.2 github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.13.0 github.com/cloudevents/sdk-go/v2 v2.13.0 diff --git a/go.sum b/go.sum index c58a6c7c8..1f8004ab7 100644 --- a/go.sum +++ b/go.sum @@ -431,6 +431,8 @@ github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1/go.mod h1:l3wvZkG9oW0 github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 h1:+5VZ72z0Qan5Bog5C+ZkgSqUbeVUd9wgtHOrIKuc5b8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0 h1:TOFrNxfjslms5nLLIMjW7N0+zSALX4KiGsptmpb16AA= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0/go.mod h1:EAyXOW1F6BTJPiK2pDvmnvxOHPxoTYWoqBeIlql+QhI= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.11.0 h1:82w8tzLcOwDP/Q35j/wEBPt0n0kVC3cjtPdD62G8UAk= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.11.0/go.mod h1:S78i9yTr4o/nXlH76bKjGUye9Z2wSxO5Tz7GoDr4vfI= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 h1:Lg6BW0VPmCwcMlvOviL3ruHFO+H9tZNqscK0AeuFjGM= @@ -663,6 +665,8 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chebyrash/promise v0.0.0-20220530143319-1123826567d6 h1:AtcTeZIfucJjiqhIeMoOAR292ti2QOyo2aqN3SoWopo= +github.com/chebyrash/promise v0.0.0-20220530143319-1123826567d6/go.mod h1:4DRxP3p0R7/5msq1uKcI1THYmfWgFXxQqr0DutaIAEk= github.com/chenzhuoyu/iasm v0.0.0-20220818063314-28c361dae733/go.mod h1:wOQ0nsbeOLa2awv8bUYFW/EHXbjQMlZ10fAlXDB2sz8= github.com/chenzhuoyu/iasm v0.0.0-20230222070914-0b1b64b0e762 h1:4+00EOUb1t9uxAbgY8VvgfKJKDpim3co4MqsAbelIbs= github.com/chenzhuoyu/iasm v0.0.0-20230222070914-0b1b64b0e762/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= diff --git a/tests/config/crypto/azure/keyvault/azure-keyvault.yaml b/tests/config/crypto/azure/keyvault/azure-keyvault.yaml new file mode 100644 index 000000000..b32fa4dc9 --- /dev/null +++ b/tests/config/crypto/azure/keyvault/azure-keyvault.yaml @@ -0,0 +1,15 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: azurekeyvault +spec: + type: crypto.azure.keyvault + metadata: + - name: vaultName + value: ${{AzureKeyVaultName}} + - name: azureTenantId + value: ${{AzureKeyVaultTenantId}} + - name: azureClientId + value: ${{AzureKeyVaultServicePrincipalClientId}} + - name: azureClientSecret + value: ${{AzureKeyVaultServicePrincipalClientSecret}} diff --git a/tests/config/crypto/tests.yml b/tests/config/crypto/tests.yml index b28f3d1c1..147deb672 100644 --- a/tests/config/crypto/tests.yml +++ b/tests/config/crypto/tests.yml @@ -53,3 +53,19 @@ components: - algorithms: ["A256CBC", "A256GCM", "A256KW", "C20P", "XC20P", "C20PKW", "XC20PKW", "A128CBC-HS256"] type: symmetric name: symmetric-256 + - component: azure.keyvault + # Althoguh Azure Key Vault supports symmetric keys, those are only available in "Managed HSMs", which are too impractical for our tests + allOperations: false + operations: [] + config: + keys: + - algorithms: ["ES256"] + type: private + name: ec256key + - algorithms: ["ES512"] + type: private + # "521" is not a typo + name: ec521key + - algorithms: ["PS256" , "PS384" , "PS512" , "RS256" , "RS384" , "RS512" , "RSA1_5" , "RSA-OAEP" , "RSA-OAEP-256"] + type: private + name: rsakey diff --git a/tests/conformance/common.go b/tests/conformance/common.go index 58d727e88..b5f0c48de 100644 --- a/tests/conformance/common.go +++ b/tests/conformance/common.go @@ -59,6 +59,7 @@ import ( b_rabbitmq "github.com/dapr/components-contrib/bindings/rabbitmq" b_redis "github.com/dapr/components-contrib/bindings/redis" c_redis "github.com/dapr/components-contrib/configuration/redis" + cr_azurekeyvault "github.com/dapr/components-contrib/crypto/azure/keyvault" cr_jwks "github.com/dapr/components-contrib/crypto/jwks" cr_localstorage "github.com/dapr/components-contrib/crypto/localstorage" p_snssqs "github.com/dapr/components-contrib/pubsub/aws/snssqs" @@ -538,6 +539,8 @@ func loadSecretStore(tc TestComponent) secretstores.SecretStore { func loadCryptoProvider(tc TestComponent) contribCrypto.SubtleCrypto { var component contribCrypto.SubtleCrypto switch tc.Component { + case "azure.keyvault": + component = cr_azurekeyvault.NewAzureKeyvaultCrypto(testLogger) case "localstorage": component = cr_localstorage.NewLocalStorageCrypto(testLogger) case "jwks":