Skip to content

Commit

Permalink
Add support for Azure KMS Backend
Browse files Browse the repository at this point in the history
Allow secrets to be:
  1. encrypted and stored
  2. decrypted and revealed
using the key specified from Key Vault.
Allow stored encrypted secrets to be updated when key is changed.
  • Loading branch information
royari authored and ramaro committed Oct 28, 2020
1 parent 88f60db commit 15d5674
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 3 deletions.
4 changes: 3 additions & 1 deletion kapitan/cached.py
Expand Up @@ -12,6 +12,7 @@
gpg_obj = None
gkms_obj = None
awskms_obj = None
azkms_obj = None
dot_kapitan = {}
ref_controller_obj = None
revealer_obj = None
Expand All @@ -20,14 +21,15 @@


def reset_cache():
global inv, inv_cache, gpg_obj, gkms_obj, awskms_obj, dot_kapitan, ref_controller_obj, revealer_obj, inv_sources
global inv, inv_cache, gpg_obj, gkms_obj, awskms_obj, azkms_obj, dot_kapitan, ref_controller_obj, revealer_obj, inv_sources

inv = {}
inv_cache = {}
inv_sources = set()
gpg_obj = None
gkms_obj = None
awskms_obj = None
azkms_obj = None
dot_kapitan = {}
ref_controller_obj = None
revealer_obj = None
Expand Down
6 changes: 5 additions & 1 deletion kapitan/refs/base.py
Expand Up @@ -446,13 +446,17 @@ def _get_backend(self, type_name):

self.register_backend(GoogleKMSBackend(self.path, **ref_kwargs))
elif type_name == "awskms":
from kapitan.refs.secrets.awskms import AWSKMSBackend
from kapitan.refs.secrets.azkms import AWSKMSBackend

self.register_backend(AWSKMSBackend(self.path, **ref_kwargs))
elif type_name == "vaultkv":
from kapitan.refs.secrets.vaultkv import VaultBackend

self.register_backend(VaultBackend(self.path, **ref_kwargs))
elif type_name == "azkms":
from kapitan.refs.secrets.azkms import AzureKMSBackend

self.register_backend(AzureKMSBackend(self.path, **ref_kwargs))
else:
raise RefBackendError(f"no backend for ref type: {type_name}")
return self.backends[type_name]
Expand Down
65 changes: 64 additions & 1 deletion kapitan/refs/cmd_parser.py
Expand Up @@ -11,6 +11,7 @@
from kapitan.refs.env import EnvRef
from kapitan.refs.secrets.awskms import AWSKMSSecret
from kapitan.refs.secrets.gkms import GoogleKMSSecret
from kapitan.refs.secrets.azkms import AzureKMSSecret
from kapitan.refs.secrets.gpg import GPGSecret, lookup_fingerprints
from kapitan.refs.secrets.vaultkv import VaultSecret
from kapitan.resources import inventory_reclass
Expand Down Expand Up @@ -115,6 +116,28 @@ def ref_write(args, ref_controller):
tag = "?{{awskms:{}}}".format(token_path)
ref_controller[tag] = secret_obj

elif token_name.startswith("azkms:"):
type_name, token_path = token_name.split(":")
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError(
"parameters.kapitan.secrets not defined in inventory of target {}".format(
args.target_name
)
)

key = kap_inv_params["secrets"]["azkms"]["key"]
if not key:
raise KapitanError(
"No KMS key specified. Use --key or specify it in parameters.kapitan.secrets.gkms.key and use --target-name"
)
secret_obj = AzureKMSSecret(data, key, encode_base64=args.base64)
tag = "?{{azkms:{}}}".format(token_path)
ref_controller[tag] = secret_obj

elif token_name.startswith("base64:"):
type_name, token_path = token_name.split(":")
_data = data.encode()
Expand Down Expand Up @@ -181,7 +204,7 @@ def ref_write(args, ref_controller):

else:
fatal_error(
"Invalid token: {name}. Try using gpg/gkms/awskms/vaultkv/base64/plain/env:{name}".format(
"Invalid token: {name}. Try using gpg/gkms/awskms/azkms/vaultkv/base64/plain/env:{name}".format(
name=token_name
)
)
Expand Down Expand Up @@ -238,6 +261,25 @@ def secret_update(args, ref_controller):
secret_obj.update_key(key)
ref_controller[tag] = secret_obj

elif token_name.startswith("azkms:"):
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError("parameters.kapitan.secrets not defined in {}".format(args.target_name))

key = kap_inv_params["secrets"]["azkms"]["key"]
if not key:
raise KapitanError(
"No KMS key specified. Use --key or specify it in parameters.kapitan.secrets.gkms.key and use --target"
)
type_name, token_path = token_name.split(":")
tag = "?{{gkms:{}}}".format(token_path)
secret_obj = ref_controller[tag]
secret_obj.update_key(key)
ref_controller[tag] = secret_obj

elif token_name.startswith("awskms:"):
key = args.key
if args.target_name:
Expand Down Expand Up @@ -318,6 +360,10 @@ def secret_update_validate(args, ref_controller):
vaultkv = kap_inv_params["secrets"]["vaultkv"]["auth"]
except KeyError:
vaultkv = None
try:
azkey = kap_inv_params["secrets"]["azkms"]["key"]
except KeyError:
azkey = None

for token_path in token_paths:
if token_path.startswith("?{gpg:"):
Expand Down Expand Up @@ -387,6 +433,23 @@ def secret_update_validate(args, ref_controller):
secret_obj.update_key(awskey)
ref_controller[token_path] = secret_obj

elif token_path.startswith("?{azkms:"):
if not azkey:
logger.debug(
"secret_update_validate: target: %s has no inventory azkms key, skipping %s",
target_name,
token_path,
)
continue
secret_obj = ref_controller[token_path]
if azkey != secret_obj.key:
if args.validate_targets:
logger.info("%s key mismatch", token_path)
ret_code = 1
else:
secret_obj.update_key(azkey)
ref_controller[token_path] = secret_obj

else:
logger.info("Invalid secret %s, could not get type, skipping", token_path)

Expand Down
179 changes: 179 additions & 0 deletions kapitan/refs/secrets/azkms.py
@@ -0,0 +1,179 @@
"azkms secret module"

import os
import logging
import base64

from azure.keyvault.keys.crypto import CryptographyClient, EncryptionAlgorithm
from azure.keyvault.keys import KeyClient
from azure.identity import DefaultAzureCredential

from kapitan.refs.base64 import Base64Ref, Base64RefBackend
from kapitan.refs.base import RefError
from kapitan import cached
from kapitan.errors import KapitanError

logger = logging.getLogger(__name__)


class AzureKMSError(KapitanError):
"""
Generic Azure Key Vault error
"""

pass


def azkms_obj(key_id):
"""
Return Azure Key Vault Object
"""
# e.g of key_id https://kapitanbackend.vault.azure.net/keys/myKey/deadbeef
if not cached.azkms_obj:

attrs = key_id.split("/")
if key_id.startswith("http"):
key_vault_uri = attrs[2]
key_name = attrs[4]
key_version = attrs[5]
else:
key_vault_uri = attrs[0]
key_name = attrs[2]
key_version = attrs[3]
# If --verbose is set, show requests from azure
if logger.getEffectiveLevel() > logging.DEBUG:
logging.getLogger("azure").setLevel(logging.ERROR)
credential = DefaultAzureCredential()
key_client = KeyClient(vault_url=f"https://{key_vault_uri}", credential=credential)
key = key_client.get_key(key_name, key_version)
cached.azkms_obj = CryptographyClient(key, credential)

return cached.azkms_obj


class AzureKMSSecret(Base64Ref):
def __init__(self, data, key, encrypt=True, encode_base64=False, **kwargs):
"""
encrypts data with key
set encode_base64 to True to base64 encode data before encrypting and writing
set encrypt to False if loading data that is already encrypted and base64
"""

if encrypt:
self._encrypt(data, key, encode_base64)
if encode_base64:
kwargs["encoding"] = "base64"
else:
self.data = data
self.key = key
super().__init__(self.data, **kwargs)
self.type_name = "azkms"

@classmethod
def from_params(cls, data, ref_params):
"""
Return new AzureKMSSecret from data and ref_params: target_name
key will be grabbed from the inventory via target_name
"""
try:
target_name = ref_params.kwargs["target_name"]
if target_name is None:
raise ValueError("target_name not set")

target_inv = cached.inv["nodes"].get(target_name, None)
if target_inv is None:
raise ValueError("target_inv not set")

key = target_inv["parameters"]["kapitan"]["secrets"]["azkms"]["key"]
return cls(data, key, **ref_params.kwargs)
except KeyError:
raise RefError("Could not create AzureKMSSecret: target_name missing")

@classmethod
def from_path(cls, ref_full_path, **kwargs):
return super().from_path(ref_full_path, encrypt=False, **kwargs)

def reveal(self):
"""
returns decrypted data
raises AzureKMSError if decryption fails
"""
# can't use super().reveal() as we want bytes
ref_data = base64.b64decode(self.data)
return self._decrypt(ref_data, self.key)

def update_key(self, key):
"""
re-encrypts data with new key, respects original encoding
returns True if key is different and secret is updated, False otherwise
"""
if key == self.key:
return False

data_dec = self.reveal()
encode_base64 = self.encoding == "base64"
if encode_base64:
data_dec = base64.b64decode(data_dec).decode()
self._encrypt(data_dec, key, encode_base64)
self.data = base64.b64encode(self.data).decode()
return True

def _encrypt(self, data, key, encode_base64):
"""
encrypts data
set encode_base64 to True to base64 encode data before writing
"""
assert isinstance(key, str)
_data = data
self.encoding = "original"
if encode_base64:
_data = base64.b64encode(data.encode())
self.encoding = "base64"
else:
# To guarantee _data is bytes
if isinstance(data, str):
_data = data.encode()
try:
ciphertext = ""
# Mocking encrypted response for tests
if key == "mock":
ciphertext = base64.b64encode("mock".encode())
else:

request = azkms_obj(key).encrypt(EncryptionAlgorithm.rsa_oaep, _data)
ciphertext = request.ciphertext

self.data = ciphertext
self.key = key

except Exception as e:
raise AzureKMSError(e)

def _decrypt(self, data, key):
"""decrypt data"""
try:
plaintext = ""
# Mocking decrypted response for tests
if self.key == "mock":
plaintext = "mock".encode()
else:
request = azkms_obj(key).decrypt(EncryptionAlgorithm.rsa_oaep, data)
plaintext = request.plaintext

return plaintext.decode()

except Exception as e:
raise AzureKMSError(e)

def dump(self):
"""
Returns dict with keys/values to be serialised.
"""
return {"data": self.data, "encoding": self.encoding, "key": self.key, "type": self.type_name}


class AzureKMSBackend(Base64RefBackend):
def __init__(self, path, ref_type=AzureKMSSecret, **ref_kwargs):
"init AzureKMSBackend ref backend type"
super().__init__(path, ref_type, **ref_kwargs)
self.type_name = "azkms"
2 changes: 2 additions & 0 deletions requirements.txt
Expand Up @@ -19,5 +19,7 @@ rfc3987==1.3.8
GitPython==3.1.3
hvac==0.10.4
docker==4.2.1
azure-keyvault-keys==4.2.0
azure-identity==1.4.0
# Reclass dependencies
pyparsing

0 comments on commit 15d5674

Please sign in to comment.