pkg/plugin/keyvault.go (268 lines of code) (raw):
// Copyright (c) Microsoft and contributors. All rights reserved.
//
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
package plugin
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"path"
"regexp"
"strings"
"github.com/Azure/kubernetes-kms/pkg/auth"
"github.com/Azure/kubernetes-kms/pkg/config"
"github.com/Azure/kubernetes-kms/pkg/consts"
"github.com/Azure/kubernetes-kms/pkg/utils"
"github.com/Azure/kubernetes-kms/pkg/version"
kv "github.com/Azure/azure-sdk-for-go/services/keyvault/2016-10-01/keyvault"
"github.com/Azure/go-autorest/autorest"
"github.com/Azure/go-autorest/autorest/azure"
"k8s.io/kms/pkg/service"
"monis.app/mlog"
)
// encryptionResponseVersion is validated prior to decryption.
// This is helpful in case we want to change anything about the data we send in the future.
var encryptionResponseVersion = "1"
const (
dateAnnotationKey = "date.azure.akv.io"
requestIDAnnotationKey = "x-ms-request-id.azure.akv.io"
keyvaultRegionAnnotationKey = "x-ms-keyvault-region.azure.akv.io"
versionAnnotationKey = "version.azure.akv.io"
algorithmAnnotationKey = "algorithm.azure.akv.io"
dateAnnotationValue = "Date"
requestIDAnnotationValue = "X-Ms-Request-Id"
keyvaultRegionAnnotationValue = "X-Ms-Keyvault-Region"
)
// Client interface for interacting with Keyvault.
type Client interface {
Encrypt(
ctx context.Context,
plain []byte,
encryptionAlgorithm kv.JSONWebKeyEncryptionAlgorithm,
) (*service.EncryptResponse, error)
Decrypt(
ctx context.Context,
cipher []byte,
encryptionAlgorithm kv.JSONWebKeyEncryptionAlgorithm,
apiVersion string,
annotations map[string][]byte,
decryptRequestKeyID string,
) ([]byte, error)
GetUserAgent() string
GetVaultURL() string
}
// KeyVaultClient is a client for interacting with Keyvault.
type KeyVaultClient struct {
baseClient kv.BaseClient
config *config.AzureConfig
vaultName string
keyName string
keyVersion string
vaultURL string
keyIDHash string
azureEnvironment *azure.Environment
}
// NewKeyVaultClient returns a new key vault client to use for kms operations.
func NewKeyVaultClient(
config *config.AzureConfig,
vaultName, keyName, keyVersion string,
proxyMode bool,
proxyAddress string,
proxyPort int,
managedHSM bool,
) (Client, error) {
// Sanitize vaultName, keyName, keyVersion. (https://github.com/Azure/kubernetes-kms/issues/85)
vaultName = utils.SanitizeString(vaultName)
keyName = utils.SanitizeString(keyName)
keyVersion = utils.SanitizeString(keyVersion)
// this should be the case for bring your own key, clusters bootstrapped with
// aks-engine or aks and standalone kms plugin deployments
if len(vaultName) == 0 || len(keyName) == 0 || len(keyVersion) == 0 {
return nil, fmt.Errorf("key vault name, key name and key version are required")
}
kvClient := kv.New()
err := kvClient.AddToUserAgent(version.GetUserAgent())
if err != nil {
return nil, fmt.Errorf("failed to add user agent to keyvault client, error: %+v", err)
}
env, err := auth.ParseAzureEnvironment(config.Cloud)
if err != nil {
return nil, fmt.Errorf("failed to parse cloud environment: %s, error: %+v", config.Cloud, err)
}
if proxyMode {
env.ActiveDirectoryEndpoint = fmt.Sprintf("http://%s:%d/", proxyAddress, proxyPort)
}
vaultResourceURL := getVaultResourceIdentifier(managedHSM, env)
if vaultResourceURL == azure.NotAvailable {
return nil, fmt.Errorf("keyvault resource identifier not available for cloud: %s", env.Name)
}
token, err := auth.GetKeyvaultToken(config, env, vaultResourceURL, proxyMode)
if err != nil {
return nil, fmt.Errorf("failed to get key vault token, error: %+v", err)
}
kvClient.Authorizer = token
vaultURL, err := getVaultURL(vaultName, managedHSM, env)
if err != nil {
return nil, fmt.Errorf("failed to get vault url, error: %+v", err)
}
keyIDHash, err := getKeyIDHash(*vaultURL, keyName, keyVersion)
if err != nil {
return nil, fmt.Errorf("failed to get key id hash, error: %w", err)
}
if proxyMode {
kvClient.RequestInspector = autorest.WithHeader(consts.RequestHeaderTargetType, consts.TargetTypeKeyVault)
vaultURL = getProxiedVaultURL(vaultURL, proxyAddress, proxyPort)
}
mlog.Always("using kms key for encrypt/decrypt", "vaultURL", *vaultURL, "keyName", keyName, "keyVersion", keyVersion)
client := &KeyVaultClient{
baseClient: kvClient,
config: config,
vaultName: vaultName,
keyName: keyName,
keyVersion: keyVersion,
vaultURL: *vaultURL,
azureEnvironment: env,
keyIDHash: keyIDHash,
}
return client, nil
}
// Encrypt encrypts the given plain text using the keyvault key.
func (kvc *KeyVaultClient) Encrypt(
ctx context.Context,
plain []byte,
encryptionAlgorithm kv.JSONWebKeyEncryptionAlgorithm,
) (*service.EncryptResponse, error) {
value := base64.RawURLEncoding.EncodeToString(plain)
params := kv.KeyOperationsParameters{
Algorithm: encryptionAlgorithm,
Value: &value,
}
result, err := kvc.baseClient.Encrypt(ctx, kvc.vaultURL, kvc.keyName, kvc.keyVersion, params)
if err != nil {
return nil, fmt.Errorf("failed to encrypt, error: %+v", err)
}
if kvc.keyIDHash != fmt.Sprintf("%x", sha256.Sum256([]byte(*result.Kid))) {
return nil, fmt.Errorf(
"key id initialized does not match with the key id from encryption result, expected: %s, got: %s",
kvc.keyIDHash,
*result.Kid,
)
}
annotations := map[string][]byte{
dateAnnotationKey: []byte(result.Header.Get(dateAnnotationValue)),
requestIDAnnotationKey: []byte(result.Header.Get(requestIDAnnotationValue)),
keyvaultRegionAnnotationKey: []byte(result.Header.Get(keyvaultRegionAnnotationValue)),
versionAnnotationKey: []byte(encryptionResponseVersion),
algorithmAnnotationKey: []byte(encryptionAlgorithm),
}
return &service.EncryptResponse{
Ciphertext: []byte(*result.Result),
KeyID: kvc.keyIDHash,
Annotations: annotations,
}, nil
}
// Decrypt decrypts the given cipher text using the keyvault key.
func (kvc *KeyVaultClient) Decrypt(
ctx context.Context,
cipher []byte,
encryptionAlgorithm kv.JSONWebKeyEncryptionAlgorithm,
apiVersion string,
annotations map[string][]byte,
decryptRequestKeyID string,
) ([]byte, error) {
if apiVersion == version.KMSv2APIVersion {
err := kvc.validateAnnotations(annotations, decryptRequestKeyID, encryptionAlgorithm)
if err != nil {
return nil, err
}
}
value := string(cipher)
params := kv.KeyOperationsParameters{
Algorithm: encryptionAlgorithm,
Value: &value,
}
result, err := kvc.baseClient.Decrypt(ctx, kvc.vaultURL, kvc.keyName, kvc.keyVersion, params)
if err != nil {
return nil, fmt.Errorf("failed to decrypt, error: %+v", err)
}
bytes, err := base64.RawURLEncoding.DecodeString(*result.Result)
if err != nil {
return nil, fmt.Errorf("failed to base64 decode result, error: %+v", err)
}
return bytes, nil
}
func (kvc *KeyVaultClient) GetUserAgent() string {
return kvc.baseClient.UserAgent
}
func (kvc *KeyVaultClient) GetVaultURL() string {
return kvc.vaultURL
}
// ValidateAnnotations validates following annotations before decryption:
// - Algorithm.
// - Version.
// It also validates keyID that the API server checks.
func (kvc *KeyVaultClient) validateAnnotations(
annotations map[string][]byte,
keyID string,
encryptionAlgorithm kv.JSONWebKeyEncryptionAlgorithm,
) error {
if len(annotations) == 0 {
return fmt.Errorf("invalid annotations, annotations cannot be empty")
}
if keyID != kvc.keyIDHash {
return fmt.Errorf(
"key id %s does not match expected key id %s used for encryption",
keyID,
kvc.keyIDHash,
)
}
algorithm := string(annotations[algorithmAnnotationKey])
if algorithm != string(encryptionAlgorithm) {
return fmt.Errorf(
"algorithm %s does not match expected algorithm %s used for encryption",
algorithm,
encryptionAlgorithm,
)
}
version := string(annotations[versionAnnotationKey])
if version != encryptionResponseVersion {
return fmt.Errorf(
"version %s does not match expected version %s used for encryption",
version,
encryptionResponseVersion,
)
}
return nil
}
func getVaultURL(vaultName string, managedHSM bool, env *azure.Environment) (vaultURL *string, err error) {
// Key Vault name must be a 3-24 character string
if len(vaultName) < 3 || len(vaultName) > 24 {
return nil, fmt.Errorf("invalid vault name: %q, must be between 3 and 24 chars", vaultName)
}
// See docs for validation spec: https://docs.microsoft.com/en-us/azure/key-vault/about-keys-secrets-and-certificates#objects-identifiers-and-versioning
isValid := regexp.MustCompile(`^[-A-Za-z0-9]+$`).MatchString
if !isValid(vaultName) {
return nil, fmt.Errorf("invalid vault name: %q, must match [-a-zA-Z0-9]{3,24}", vaultName)
}
vaultDNSSuffixValue := getVaultDNSSuffix(managedHSM, env)
if vaultDNSSuffixValue == azure.NotAvailable {
return nil, fmt.Errorf("vault dns suffix not available for cloud: %s", env.Name)
}
vaultURI := fmt.Sprintf("https://%s.%s/", vaultName, vaultDNSSuffixValue)
return &vaultURI, nil
}
func getProxiedVaultURL(vaultURL *string, proxyAddress string, proxyPort int) *string {
proxiedVaultURL := fmt.Sprintf("http://%s:%d/%s", proxyAddress, proxyPort, strings.TrimPrefix(*vaultURL, "https://"))
return &proxiedVaultURL
}
func getVaultDNSSuffix(managedHSM bool, env *azure.Environment) string {
if managedHSM {
return env.ManagedHSMDNSSuffix
}
return env.KeyVaultDNSSuffix
}
func getVaultResourceIdentifier(managedHSM bool, env *azure.Environment) string {
if managedHSM {
return env.ResourceIdentifiers.ManagedHSM
}
return env.ResourceIdentifiers.KeyVault
}
func getKeyIDHash(vaultURL, keyName, keyVersion string) (string, error) {
if vaultURL == "" || keyName == "" || keyVersion == "" {
return "", fmt.Errorf("vault url, key name and key version cannot be empty")
}
baseURL, err := url.Parse(vaultURL)
if err != nil {
return "", fmt.Errorf("failed to parse vault url, error: %w", err)
}
urlPath := path.Join("keys", keyName, keyVersion)
keyID := baseURL.ResolveReference(
&url.URL{
Path: urlPath,
},
).String()
return fmt.Sprintf("%x", sha256.Sum256([]byte(keyID))), nil
}