Skip to content
This repository was archived by the owner on May 9, 2020. It is now read-only.

Feature/encrypted data bags #82

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4140cc7
Extend ChefAPI with the secret_file option
kamilbednarz Oct 17, 2013
9bea357
Add encryption exceptions
kamilbednarz Oct 17, 2013
3211ef3
Add a class for encrypted data bag item
Oct 17, 2013
267a790
Add data bag decryptor for version 2
kamilbednarz Oct 22, 2013
6016f60
Add Encryptor version 1
kamilbednarz Nov 25, 2013
0227ddf
Add support for choosing encryption version
kamilbednarz Dec 31, 2013
4b0bf1d
Add Encryptor version 2
kamilbednarz Dec 31, 2013
f74a87b
Remove reading secret_file knife config from knife.rb
kamilbednarz Jan 1, 2014
667a816
Update encryption get_version method to support both string and int p…
kamilbednarz Jan 1, 2014
2674c36
Add EncryptedDataBagItem to chef module
kamilbednarz Jan 1, 2014
25b29dc
Add test for reading data bag encryption version from config file
kamilbednarz Jan 1, 2014
e3d863e
Add specs for EncryptedDataBagItem class
kamilbednarz Jan 1, 2014
0ab75ae
Fix issue with failing Api test
kamilbednarz Jan 1, 2014
97babcf
Fix paths to files in specs for EncryptedDataBagItem
kamilbednarz Jan 1, 2014
c13adba
Remove trash file
kamilbednarz Jan 1, 2014
81b1b30
Reworked AES implementation - use ctypes instead of m2crypto
kamilbednarz Jan 3, 2014
b5b6ea5
Refactored aes cipher class
kamilbednarz Jan 7, 2014
476123e
Replaced encrypted_data_bag_item AES encryption method to AES256Cipher
kamilbednarz Jan 7, 2014
76aeaa9
Replace direct usage of simplejson with the one from chef.utils
kamilbednarz Jan 7, 2014
a12c4ad
Import needed class instead of the whole module
kamilbednarz Jan 11, 2014
0f6eff7
Removed stripping padding chars in decrypted strings
kamilbednarz Jan 11, 2014
08e00ec
Use itertools for HMAC validation
kamilbednarz Jan 12, 2014
e2cf2c1
Add error messages for decryption exceptions
kamilbednarz Jan 12, 2014
41ca735
AES256Cipher inherits from object
kamilbednarz Jan 12, 2014
f3413df
Remove manuall deleting encryptors/decryptors
kamilbednarz Jan 12, 2014
bc62035
Refactor HMAC validation
kamilbednarz Jan 12, 2014
96f7ea5
encrypted_data_bag_item module refactored. Removed nested classes
kamilbednarz Jan 12, 2014
eafc2f6
Merge remote-tracking branch 'upstream/master' into feature/encrypted…
kamilbednarz Sep 5, 2018
b8cc53c
Py3 support for encrypted databags code
fpedrini Sep 4, 2018
7b266cb
Add docs for encrypted_databag
fpedrini Sep 4, 2018
105b22e
Add ability to pass encryption key as string to ChefApi
fpedrini Sep 4, 2018
31209f2
Fix Python3 compatibility
kamilbednarz Sep 5, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions chef/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from chef.api import ChefAPI, autoconfigure
from chef.client import Client
from chef.data_bag import DataBag, DataBagItem
from chef.encrypted_data_bag_item import EncryptedDataBagItem
from chef.exceptions import ChefError
from chef.node import Node
from chef.role import Role
Expand Down
129 changes: 129 additions & 0 deletions chef/aes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import os
import sys

from ctypes import *
from chef.rsa import _eay, SSLError

c_int_p = POINTER(c_int)

# void EVP_CIPHER_CTX_init(EVP_CIPHER_CTX *a);
EVP_CIPHER_CTX_init = _eay.EVP_CIPHER_CTX_init
EVP_CIPHER_CTX_init.argtypes = [c_void_p]
EVP_CIPHER_CTX_init.restype = None

#int EVP_CipherUpdate(EVP_CIPHER_CTX *ctx, unsigned char *out,
# int *outl, unsigned char *in, int inl);
EVP_CipherUpdate = _eay.EVP_CipherUpdate
EVP_CipherUpdate.argtypes = [c_void_p, c_char_p, c_int_p, c_char_p, c_int]
EVP_CipherUpdate.restype = c_int

#int EVP_CipherFinal(EVP_CIPHER_CTX *ctx, unsigned char *out,
# int *outl);
EVP_CipherFinal = _eay.EVP_CipherFinal
EVP_CipherFinal.argtypes = [c_void_p, c_char_p, c_int_p]
EVP_CipherFinal.restype = c_int

#EVP_CIPHER *EVP_aes_256_cbc(void);
EVP_aes_256_cbc = _eay.EVP_aes_256_cbc
EVP_aes_256_cbc.argtypes = []
EVP_aes_256_cbc.restype = c_void_p

#EVP_MD *EVP_sha1(void);
EVP_sha1 = _eay.EVP_sha1
EVP_sha1.argtypes = []
EVP_sha1.restype = c_void_p

#int EVP_CipherInit(EVP_CIPHER_CTX *ctx, const EVP_CIPHER *type,
# unsigned char *key, unsigned char *iv, int enc);
EVP_CipherInit = _eay.EVP_CipherInit
EVP_CipherInit.argtypes = [c_void_p, c_void_p, c_char_p, c_char_p, c_int]
EVP_CipherInit.restype = c_int

#int EVP_CIPHER_CTX_set_padding(EVP_CIPHER_CTX *x, int padding);
EVP_CIPHER_CTX_set_padding = _eay.EVP_CIPHER_CTX_set_padding
EVP_CIPHER_CTX_set_padding.argtypes = [c_void_p, c_int]
EVP_CIPHER_CTX_set_padding.restype = c_int

# Structures required for ctypes

EVP_MAX_IV_LENGTH = 16
EVP_MAX_BLOCK_LENGTH = 32
AES_BLOCK_SIZE = 16

class EVP_CIPHER(Structure):
_fields_ = [
("nid", c_int),
("block_size", c_int),
("key_len", c_int),
("iv_len", c_int),
("flags", c_ulong),
("init", c_voidp),
("do_cipher", c_voidp),
("cleanup", c_voidp),
("set_asn1_parameters", c_voidp),
("get_asn1_parameters", c_voidp),
("ctrl", c_voidp),
("app_data", c_voidp)
]

class EVP_CIPHER_CTX(Structure):
_fields_ = [
("cipher", POINTER(EVP_CIPHER)),
("engine", c_voidp),
("encrypt", c_int),
("buflen", c_int),
("oiv", c_ubyte * EVP_MAX_IV_LENGTH),
("iv", c_ubyte * EVP_MAX_IV_LENGTH),
("buf", c_ubyte * EVP_MAX_BLOCK_LENGTH),
("num", c_int),
("app_data", c_voidp),
("key_len", c_int),
("flags", c_ulong),
("cipher_data", c_voidp),
("final_used", c_int),
("block_mask", c_int),
("final", c_ubyte * EVP_MAX_BLOCK_LENGTH) ]


class AES256Cipher(object):
def __init__(self, key, iv, salt='12345678'):
self.key_data = create_string_buffer(key)
self.iv = create_string_buffer(iv)
self.encryptor = self.decryptor = None
self.salt = create_string_buffer(salt.encode('utf8'))

self.encryptor = EVP_CIPHER_CTX()
self._init_cipher(byref(self.encryptor), 1)

self.decryptor = EVP_CIPHER_CTX()
self._init_cipher(byref(self.decryptor), 0)

def _init_cipher(self, ctypes_cipher, crypt_mode):
""" crypt_mode parameter is a flag deciding whether the cipher should be
used for encryption (1) or decryption (0) """
EVP_CIPHER_CTX_init(ctypes_cipher)
EVP_CipherInit(ctypes_cipher, EVP_aes_256_cbc(), self.key_data, self.iv, c_int(crypt_mode))
EVP_CIPHER_CTX_set_padding(ctypes_cipher, c_int(1))

def _process_data(self, ctypes_cipher, data):
# Guard against str passed in when using python3
if sys.version_info[0] > 2 and isinstance(data, str):
data = data.encode('utf8')
length = c_int(len(data))
buf_length = c_int(length.value + AES_BLOCK_SIZE)
buf = create_string_buffer(buf_length.value)

final_buf = create_string_buffer(AES_BLOCK_SIZE)
final_length = c_int(0)

EVP_CipherUpdate(ctypes_cipher, buf, byref(buf_length), create_string_buffer(data), length)
EVP_CipherFinal(ctypes_cipher, final_buf, byref(final_length))

return string_at(buf, buf_length) + string_at(final_buf, final_length)


def encrypt(self, data):
return self._process_data(byref(self.encryptor), data)

def decrypt(self, data):
return self._process_data(byref(self.decryptor), data)
28 changes: 25 additions & 3 deletions chef/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,19 @@ class ChefAPI(object):

with ChefAPI('http://localhost:4000', 'client.pem', 'admin'):
n = Node('web1')

In order to use :class:`EncryptedDataBagItem` object it is necessary
to specify either a path to a file containing the Chef secret key and
the Encrypted Databag version to be used (v1 by default).
If both secret_file and secret_key are passed as argument, secret_key
will take precedence.
"""

ruby_value_re = re.compile(r'#\{([^}]+)\}')
env_value_re = re.compile(r'ENV\[(.+)\]')
ruby_string_re = re.compile(r'^\s*(["\'])(.*?)\1\s*$')

def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=True):
def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=True, secret_file=None, secret_key=None, encryption_version=1):
self.url = url.rstrip('/')
self.parsed_url = six.moves.urllib.parse.urlparse(self.url)
if not isinstance(key, Key):
Expand All @@ -67,11 +73,22 @@ def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=Tr
self.client = client
self.version = version
self.headers = dict((k.lower(), v) for k, v in six.iteritems(headers))
self.encryption_version = encryption_version
self.version_parsed = pkg_resources.parse_version(self.version)
self.platform = self.parsed_url.hostname == 'api.opscode.com'
self.ssl_verify = ssl_verify
if not api_stack_value():
self.set_default()
self.encryption_key = None
# Read the secret key from the input file
if secret_file is not None:
self.secret_file = secret_file
if os.path.exists(self.secret_file):
self.encryption_key = open(self.secret_file).read().strip()
if secret_key is not None:
if encryption_key is not None:
Copy link

@code-haven code-haven Jan 2, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should check self.encryption_key instead - as encryption_key is not defined

log.debug('Two encryption key found (file and parameter). The key passed as parameter will be used')
self.encryption_key = secret_key

@classmethod
def from_config_file(cls, path):
Expand All @@ -83,8 +100,8 @@ def from_config_file(cls, path):
# Can't even read the config file
log.debug('Unable to read config file "%s"', path)
return
url = key_path = client_name = None
ssl_verify = True
url = key_path = client_name = encryption_version = None
for line in open(path):
if not line.strip() or line.startswith('#'):
continue # Skip blanks and comments
Expand Down Expand Up @@ -123,6 +140,9 @@ def _ruby_value(match):
elif key == 'node_name':
log.debug('Found client name: %r', value)
client_name = value
elif key == 'data_bag_encrypt_version':
log.debug('Found data bag encryption version: %r', value)
encryption_version = value
elif key == 'client_key':
log.debug('Found key path: %r', value)
key_path = value
Expand Down Expand Up @@ -161,7 +181,9 @@ def _ruby_value(match):
return
if not client_name:
client_name = socket.getfqdn()
return cls(url, key_path, client_name, ssl_verify=ssl_verify)
if not encryption_version:
encryption_version = 1
return cls(url, key_path, client_name, ssl_verify=ssl_verify, encryption_version=encryption_version)

@staticmethod
def get_global():
Expand Down
153 changes: 153 additions & 0 deletions chef/encrypted_data_bag_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError
from chef.aes import AES256Cipher, EVP_MAX_IV_LENGTH
from chef.utils import json
from chef.data_bag import DataBagItem

import os
import sys
import hmac
import base64
import chef
import hashlib
import binascii
import itertools
import six
from six.moves import filterfalse, zip_longest


class EncryptedDataBagItem(DataBagItem):
"""An Encrypted Chef data bag item object.

Encrypted Databag Items behave in the same way as :class:`DatabagItem`
except the keys and values are encrypted as detailed in the Chef docs:
https://docs.chef.io/data_bags.html#encrypt-a-data-bag-item

Refer to the :class:`DatabagItem` documentation for usage.
"""
SUPPORTED_ENCRYPTION_VERSIONS = (1,2)
AES_MODE = 'aes_256_cbc'

def __getitem__(self, key):
if key == 'id':
return self.raw_data[key]
else:
return create_decryptor(self.api.encryption_key, self.raw_data[key]).decrypt()

def __setitem__(self, key, value):
if key == 'id':
self.raw_data[key] = value
else:
self.raw_data[key] = create_encryptor(self.api.encryption_key, value, self.api.encryption_version).to_dict()

def create_encryptor(key, data, version):
try:
return {
1: EncryptorVersion1(key, data),
2: EncryptorVersion2(key, data)
}[version]
except KeyError:
raise ChefUnsupportedEncryptionVersionError(version)

class EncryptorVersion1(object):
VERSION = 1

def __init__(self, key, data):
self.plain_key = key.encode('utf8')
self.key = hashlib.sha256(key.encode('utf8')).digest()
self.data = data
self.iv = binascii.hexlify(os.urandom(int(EVP_MAX_IV_LENGTH/2)))
self.encryptor = AES256Cipher(key=self.key, iv=self.iv)
self.encrypted_data = None

def encrypt(self):
if self.encrypted_data is None:
data = json.dumps({'json_wrapper': self.data})
self.encrypted_data = self.encryptor.encrypt(data)
return self.encrypted_data

def to_dict(self):
return {
"encrypted_data": base64.standard_b64encode(self.encrypt()).decode('utf8'),
"iv": base64.standard_b64encode(self.iv).decode('utf8'),
"version": self.VERSION,
"cipher": "aes-256-cbc"
}

class EncryptorVersion2(EncryptorVersion1):
VERSION = 2

def __init__(self, key, data):
super(EncryptorVersion2, self).__init__(key, data)
self.hmac = None

def encrypt(self):
self.encrypted_data = super(EncryptorVersion2, self).encrypt()
self.hmac = (self.hmac if self.hmac is not None else self._generate_hmac())
return self.encrypted_data

def _generate_hmac(self):
raw_hmac = hmac.new(self.plain_key, base64.standard_b64encode(self.encrypted_data), hashlib.sha256).digest()
return raw_hmac

def to_dict(self):
result = super(EncryptorVersion2, self).to_dict()
result['hmac'] = base64.standard_b64encode(self.hmac).decode('utf8')
return result

def get_decryption_version(data):
if 'version' in data:
if str(data['version']) in map(str, EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS):
return data['version']
else:
raise ChefUnsupportedEncryptionVersionError(data['version'])
else:
return 1

def create_decryptor(key, data):
version = get_decryption_version(data)
if version == 1:
return DecryptorVersion1(key, data['encrypted_data'], data['iv'])
elif version == 2:
return DecryptorVersion2(key, data['encrypted_data'], data['iv'], data['hmac'])

class DecryptorVersion1(object):
def __init__(self, key, data, iv):
self.key = hashlib.sha256(key.encode('utf8')).digest()
self.data = base64.standard_b64decode(data)
self.iv = base64.standard_b64decode(iv)
self.decryptor = AES256Cipher(key=self.key, iv=self.iv)

def decrypt(self):
value = self.decryptor.decrypt(self.data)
# After decryption we should get a string with JSON
try:
value = json.loads(value.decode('utf-8'))
except ValueError:
raise ChefDecryptionError("Error decrypting data bag value. Most likely the provided key is incorrect")
return value['json_wrapper']

class DecryptorVersion2(DecryptorVersion1):
def __init__(self, key, data, iv, hmac):
super(DecryptorVersion2, self).__init__(key, data, iv)
self.hmac = base64.standard_b64decode(hmac)
self.encoded_data = data

def _validate_hmac(self):
encoded_data = self.encoded_data.encode('utf8')

expected_hmac = hmac.new(self.key, encoded_data, hashlib.sha256).digest()
valid = len(expected_hmac) ^ len(self.hmac)
for expected_char, candidate_char in zip_longest(expected_hmac, self.hmac):
if sys.version_info[0] > 2:
valid |= expected_char ^ candidate_char
else:
valid |= ord(expected_char) ^ ord(candidate_char)

return valid == 0

def decrypt(self):
if self._validate_hmac():
return super(DecryptorVersion2, self).decrypt()
else:
raise ChefDecryptionError("Error decrypting data bag value. HMAC validation failed.")

9 changes: 8 additions & 1 deletion chef/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ class ChefServerNotFoundError(ChefServerError):
class ChefAPIVersionError(ChefError):
"""An incompatible API version error"""


class ChefObjectTypeError(ChefError):
"""An invalid object type error"""

class ChefUnsupportedEncryptionVersionError(ChefError):
def __init__(self, version):
message = "This version of chef does not support encrypted data bag item format version %s" % version
return super(ChefError, self).__init__(message)

class ChefDecryptionError(ChefError):
"""Error decrypting data bag value. Most likely the provided key is incorrect"""

5 changes: 5 additions & 0 deletions chef/tests/configs/encryption.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
chef_server_url 'http://chef:4000'
client_key '../client.pem'
node_name "test_1"

data_bag_encrypt_version '2'
1 change: 1 addition & 0 deletions chef/tests/encryption_key
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1FNnCAvhritIPDerwUYKFNFZ8NaAIWyCXV43hqSVDeGk5pDt253E+RlUHlL7H/3HEFo/gnZWsk9Y5bEyOP7tUQSnT8enCbFqtvyBpiVep+4BYHss2aWBqqsm7aiPXa+BQHagmVHySleU+sFdLcNOASNMLiUB6azk8Xme1Gris8Awavrn/s5vRB7Bsl7xl84nSmu7Lg3C6Vezyye6K4ZmJOA1p0QPSMVGEJC5RkwAmA+W6G5MilBDMdxxN7mxy49WRSFLT35xFQNJOJ+Rvk53FJrhOCmiHkVNumF2MuhIpLsbrqpcdsU5UIxibjd2Dt+yz7/qytCsGSyZkVws09MgAH5icjZYV6DL8Y9CRa39KEyHl5DjHmWiRiuoFTc6oiUa0QAh08X64jz8OvcTWCJD9Fi5PdNkJblDMp9g6vvn/UPTos2s0KjzkLKdRbLrJovCSs52kkhTzfYXOYt4rmi5mQbdtcr2vsXFs+CT68Yfs56RFA2BA/+KLdaNzHFeH/Wl3h/hrciQfpAW62jnttBGr7sMV0pevXQTr2npPWq0fZHWO4gxkrL729najiDPOEeA2TeHV6+h6znZNYvfpNIRPIOMDLG7bdq2+/G7OvuE7u15qHYzWlJpvouhLA55upDK6CK1ONQw14JIK4+s9Dt2gYpV//G7MqnFMsnq3Y9ptt4=
Loading