From 06c69d94d8d2ae5d885865c769329d8b5ebd66a5 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Thu, 17 Oct 2013 17:18:21 -0400 Subject: [PATCH 01/30] Extend ChefAPI with the secret_file option --- chef/api.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/chef/api.py b/chef/api.py index 870592f..6224e68 100644 --- a/chef/api.py +++ b/chef/api.py @@ -56,7 +56,7 @@ class ChefAPI(object): 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): self.url = url.rstrip('/') self.parsed_url = six.moves.urllib.parse.urlparse(self.url) if not isinstance(key, Key): @@ -72,6 +72,12 @@ def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=Tr self.ssl_verify = ssl_verify if not api_stack_value(): self.set_default() + self.encryption_key = None + 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() + @classmethod def from_config_file(cls, path): @@ -83,7 +89,7 @@ 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 + url = key_path = client_name = secret_file = None ssl_verify = True for line in open(path): if not line.strip() or line.startswith('#'): @@ -129,6 +135,11 @@ def _ruby_value(match): if not os.path.isabs(key_path): # Relative paths are relative to the config file key_path = os.path.abspath(os.path.join(os.path.dirname(path), key_path)) + elif key == 'secret_file': + log.debug('Found secret_file: %r', value) + secret_file = value + if os.path.exists(secret_file): + secret_file = open(secret_file).read() if not (url and client_name and key_path): # No URL, no chance this was valid, try running Ruby @@ -161,7 +172,7 @@ def _ruby_value(match): return if not client_name: client_name = socket.getfqdn() - return cls(url, key_path, client_name, ssl_verify=ssl_verify) + return cls(url, key_path, client_name, ssl_verify=ssl_verify, ssl_verify) @staticmethod def get_global(): From 9e5a2f29423e722ed8e24d589faf8070a6f3ab54 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Thu, 17 Oct 2013 17:19:48 -0400 Subject: [PATCH 02/30] Add encryption exceptions --- chef/exceptions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/chef/exceptions.py b/chef/exceptions.py index b32ed3f..ddb40c5 100644 --- a/chef/exceptions.py +++ b/chef/exceptions.py @@ -35,3 +35,10 @@ class ChefAPIVersionError(ChefError): 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""" From 5fc609def7bf65840095d10428a1a654ee93c45c Mon Sep 17 00:00:00 2001 From: "kamilbednarz, syktus" Date: Thu, 17 Oct 2013 17:21:51 -0400 Subject: [PATCH 03/30] Add a class for encrypted data bag item --- chef/encrypted_data_bag_item.py | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 chef/encrypted_data_bag_item.py diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py new file mode 100644 index 0000000..7b69cad --- /dev/null +++ b/chef/encrypted_data_bag_item.py @@ -0,0 +1,56 @@ +from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError +from Crypto.Cipher import AES + +import base64 +import chef +import hashlib +import simplejson as json + +class EncryptedDataBagItem(chef.DataBagItem): + SUPPORTED_ENCRYPTION_VERSIONS = (1,) + + def __getitem__(self, key): + if key == 'id': + return self.raw_data[key] + else: + return EncryptedDataBagItem.Decryptors.create_decryptor(self.api.encryption_key, self.raw_data[key]).decrypt() + + @staticmethod + def get_version(data): + if data.has_key('version'): + if data['version'] in EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS: + return data['version'] + else: + raise ChefUnsupportedEncryptionVersionError(data['version']) + else: + # Should be 0 after implementing DecryptorVersion0 + return "1" + + class Decryptors: + STRIP_CHARS = (chr(15), chr(12),) + + @staticmethod + def create_decryptor(key, data): + return { + 1: EncryptedDataBagItem.Decryptors.DecryptorVersion1(key, data['encrypted_data'], data['iv']) + }[EncryptedDataBagItem.get_version(data)] + + class DecryptorVersion1: + AES_MODE = AES.MODE_CBC + + def __init__(self, key, data, iv): + self.key = hashlib.sha256(key).digest() + self.data = base64.standard_b64decode(data) + self.iv = base64.standard_b64decode(iv) + self.decryptor = AES.new(self.key, self.AES_MODE, self.iv) + + def decrypt(self): + value = self.decryptor.decrypt(self.data) + # Strip all the \r and \n characters + value = value.strip(reduce(lambda x,y: "%s%s" % (x,y), EncryptedDataBagItem.Decryptors.STRIP_CHARS)) + # After decryption we should get a JSON string + try: + value = json.loads(value) + except ValueError: + raise ChefDecryptionError() + return value['json_wrapper'] From ee1035cdb6604965219802bdab903d45718db7ed Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Tue, 22 Oct 2013 17:02:53 -0400 Subject: [PATCH 04/30] Add data bag decryptor for version 2 --- chef/encrypted_data_bag_item.py | 44 ++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 7b69cad..a80854e 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -1,13 +1,14 @@ from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError from Crypto.Cipher import AES +import hmac import base64 import chef import hashlib import simplejson as json class EncryptedDataBagItem(chef.DataBagItem): - SUPPORTED_ENCRYPTION_VERSIONS = (1,) + SUPPORTED_ENCRYPTION_VERSIONS = (1,2) def __getitem__(self, key): if key == 'id': @@ -26,27 +27,27 @@ def get_version(data): # Should be 0 after implementing DecryptorVersion0 return "1" - class Decryptors: - STRIP_CHARS = (chr(15), chr(12),) + class Decryptors(object): + AES_MODE = AES.MODE_CBC + STRIP_CHARS = map(chr,range(0,31)) @staticmethod def create_decryptor(key, data): return { - 1: EncryptedDataBagItem.Decryptors.DecryptorVersion1(key, data['encrypted_data'], data['iv']) + 1: EncryptedDataBagItem.Decryptors.DecryptorVersion1(key, data['encrypted_data'], data['iv']), + 2: EncryptedDataBagItem.Decryptors.DecryptorVersion2(key, data['encrypted_data'], data['iv'], data['hmac']) }[EncryptedDataBagItem.get_version(data)] - class DecryptorVersion1: - AES_MODE = AES.MODE_CBC - + class DecryptorVersion1(object): def __init__(self, key, data, iv): self.key = hashlib.sha256(key).digest() self.data = base64.standard_b64decode(data) self.iv = base64.standard_b64decode(iv) - self.decryptor = AES.new(self.key, self.AES_MODE, self.iv) + self.decryptor = AES.new(self.key, EncryptedDataBagItem.Decryptors.AES_MODE, self.iv) def decrypt(self): value = self.decryptor.decrypt(self.data) - # Strip all the \r and \n characters + # Strip all the whitespace and sequence controll characters value = value.strip(reduce(lambda x,y: "%s%s" % (x,y), EncryptedDataBagItem.Decryptors.STRIP_CHARS)) # After decryption we should get a JSON string try: @@ -54,3 +55,28 @@ def decrypt(self): except ValueError: raise ChefDecryptionError() return value['json_wrapper'] + + class DecryptorVersion2(DecryptorVersion1): + + def __init__(self, key, data, iv, hmac): + super(EncryptedDataBagItem.Decryptors.DecryptorVersion2, self).__init__(key, data, iv) + self.hmac = base64.standard_b64decode(hmac) + self.encoded_data = data + + def _validate_hmac(self): + expected_hmac = hmac.new(self.key, self.encoded_data, hashlib.sha256).digest() + expected_bytes = map(ord, expected_hmac) + candidate_hmac_bytes = map(ord, self.hmac) + valid = len(expected_bytes) ^ len(candidate_hmac_bytes) + index = 0 + for value in expected_bytes: + valid |= value ^ candidate_hmac_bytes[index] + index += 1 + return valid == 0 + + def decrypt(self): + if self._validate_hmac(): + return super(EncryptedDataBagItem.Decryptors.DecryptorVersion2, self).decrypt() + else: + raise ChefDecryptionError() + From 97c63722bb733b0cad723c7b08dba0f02c85dbe3 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Mon, 25 Nov 2013 17:44:32 -0500 Subject: [PATCH 05/30] Add Encryptor version 1 --- chef/encrypted_data_bag_item.py | 65 +++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index a80854e..2205be5 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -1,6 +1,7 @@ from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError -from Crypto.Cipher import AES +from M2Crypto.EVP import Cipher +import os import hmac import base64 import chef @@ -9,6 +10,7 @@ class EncryptedDataBagItem(chef.DataBagItem): SUPPORTED_ENCRYPTION_VERSIONS = (1,2) + AES_MODE = 'aes_256_cbc' def __getitem__(self, key): if key == 'id': @@ -16,6 +18,12 @@ def __getitem__(self, key): else: return EncryptedDataBagItem.Decryptors.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] = EncryptedDataBagItem.Encryptors.create_encryptor(self.api.encryption_key, value, 1).to_dict() + @staticmethod def get_version(data): if data.has_key('version'): @@ -27,29 +35,64 @@ def get_version(data): # Should be 0 after implementing DecryptorVersion0 return "1" + class Encryptors(object): + @staticmethod + def create_encryptor(key, data, version): + try: + return { + 1: EncryptedDataBagItem.Encryptors.EncryptorVersion1(key, data) + }[version] + except KeyError: + raise ChefUnsupportedEncryptionVersionError(version) + + class EncryptorVersion1(object): + VERSION = 1 + def __init__(self, key, data): + self.key = hashlib.sha256(key).digest() + self.data = data + self.iv = os.urandom(8).encode('hex') + self.encryptor = Cipher(alg=EncryptedDataBagItem.AES_MODE, key=self.key, iv=self.iv, op=1) + 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.update(data) + self.encryptor.final() + del self.encryptor + return self.encrypted_data + + def to_dict(self): + return { + "encrypted_data": base64.standard_b64encode(self.encrypt()), + "iv": base64.standard_b64encode(self.iv), + "version": self.VERSION, + "cipher": "aes-256-cbc" + } + class Decryptors(object): - AES_MODE = AES.MODE_CBC - STRIP_CHARS = map(chr,range(0,31)) + STRIP_CHARS = map(chr,range(0,31)) @staticmethod def create_decryptor(key, data): - return { - 1: EncryptedDataBagItem.Decryptors.DecryptorVersion1(key, data['encrypted_data'], data['iv']), - 2: EncryptedDataBagItem.Decryptors.DecryptorVersion2(key, data['encrypted_data'], data['iv'], data['hmac']) - }[EncryptedDataBagItem.get_version(data)] + version = EncryptedDataBagItem.get_version(data) + if version == 1: + return EncryptedDataBagItem.Decryptors.DecryptorVersion1(key, data['encrypted_data'], data['iv']) + elif version == 2: + return EncryptedDataBagItem.Decryptors.DecryptorVersion2(key, data['encrypted_data'], data['iv'], data['hmac']) class DecryptorVersion1(object): def __init__(self, key, data, iv): self.key = hashlib.sha256(key).digest() self.data = base64.standard_b64decode(data) self.iv = base64.standard_b64decode(iv) - self.decryptor = AES.new(self.key, EncryptedDataBagItem.Decryptors.AES_MODE, self.iv) + self.decryptor = Cipher(alg=EncryptedDataBagItem.AES_MODE, key=self.key, iv=self.iv, op=0) def decrypt(self): - value = self.decryptor.decrypt(self.data) - # Strip all the whitespace and sequence controll characters + value = self.decryptor.update(self.data) + self.decryptor.final() + del self.decryptor + # Strip all the whitespace and sequence control characters value = value.strip(reduce(lambda x,y: "%s%s" % (x,y), EncryptedDataBagItem.Decryptors.STRIP_CHARS)) - # After decryption we should get a JSON string + # After decryption we should get a string with JSON try: value = json.loads(value) except ValueError: From 2aad60a0925b8a5ff37612d41d6612c40ec2b3b2 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Tue, 31 Dec 2013 10:28:23 -0500 Subject: [PATCH 06/30] Add support for choosing encryption version --- chef/api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/chef/api.py b/chef/api.py index 6224e68..8e55f99 100644 --- a/chef/api.py +++ b/chef/api.py @@ -56,7 +56,7 @@ class ChefAPI(object): 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, secret_file=None): + def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=True, secret_file=None, encryption_version=1): self.url = url.rstrip('/') self.parsed_url = six.moves.urllib.parse.urlparse(self.url) if not isinstance(key, Key): @@ -67,6 +67,7 @@ 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 @@ -129,6 +130,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 @@ -172,7 +176,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, ssl_verify) + if not encryption_version: + encryption_version = 1 + return cls(url, key_path, client_name, ssl_verify=ssl_verify, secret_file, encryption_version) @staticmethod def get_global(): From 98c0944342d41c327da3454eb03a00768ac19b4d Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Tue, 31 Dec 2013 10:29:10 -0500 Subject: [PATCH 07/30] Add Encryptor version 2 --- chef/encrypted_data_bag_item.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 2205be5..bb3fbdb 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -22,7 +22,7 @@ def __setitem__(self, key, value): if key == 'id': self.raw_data[key] = value else: - self.raw_data[key] = EncryptedDataBagItem.Encryptors.create_encryptor(self.api.encryption_key, value, 1).to_dict() + self.raw_data[key] = EncryptedDataBagItem.Encryptors.create_encryptor(self.api.encryption_key, value, self.api.encryption_version).to_dict() @staticmethod def get_version(data): @@ -32,15 +32,15 @@ def get_version(data): else: raise ChefUnsupportedEncryptionVersionError(data['version']) else: - # Should be 0 after implementing DecryptorVersion0 - return "1" + return 0 class Encryptors(object): @staticmethod def create_encryptor(key, data, version): try: return { - 1: EncryptedDataBagItem.Encryptors.EncryptorVersion1(key, data) + 1: EncryptedDataBagItem.Encryptors.EncryptorVersion1(key, data), + 2: EncryptedDataBagItem.Encryptors.EncryptorVersion2(key, data) }[version] except KeyError: raise ChefUnsupportedEncryptionVersionError(version) @@ -48,6 +48,7 @@ def create_encryptor(key, data, version): class EncryptorVersion1(object): VERSION = 1 def __init__(self, key, data): + self.plain_key = key self.key = hashlib.sha256(key).digest() self.data = data self.iv = os.urandom(8).encode('hex') @@ -69,6 +70,27 @@ def to_dict(self): "cipher": "aes-256-cbc" } + class EncryptorVersion2(EncryptorVersion1): + VERSION = 2 + + def __init__(self, key, data): + super(EncryptedDataBagItem.Encryptors.EncryptorVersion2, self).__init__(key, data) + self.hmac = None + + def encrypt(self): + self.encrypted_data = super(EncryptedDataBagItem.Encryptors.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(EncryptedDataBagItem.Encryptors.EncryptorVersion2, self).to_dict() + result['hmac'] = base64.standard_b64encode(self.hmac) + return result + class Decryptors(object): STRIP_CHARS = map(chr,range(0,31)) From e8cc24885e3f93af30dea39894207a8d5b1f330a Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 11:54:17 -0500 Subject: [PATCH 08/30] Remove reading secret_file knife config from knife.rb --- chef/api.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/chef/api.py b/chef/api.py index 8e55f99..e3cad6b 100644 --- a/chef/api.py +++ b/chef/api.py @@ -90,8 +90,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 = secret_file = 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 @@ -139,11 +139,6 @@ def _ruby_value(match): if not os.path.isabs(key_path): # Relative paths are relative to the config file key_path = os.path.abspath(os.path.join(os.path.dirname(path), key_path)) - elif key == 'secret_file': - log.debug('Found secret_file: %r', value) - secret_file = value - if os.path.exists(secret_file): - secret_file = open(secret_file).read() if not (url and client_name and key_path): # No URL, no chance this was valid, try running Ruby @@ -178,7 +173,7 @@ def _ruby_value(match): client_name = socket.getfqdn() if not encryption_version: encryption_version = 1 - return cls(url, key_path, client_name, ssl_verify=ssl_verify, secret_file, encryption_version) + return cls(url, key_path, client_name, ssl_verify=ssl_verify, encryption_version=encryption_version) @staticmethod def get_global(): From 86b8761d8939f049672d076ca58cbe929a49cd79 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 11:55:09 -0500 Subject: [PATCH 09/30] Update encryption get_version method to support both string and int params --- chef/encrypted_data_bag_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index bb3fbdb..354fd8b 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -27,12 +27,12 @@ def __setitem__(self, key, value): @staticmethod def get_version(data): if data.has_key('version'): - if data['version'] in EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS: + if str(data['version']) in map(str, EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS): return data['version'] else: raise ChefUnsupportedEncryptionVersionError(data['version']) else: - return 0 + return 1 class Encryptors(object): @staticmethod From 61a4629ae2b4f0bdd67d9fede33411c22e4760e5 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 11:55:31 -0500 Subject: [PATCH 10/30] Add EncryptedDataBagItem to chef module --- chef/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/chef/__init__.py b/chef/__init__.py index 2c0645b..4cee954 100644 --- a/chef/__init__.py +++ b/chef/__init__.py @@ -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 From 1e42daaa527174e4b97dc5bb09a238762fe54b4c Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 11:56:14 -0500 Subject: [PATCH 11/30] Add test for reading data bag encryption version from config file --- chef/tests/configs/encryption.rb | 5 +++++ chef/tests/test_api.py | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 chef/tests/configs/encryption.rb diff --git a/chef/tests/configs/encryption.rb b/chef/tests/configs/encryption.rb new file mode 100644 index 0000000..355a75e --- /dev/null +++ b/chef/tests/configs/encryption.rb @@ -0,0 +1,5 @@ +chef_server_url 'http://chef:4000' +client_key '../client.pem' +node_name "test_1" + +data_bag_encrypt_version 2 diff --git a/chef/tests/test_api.py b/chef/tests/test_api.py index 6b19047..35d2b06 100644 --- a/chef/tests/test_api.py +++ b/chef/tests/test_api.py @@ -48,3 +48,7 @@ def test_bad_key_raises(self): for item in invalids: self.assertRaises( ValueError, ChefAPI, 'foobar', item, 'user') + + def test_encryption(self): + api = self.load('encryption.rb') + self.assertEqual(api.encryption_version, '2') From cda71f0fe6a825b488baa93cb5ff9fad10544596 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 11:56:48 -0500 Subject: [PATCH 12/30] Add specs for EncryptedDataBagItem class --- chef/tests/encryption_key | 1 + chef/tests/test_encrypted_data_bag_item.py | 82 ++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 chef/tests/encryption_key create mode 100644 chef/tests/test_encrypted_data_bag_item.py diff --git a/chef/tests/encryption_key b/chef/tests/encryption_key new file mode 100644 index 0000000..440928e --- /dev/null +++ b/chef/tests/encryption_key @@ -0,0 +1 @@ +1FNnCAvhritIPDerwUYKFNFZ8NaAIWyCXV43hqSVDeGk5pDt253E+RlUHlL7H/3HEFo/gnZWsk9Y5bEyOP7tUQSnT8enCbFqtvyBpiVep+4BYHss2aWBqqsm7aiPXa+BQHagmVHySleU+sFdLcNOASNMLiUB6azk8Xme1Gris8Awavrn/s5vRB7Bsl7xl84nSmu7Lg3C6Vezyye6K4ZmJOA1p0QPSMVGEJC5RkwAmA+W6G5MilBDMdxxN7mxy49WRSFLT35xFQNJOJ+Rvk53FJrhOCmiHkVNumF2MuhIpLsbrqpcdsU5UIxibjd2Dt+yz7/qytCsGSyZkVws09MgAH5icjZYV6DL8Y9CRa39KEyHl5DjHmWiRiuoFTc6oiUa0QAh08X64jz8OvcTWCJD9Fi5PdNkJblDMp9g6vvn/UPTos2s0KjzkLKdRbLrJovCSs52kkhTzfYXOYt4rmi5mQbdtcr2vsXFs+CT68Yfs56RFA2BA/+KLdaNzHFeH/Wl3h/hrciQfpAW62jnttBGr7sMV0pevXQTr2npPWq0fZHWO4gxkrL729najiDPOEeA2TeHV6+h6znZNYvfpNIRPIOMDLG7bdq2+/G7OvuE7u15qHYzWlJpvouhLA55upDK6CK1ONQw14JIK4+s9Dt2gYpV//G7MqnFMsnq3Y9ptt4= \ No newline at end of file diff --git a/chef/tests/test_encrypted_data_bag_item.py b/chef/tests/test_encrypted_data_bag_item.py new file mode 100644 index 0000000..dce91ca --- /dev/null +++ b/chef/tests/test_encrypted_data_bag_item.py @@ -0,0 +1,82 @@ +from chef import DataBag, EncryptedDataBagItem +from chef.exceptions import ChefError, ChefUnsupportedEncryptionVersionError, ChefDecryptionError +from chef.tests import ChefTestCase +from chef.api import ChefAPI + +import copy + +class EncryptedDataBagItemTestCase(ChefTestCase): + def setUp(self): + super(EncryptedDataBagItemTestCase, self).setUp() + + """ + This is data encoded using knife, it contains two examples of + encryption methods versions: 1 and 2. + """ + self.knife_examples = { + 'id': 'test', + "pychef_test_ver1": { + "encrypted_data": "Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=\n", + "iv": "GLVikZLxG0SWYnb68Pr8Ag==\n", + "version": 1, + "cipher": "aes-256-cbc" + }, + "pychef_test_ver2": { + "encrypted_data": "m2UCN7TYqRJhGfeGFCWtdlF8qtz15W8EmCRqQ4TI4nJpGm/Bqe1WgnzekJus\n7aM0\n", + "hmac": "mzhfGpf/7rkkIQOSbK22zUv1X+bTCNI2l3FgMBgVOAY=\n", + "iv": "EKNLqsxNfiFFDZPDnyXRfw==\n", + "version": 2, + "cipher": "aes-256-cbc" + } + } + + def test_get_version(self): + self.assertEqual(EncryptedDataBagItem.get_version({"version": "1"}), '1') + self.assertEqual(EncryptedDataBagItem.get_version({"version": 1}), 1) + self.assertEqual(EncryptedDataBagItem.get_version({"version": "2"}), '2') + self.assertEqual(EncryptedDataBagItem.get_version({"version": 2}), 2) + self.assertRaises(ChefUnsupportedEncryptionVersionError, EncryptedDataBagItem.get_version, {"version": 0}) + self.assertRaises(ChefUnsupportedEncryptionVersionError, EncryptedDataBagItem.get_version, {"version": "not a number"}) + + def test__getitem__(self): + api = ChefAPI('https://chef_test:3000', 'client.pem', 'admin', secret_file='encryption_key') + bag = DataBag('test_1') + item = EncryptedDataBagItem(bag, 'test', api, True) + item.raw_data = copy.deepcopy(self.knife_examples) + + self.assertEqual(item['id'], 'test') + self.assertEqual(item['pychef_test_ver1'], 'secr3t c0d3') + self.assertEqual(item['pychef_test_ver2'], '3ncrypt3d d@t@ b@g') + + # Incorrect IV should raise a decryption error + item.raw_data['pychef_test_ver1']['iv'] = 'ZTM1MjY3OTc4ZjAwOTBlNw==' + self.assertRaises(ChefDecryptionError, item.__getitem__, 'pychef_test_ver1') + + # Invalid HMAC should raise a decryption error + item.raw_data['pychef_test_ver2']['hmac'] = 'v0lMrOmi1ZgA/vtfE2NZO2mO62LagIM2KCZSrWiO/8M=' + self.assertRaises(ChefDecryptionError, item.__getitem__, 'pychef_test_ver2') + + def test__set_item__(self): + api = ChefAPI('https://chef_test:3000', 'client.pem', 'admin', secret_file='encryption_key') + bag = DataBag('test_1') + item = EncryptedDataBagItem(bag, 'test', api, True) + item['id'] = 'test' + api.encryption_version = 1 + item['pychef_test_ver1'] = 'secr3t c0d3' + api.encryption_version = 2 + item['pychef_test_ver2'] = '3ncrypt3d d@t@ b@g' + + self.assertEqual(item['id'], 'test') + + self.assertIsInstance(item.raw_data['pychef_test_ver1'], dict) + self.assertEqual(item.raw_data['pychef_test_ver1']['version'], 1) + self.assertEqual(item.raw_data['pychef_test_ver1']['cipher'], 'aes-256-cbc') + self.assertIsNotNone(item.raw_data['pychef_test_ver1']['iv']) + self.assertIsNotNone(item.raw_data['pychef_test_ver1']['encrypted_data']) + + self.assertIsInstance(item.raw_data['pychef_test_ver2'], dict) + self.assertEqual(item.raw_data['pychef_test_ver2']['version'], 2) + self.assertEqual(item.raw_data['pychef_test_ver2']['cipher'], 'aes-256-cbc') + self.assertIsNotNone(item.raw_data['pychef_test_ver2']['iv']) + self.assertIsNotNone(item.raw_data['pychef_test_ver2']['hmac']) + self.assertIsNotNone(item.raw_data['pychef_test_ver2']['encrypted_data']) From 61e039a114d37b14947c6ed902a9dd72c6099c6c Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 12:01:44 -0500 Subject: [PATCH 13/30] Fix issue with failing Api test --- chef/tests/configs/encryption.rb | 2 +- chef/tests/configs/encryption.rb.orig | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 chef/tests/configs/encryption.rb.orig diff --git a/chef/tests/configs/encryption.rb b/chef/tests/configs/encryption.rb index 355a75e..68528fe 100644 --- a/chef/tests/configs/encryption.rb +++ b/chef/tests/configs/encryption.rb @@ -2,4 +2,4 @@ client_key '../client.pem' node_name "test_1" -data_bag_encrypt_version 2 +data_bag_encrypt_version '2' diff --git a/chef/tests/configs/encryption.rb.orig b/chef/tests/configs/encryption.rb.orig new file mode 100644 index 0000000..68528fe --- /dev/null +++ b/chef/tests/configs/encryption.rb.orig @@ -0,0 +1,5 @@ +chef_server_url 'http://chef:4000' +client_key '../client.pem' +node_name "test_1" + +data_bag_encrypt_version '2' From 11ecdf7af81110d56e609237dcef5978daf5d368 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 18:39:17 +0100 Subject: [PATCH 14/30] Fix paths to files in specs for EncryptedDataBagItem --- chef/tests/test_encrypted_data_bag_item.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/chef/tests/test_encrypted_data_bag_item.py b/chef/tests/test_encrypted_data_bag_item.py index dce91ca..05d5646 100644 --- a/chef/tests/test_encrypted_data_bag_item.py +++ b/chef/tests/test_encrypted_data_bag_item.py @@ -1,9 +1,10 @@ from chef import DataBag, EncryptedDataBagItem from chef.exceptions import ChefError, ChefUnsupportedEncryptionVersionError, ChefDecryptionError -from chef.tests import ChefTestCase +from chef.tests import ChefTestCase, TEST_ROOT from chef.api import ChefAPI import copy +import os class EncryptedDataBagItemTestCase(ChefTestCase): def setUp(self): @@ -39,7 +40,7 @@ def test_get_version(self): self.assertRaises(ChefUnsupportedEncryptionVersionError, EncryptedDataBagItem.get_version, {"version": "not a number"}) def test__getitem__(self): - api = ChefAPI('https://chef_test:3000', 'client.pem', 'admin', secret_file='encryption_key') + api = ChefAPI('https://chef_test:3000', os.path.join(TEST_ROOT, 'client.pem'), 'admin', secret_file=os.path.join(TEST_ROOT, 'encryption_key')) bag = DataBag('test_1') item = EncryptedDataBagItem(bag, 'test', api, True) item.raw_data = copy.deepcopy(self.knife_examples) @@ -57,7 +58,7 @@ def test__getitem__(self): self.assertRaises(ChefDecryptionError, item.__getitem__, 'pychef_test_ver2') def test__set_item__(self): - api = ChefAPI('https://chef_test:3000', 'client.pem', 'admin', secret_file='encryption_key') + api = ChefAPI('https://chef_test:3000', os.path.join(TEST_ROOT, 'client.pem'), 'admin', secret_file=os.path.join(TEST_ROOT, 'encryption_key')) bag = DataBag('test_1') item = EncryptedDataBagItem(bag, 'test', api, True) item['id'] = 'test' From 9863d29b77a8ed3fba3ac0571abd3556d566726b Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Wed, 1 Jan 2014 18:42:36 +0100 Subject: [PATCH 15/30] Remove trash file --- chef/tests/configs/encryption.rb.orig | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 chef/tests/configs/encryption.rb.orig diff --git a/chef/tests/configs/encryption.rb.orig b/chef/tests/configs/encryption.rb.orig deleted file mode 100644 index 68528fe..0000000 --- a/chef/tests/configs/encryption.rb.orig +++ /dev/null @@ -1,5 +0,0 @@ -chef_server_url 'http://chef:4000' -client_key '../client.pem' -node_name "test_1" - -data_bag_encrypt_version '2' From 8468559bfd0f87da9c3464b2afc96aaf1e9c0a97 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Fri, 3 Jan 2014 04:27:20 +0100 Subject: [PATCH 16/30] Reworked AES implementation - use ctypes instead of m2crypto --- chef/aes.py | 134 ++++++++++++++++++++++++++++++++ chef/encrypted_data_bag_item.py | 3 +- chef/tests/test_aes.py | 24 ++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 chef/aes.py create mode 100644 chef/tests/test_aes.py diff --git a/chef/aes.py b/chef/aes.py new file mode 100644 index 0000000..abaa232 --- /dev/null +++ b/chef/aes.py @@ -0,0 +1,134 @@ +import os + +from ctypes import * +from rsa import load_crypto_lib, SSLError + +_eay = load_crypto_lib() + +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(): + 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) + + self.encryptor = EVP_CIPHER_CTX() + EVP_CIPHER_CTX_init(byref(self.encryptor)) + EVP_CipherInit(byref(self.encryptor), EVP_aes_256_cbc(), self.key_data, self.iv, c_int(1)) + EVP_CIPHER_CTX_set_padding(byref(self.encryptor), c_int(1)) + + self.decryptor = EVP_CIPHER_CTX() + EVP_CIPHER_CTX_init(byref(self.decryptor)) + EVP_CipherInit(byref(self.decryptor), EVP_aes_256_cbc(), self.key_data, self.iv, c_int(0)) + EVP_CIPHER_CTX_set_padding(byref(self.decryptor), c_int(1)) + + def encrypt(self, data): + 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(byref(self.encryptor), buf, byref(buf_length), create_string_buffer(data), length) + EVP_CipherFinal(byref(self.encryptor), final_buf, byref(final_length)) + + return string_at(buf, buf_length) + string_at(final_buf, final_length) + + def decrypt(self, data): + 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(byref(self.decryptor), buf, byref(buf_length), create_string_buffer(data), length) + EVP_CipherFinal(byref(self.decryptor), final_buf, byref(final_length)) + + return string_at(buf, buf_length) + string_at(final_buf, final_length) + + + + diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 354fd8b..d0a2f69 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -58,7 +58,8 @@ def __init__(self, key, data): def encrypt(self): if self.encrypted_data is None: data = json.dumps({'json_wrapper': self.data}) - self.encrypted_data = self.encryptor.update(data) + self.encryptor.final() + update_data = self.encryptor.update(data) + self.encrypted_data = update_data + self.encryptor.final() del self.encryptor return self.encrypted_data diff --git a/chef/tests/test_aes.py b/chef/tests/test_aes.py new file mode 100644 index 0000000..87ea6b3 --- /dev/null +++ b/chef/tests/test_aes.py @@ -0,0 +1,24 @@ +from chef.tests import ChefTestCase, TEST_ROOT +from chef.aes import AES256Cipher +from chef.rsa import SSLError + +import base64 +import os +import hashlib +import json + +class AES256CipherTestCase(ChefTestCase): + def setUp(self): + super(AES256CipherTestCase, self).setUp() + key = hashlib.sha256(open(os.path.join(TEST_ROOT, 'encryption_key')).read()).digest() + iv = base64.standard_b64decode('GLVikZLxG0SWYnb68Pr8Ag==\n') + self.cipher = AES256Cipher(key, iv) + + def test_encrypt(self): + encrypted_value = self.cipher.encrypt('{"json_wrapper":"secr3t c0d3"}') + self.assertEquals(base64.standard_b64encode(encrypted_value).strip(), "Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=") + + + def test_decrypt(self): + decrypted_value = self.cipher.decrypt(base64.standard_b64decode('Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=\n')) + self.assertEquals(decrypted_value, '{"json_wrapper":"secr3t c0d3"}') From 08980f5e9769637a1cd2eceff021da52e1ab8ccd Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Tue, 7 Jan 2014 11:36:05 +0100 Subject: [PATCH 17/30] Refactored aes cipher class --- chef/aes.py | 35 ++++++++++++++++------------------- chef/tests/test_aes.py | 1 - 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/chef/aes.py b/chef/aes.py index abaa232..29c11bd 100644 --- a/chef/aes.py +++ b/chef/aes.py @@ -94,16 +94,19 @@ def __init__(self, key, iv, salt='12345678'): self.salt = create_string_buffer(salt) self.encryptor = EVP_CIPHER_CTX() - EVP_CIPHER_CTX_init(byref(self.encryptor)) - EVP_CipherInit(byref(self.encryptor), EVP_aes_256_cbc(), self.key_data, self.iv, c_int(1)) - EVP_CIPHER_CTX_set_padding(byref(self.encryptor), c_int(1)) + self._init_cipher(byref(self.encryptor), 1) self.decryptor = EVP_CIPHER_CTX() - EVP_CIPHER_CTX_init(byref(self.decryptor)) - EVP_CipherInit(byref(self.decryptor), EVP_aes_256_cbc(), self.key_data, self.iv, c_int(0)) - EVP_CIPHER_CTX_set_padding(byref(self.decryptor), c_int(1)) + self._init_cipher(byref(self.decryptor), 0) - def encrypt(self, data): + 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): length = c_int(len(data)) buf_length = c_int(length.value + AES_BLOCK_SIZE) buf = create_string_buffer(buf_length.value) @@ -111,23 +114,17 @@ def encrypt(self, data): final_buf = create_string_buffer(AES_BLOCK_SIZE) final_length = c_int(0) - EVP_CipherUpdate(byref(self.encryptor), buf, byref(buf_length), create_string_buffer(data), length) - EVP_CipherFinal(byref(self.encryptor), final_buf, byref(final_length)) + 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 decrypt(self, data): - 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(byref(self.decryptor), buf, byref(buf_length), create_string_buffer(data), length) - EVP_CipherFinal(byref(self.decryptor), final_buf, byref(final_length)) + def encrypt(self, data): + return self._process_data(byref(self.encryptor), data) - return string_at(buf, buf_length) + string_at(final_buf, final_length) + def decrypt(self, data): + return self._process_data(byref(self.decryptor), data) diff --git a/chef/tests/test_aes.py b/chef/tests/test_aes.py index 87ea6b3..46c20f8 100644 --- a/chef/tests/test_aes.py +++ b/chef/tests/test_aes.py @@ -18,7 +18,6 @@ def test_encrypt(self): encrypted_value = self.cipher.encrypt('{"json_wrapper":"secr3t c0d3"}') self.assertEquals(base64.standard_b64encode(encrypted_value).strip(), "Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=") - def test_decrypt(self): decrypted_value = self.cipher.decrypt(base64.standard_b64decode('Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=\n')) self.assertEquals(decrypted_value, '{"json_wrapper":"secr3t c0d3"}') From 8add77065cfff6d061f0bac64e24d11d24e696fd Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Tue, 7 Jan 2014 12:14:04 +0100 Subject: [PATCH 18/30] Replaced encrypted_data_bag_item AES encryption method to AES256Cipher --- chef/encrypted_data_bag_item.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index d0a2f69..138b2c5 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -1,5 +1,5 @@ from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError -from M2Crypto.EVP import Cipher +from chef.aes import AES256Cipher import os import hmac @@ -52,14 +52,13 @@ def __init__(self, key, data): self.key = hashlib.sha256(key).digest() self.data = data self.iv = os.urandom(8).encode('hex') - self.encryptor = Cipher(alg=EncryptedDataBagItem.AES_MODE, key=self.key, iv=self.iv, op=1) + 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}) - update_data = self.encryptor.update(data) - self.encrypted_data = update_data + self.encryptor.final() + self.encrypted_data = self.encryptor.encrypt(data) del self.encryptor return self.encrypted_data @@ -108,10 +107,10 @@ def __init__(self, key, data, iv): self.key = hashlib.sha256(key).digest() self.data = base64.standard_b64decode(data) self.iv = base64.standard_b64decode(iv) - self.decryptor = Cipher(alg=EncryptedDataBagItem.AES_MODE, key=self.key, iv=self.iv, op=0) + self.decryptor = AES256Cipher(key=self.key, iv=self.iv) def decrypt(self): - value = self.decryptor.update(self.data) + self.decryptor.final() + value = self.decryptor.decrypt(self.data) del self.decryptor # Strip all the whitespace and sequence control characters value = value.strip(reduce(lambda x,y: "%s%s" % (x,y), EncryptedDataBagItem.Decryptors.STRIP_CHARS)) From ef9962ad1f4dff969aa34e9d125418075a7cd028 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Tue, 7 Jan 2014 06:24:22 -0500 Subject: [PATCH 19/30] Replace direct usage of simplejson with the one from chef.utils --- chef/encrypted_data_bag_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 138b2c5..cacc181 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -1,12 +1,12 @@ from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError from chef.aes import AES256Cipher +from chef.utils import json import os import hmac import base64 import chef import hashlib -import simplejson as json class EncryptedDataBagItem(chef.DataBagItem): SUPPORTED_ENCRYPTION_VERSIONS = (1,2) From 347d9ad8fc486bcec46773e374992fcb8de05dda Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sat, 11 Jan 2014 18:18:21 +0100 Subject: [PATCH 20/30] Import needed class instead of the whole module --- chef/encrypted_data_bag_item.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index cacc181..5168eee 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -1,6 +1,7 @@ from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError from chef.aes import AES256Cipher from chef.utils import json +from chef.data_bag import DataBagItem import os import hmac @@ -8,7 +9,7 @@ import chef import hashlib -class EncryptedDataBagItem(chef.DataBagItem): +class EncryptedDataBagItem(DataBagItem): SUPPORTED_ENCRYPTION_VERSIONS = (1,2) AES_MODE = 'aes_256_cbc' From 30621ed276bbbc02377ac2192c532bcc5e5de424 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sat, 11 Jan 2014 18:20:36 +0100 Subject: [PATCH 21/30] Removed stripping padding chars in decrypted strings --- chef/encrypted_data_bag_item.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 5168eee..f6d50db 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -93,8 +93,6 @@ def to_dict(self): return result class Decryptors(object): - STRIP_CHARS = map(chr,range(0,31)) - @staticmethod def create_decryptor(key, data): version = EncryptedDataBagItem.get_version(data) @@ -113,8 +111,6 @@ def __init__(self, key, data, iv): def decrypt(self): value = self.decryptor.decrypt(self.data) del self.decryptor - # Strip all the whitespace and sequence control characters - value = value.strip(reduce(lambda x,y: "%s%s" % (x,y), EncryptedDataBagItem.Decryptors.STRIP_CHARS)) # After decryption we should get a string with JSON try: value = json.loads(value) From 6157360264fccb325dc1c9eb36acdf413af88be5 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sun, 12 Jan 2014 17:58:17 +0100 Subject: [PATCH 22/30] Use itertools for HMAC validation --- chef/encrypted_data_bag_item.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index f6d50db..83d4acf 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -8,6 +8,7 @@ import base64 import chef import hashlib +import itertools class EncryptedDataBagItem(DataBagItem): SUPPORTED_ENCRYPTION_VERSIONS = (1,2) @@ -130,10 +131,8 @@ def _validate_hmac(self): expected_bytes = map(ord, expected_hmac) candidate_hmac_bytes = map(ord, self.hmac) valid = len(expected_bytes) ^ len(candidate_hmac_bytes) - index = 0 - for value in expected_bytes: - valid |= value ^ candidate_hmac_bytes[index] - index += 1 + for expected_byte, candidate_byte in itertools.izip_longest(expected_bytes, candidate_hmac_bytes): + valid |= expected_byte ^ candidate_byte return valid == 0 def decrypt(self): From a019733e8c4abc34397bf8de3034474d09acd0a2 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sun, 12 Jan 2014 17:58:39 +0100 Subject: [PATCH 23/30] Add error messages for decryption exceptions --- chef/encrypted_data_bag_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 83d4acf..0a0537b 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -116,7 +116,7 @@ def decrypt(self): try: value = json.loads(value) except ValueError: - raise ChefDecryptionError() + raise ChefDecryptionError("Error decrypting data bag value. Most likely the provided key is incorrect") return value['json_wrapper'] class DecryptorVersion2(DecryptorVersion1): @@ -139,5 +139,5 @@ def decrypt(self): if self._validate_hmac(): return super(EncryptedDataBagItem.Decryptors.DecryptorVersion2, self).decrypt() else: - raise ChefDecryptionError() + raise ChefDecryptionError("Error decrypting data bag value. HMAC validation failed.") From 4a17b79bebd5025caea2a58c2b1a6347519e34d1 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sun, 12 Jan 2014 18:00:13 +0100 Subject: [PATCH 24/30] AES256Cipher inherits from object --- chef/aes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chef/aes.py b/chef/aes.py index 29c11bd..017cee1 100644 --- a/chef/aes.py +++ b/chef/aes.py @@ -86,7 +86,7 @@ class EVP_CIPHER_CTX(Structure): ("final", c_ubyte * EVP_MAX_BLOCK_LENGTH) ] -class AES256Cipher(): +class AES256Cipher(object): def __init__(self, key, iv, salt='12345678'): self.key_data = create_string_buffer(key) self.iv = create_string_buffer(iv) From a40bb6f05a85cff5efc1b81c32d52589a7bba9c3 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sun, 12 Jan 2014 18:38:36 +0100 Subject: [PATCH 25/30] Remove manuall deleting encryptors/decryptors --- chef/encrypted_data_bag_item.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 0a0537b..3712b76 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -61,7 +61,6 @@ def encrypt(self): if self.encrypted_data is None: data = json.dumps({'json_wrapper': self.data}) self.encrypted_data = self.encryptor.encrypt(data) - del self.encryptor return self.encrypted_data def to_dict(self): @@ -111,7 +110,6 @@ def __init__(self, key, data, iv): def decrypt(self): value = self.decryptor.decrypt(self.data) - del self.decryptor # After decryption we should get a string with JSON try: value = json.loads(value) From 8f792a406ee5775cc25049ea5e946a151e2742f7 Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sun, 12 Jan 2014 18:39:15 +0100 Subject: [PATCH 26/30] Refactor HMAC validation --- chef/encrypted_data_bag_item.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 3712b76..12c695e 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -126,11 +126,9 @@ def __init__(self, key, data, iv, hmac): def _validate_hmac(self): expected_hmac = hmac.new(self.key, self.encoded_data, hashlib.sha256).digest() - expected_bytes = map(ord, expected_hmac) - candidate_hmac_bytes = map(ord, self.hmac) - valid = len(expected_bytes) ^ len(candidate_hmac_bytes) - for expected_byte, candidate_byte in itertools.izip_longest(expected_bytes, candidate_hmac_bytes): - valid |= expected_byte ^ candidate_byte + valid = len(expected_hmac) ^ len(self.hmac) + for expected_char, candidate_char in itertools.izip_longest(expected_hmac, self.hmac): + valid |= ord(expected_char) ^ ord(candidate_char) return valid == 0 def decrypt(self): From 12f2969b51ec7f0db64b1023b0d5b8be3862e04c Mon Sep 17 00:00:00 2001 From: Kamil Bednarz Date: Sun, 12 Jan 2014 19:27:32 +0100 Subject: [PATCH 27/30] encrypted_data_bag_item module refactored. Removed nested classes --- chef/encrypted_data_bag_item.py | 219 ++++++++++----------- chef/tests/test_encrypted_data_bag_item.py | 19 +- 2 files changed, 118 insertions(+), 120 deletions(-) diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 12c695e..ad3f97b 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -18,122 +18,117 @@ def __getitem__(self, key): if key == 'id': return self.raw_data[key] else: - return EncryptedDataBagItem.Decryptors.create_decryptor(self.api.encryption_key, self.raw_data[key]).decrypt() + 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] = EncryptedDataBagItem.Encryptors.create_encryptor(self.api.encryption_key, value, self.api.encryption_version).to_dict() - - @staticmethod - def get_version(data): - if data.has_key('version'): - if str(data['version']) in map(str, EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS): - return data['version'] - else: - raise ChefUnsupportedEncryptionVersionError(data['version']) + 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 + self.key = hashlib.sha256(key).digest() + self.data = data + self.iv = os.urandom(8).encode('hex') + 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()), + "iv": base64.standard_b64encode(self.iv), + "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) + return result + +def get_decryption_version(data): + if data.has_key('version'): + if str(data['version']) in map(str, EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS): + return data['version'] else: - return 1 - - class Encryptors(object): - @staticmethod - def create_encryptor(key, data, version): - try: - return { - 1: EncryptedDataBagItem.Encryptors.EncryptorVersion1(key, data), - 2: EncryptedDataBagItem.Encryptors.EncryptorVersion2(key, data) - }[version] - except KeyError: - raise ChefUnsupportedEncryptionVersionError(version) - - class EncryptorVersion1(object): - VERSION = 1 - def __init__(self, key, data): - self.plain_key = key - self.key = hashlib.sha256(key).digest() - self.data = data - self.iv = os.urandom(8).encode('hex') - 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()), - "iv": base64.standard_b64encode(self.iv), - "version": self.VERSION, - "cipher": "aes-256-cbc" - } - - class EncryptorVersion2(EncryptorVersion1): - VERSION = 2 - - def __init__(self, key, data): - super(EncryptedDataBagItem.Encryptors.EncryptorVersion2, self).__init__(key, data) - self.hmac = None - - def encrypt(self): - self.encrypted_data = super(EncryptedDataBagItem.Encryptors.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(EncryptedDataBagItem.Encryptors.EncryptorVersion2, self).to_dict() - result['hmac'] = base64.standard_b64encode(self.hmac) - return result - - class Decryptors(object): - @staticmethod - def create_decryptor(key, data): - version = EncryptedDataBagItem.get_version(data) - if version == 1: - return EncryptedDataBagItem.Decryptors.DecryptorVersion1(key, data['encrypted_data'], data['iv']) - elif version == 2: - return EncryptedDataBagItem.Decryptors.DecryptorVersion2(key, data['encrypted_data'], data['iv'], data['hmac']) - - class DecryptorVersion1(object): - def __init__(self, key, data, iv): - self.key = hashlib.sha256(key).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) - 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(EncryptedDataBagItem.Decryptors.DecryptorVersion2, self).__init__(key, data, iv) - self.hmac = base64.standard_b64decode(hmac) - self.encoded_data = data - - def _validate_hmac(self): - expected_hmac = hmac.new(self.key, self.encoded_data, hashlib.sha256).digest() - valid = len(expected_hmac) ^ len(self.hmac) - for expected_char, candidate_char in itertools.izip_longest(expected_hmac, self.hmac): - valid |= ord(expected_char) ^ ord(candidate_char) - return valid == 0 - - def decrypt(self): - if self._validate_hmac(): - return super(EncryptedDataBagItem.Decryptors.DecryptorVersion2, self).decrypt() - else: - raise ChefDecryptionError("Error decrypting data bag value. HMAC validation failed.") + 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).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) + 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): + expected_hmac = hmac.new(self.key, self.encoded_data, hashlib.sha256).digest() + valid = len(expected_hmac) ^ len(self.hmac) + for expected_char, candidate_char in itertools.izip_longest(expected_hmac, self.hmac): + 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.") diff --git a/chef/tests/test_encrypted_data_bag_item.py b/chef/tests/test_encrypted_data_bag_item.py index 05d5646..eea93c3 100644 --- a/chef/tests/test_encrypted_data_bag_item.py +++ b/chef/tests/test_encrypted_data_bag_item.py @@ -2,6 +2,7 @@ from chef.exceptions import ChefError, ChefUnsupportedEncryptionVersionError, ChefDecryptionError from chef.tests import ChefTestCase, TEST_ROOT from chef.api import ChefAPI +from chef.encrypted_data_bag_item import get_decryption_version import copy import os @@ -31,14 +32,6 @@ def setUp(self): } } - def test_get_version(self): - self.assertEqual(EncryptedDataBagItem.get_version({"version": "1"}), '1') - self.assertEqual(EncryptedDataBagItem.get_version({"version": 1}), 1) - self.assertEqual(EncryptedDataBagItem.get_version({"version": "2"}), '2') - self.assertEqual(EncryptedDataBagItem.get_version({"version": 2}), 2) - self.assertRaises(ChefUnsupportedEncryptionVersionError, EncryptedDataBagItem.get_version, {"version": 0}) - self.assertRaises(ChefUnsupportedEncryptionVersionError, EncryptedDataBagItem.get_version, {"version": "not a number"}) - def test__getitem__(self): api = ChefAPI('https://chef_test:3000', os.path.join(TEST_ROOT, 'client.pem'), 'admin', secret_file=os.path.join(TEST_ROOT, 'encryption_key')) bag = DataBag('test_1') @@ -81,3 +74,13 @@ def test__set_item__(self): self.assertIsNotNone(item.raw_data['pychef_test_ver2']['iv']) self.assertIsNotNone(item.raw_data['pychef_test_ver2']['hmac']) self.assertIsNotNone(item.raw_data['pychef_test_ver2']['encrypted_data']) + +class EncryptedDataBagItemHelpersTestCase(ChefTestCase): + def test_get_version(self): + self.assertEqual(get_decryption_version({"version": "1"}), '1') + self.assertEqual(get_decryption_version({"version": 1}), 1) + self.assertEqual(get_decryption_version({"version": "2"}), '2') + self.assertEqual(get_decryption_version({"version": 2}), 2) + self.assertRaises(ChefUnsupportedEncryptionVersionError, get_decryption_version, {"version": 0}) + self.assertRaises(ChefUnsupportedEncryptionVersionError, get_decryption_version, {"version": "not a number"}) + From 5a943824bf5d1c06b49a5d27c0c5937a3ca0ccb0 Mon Sep 17 00:00:00 2001 From: Francesco Pedrini Date: Tue, 4 Sep 2018 18:25:24 +0200 Subject: [PATCH 28/30] Py3 support for encrypted databags code --- chef/aes.py | 14 ++++++------- chef/encrypted_data_bag_item.py | 35 ++++++++++++++++++++++----------- chef/tests/test_aes.py | 7 ++++--- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/chef/aes.py b/chef/aes.py index 017cee1..31d2e12 100644 --- a/chef/aes.py +++ b/chef/aes.py @@ -1,9 +1,8 @@ import os +import sys from ctypes import * -from rsa import load_crypto_lib, SSLError - -_eay = load_crypto_lib() +from chef.rsa import _eay, SSLError c_int_p = POINTER(c_int) @@ -91,7 +90,7 @@ 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) + self.salt = create_string_buffer(salt.encode('utf8')) self.encryptor = EVP_CIPHER_CTX() self._init_cipher(byref(self.encryptor), 1) @@ -107,6 +106,9 @@ def _init_cipher(self, ctypes_cipher, 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) @@ -125,7 +127,3 @@ def encrypt(self, data): def decrypt(self, data): return self._process_data(byref(self.decryptor), data) - - - - diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index ad3f97b..4b29cab 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -1,14 +1,19 @@ from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError -from chef.aes import AES256Cipher +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): SUPPORTED_ENCRYPTION_VERSIONS = (1,2) @@ -39,10 +44,10 @@ class EncryptorVersion1(object): VERSION = 1 def __init__(self, key, data): - self.plain_key = key - self.key = hashlib.sha256(key).digest() + self.plain_key = key.encode('utf8') + self.key = hashlib.sha256(key.encode('utf8')).digest() self.data = data - self.iv = os.urandom(8).encode('hex') + 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 @@ -54,8 +59,8 @@ def encrypt(self): def to_dict(self): return { - "encrypted_data": base64.standard_b64encode(self.encrypt()), - "iv": base64.standard_b64encode(self.iv), + "encrypted_data": base64.standard_b64encode(self.encrypt()).decode('utf8'), + "iv": base64.standard_b64encode(self.iv).decode('utf8'), "version": self.VERSION, "cipher": "aes-256-cbc" } @@ -78,11 +83,11 @@ def _generate_hmac(self): def to_dict(self): result = super(EncryptorVersion2, self).to_dict() - result['hmac'] = base64.standard_b64encode(self.hmac) + result['hmac'] = base64.standard_b64encode(self.hmac).decode('utf8') return result def get_decryption_version(data): - if data.has_key('version'): + if 'version' in data: if str(data['version']) in map(str, EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS): return data['version'] else: @@ -99,7 +104,7 @@ def create_decryptor(key, data): class DecryptorVersion1(object): def __init__(self, key, data, iv): - self.key = hashlib.sha256(key).digest() + 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) @@ -120,10 +125,16 @@ def __init__(self, key, data, iv, hmac): self.encoded_data = data def _validate_hmac(self): - expected_hmac = hmac.new(self.key, self.encoded_data, hashlib.sha256).digest() + 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 itertools.izip_longest(expected_hmac, self.hmac): - valid |= ord(expected_char) ^ ord(candidate_char) + 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): diff --git a/chef/tests/test_aes.py b/chef/tests/test_aes.py index 46c20f8..38c73d6 100644 --- a/chef/tests/test_aes.py +++ b/chef/tests/test_aes.py @@ -10,14 +10,15 @@ class AES256CipherTestCase(ChefTestCase): def setUp(self): super(AES256CipherTestCase, self).setUp() - key = hashlib.sha256(open(os.path.join(TEST_ROOT, 'encryption_key')).read()).digest() + enc_key = open(os.path.join(TEST_ROOT, 'encryption_key')).read() + key = hashlib.sha256(enc_key.encode('utf8')).digest() iv = base64.standard_b64decode('GLVikZLxG0SWYnb68Pr8Ag==\n') self.cipher = AES256Cipher(key, iv) def test_encrypt(self): encrypted_value = self.cipher.encrypt('{"json_wrapper":"secr3t c0d3"}') - self.assertEquals(base64.standard_b64encode(encrypted_value).strip(), "Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=") + self.assertEquals(base64.standard_b64encode(encrypted_value).strip(), "Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=".encode('utf8')) def test_decrypt(self): decrypted_value = self.cipher.decrypt(base64.standard_b64decode('Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=\n')) - self.assertEquals(decrypted_value, '{"json_wrapper":"secr3t c0d3"}') + self.assertEquals(decrypted_value, '{"json_wrapper":"secr3t c0d3"}'.encode('utf8')) From 1801c55dfe7e3cbce53f319fb324023445c66988 Mon Sep 17 00:00:00 2001 From: Francesco Pedrini Date: Tue, 4 Sep 2018 18:39:26 +0200 Subject: [PATCH 29/30] Add docs for encrypted_databag --- chef/api.py | 4 ++++ chef/encrypted_data_bag_item.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/chef/api.py b/chef/api.py index e3cad6b..aafc1dc 100644 --- a/chef/api.py +++ b/chef/api.py @@ -50,6 +50,10 @@ 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) """ ruby_value_re = re.compile(r'#\{([^}]+)\}') diff --git a/chef/encrypted_data_bag_item.py b/chef/encrypted_data_bag_item.py index 4b29cab..df45db0 100644 --- a/chef/encrypted_data_bag_item.py +++ b/chef/encrypted_data_bag_item.py @@ -16,6 +16,14 @@ 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' From ef67bc0a9088559be5e283b7888998e924ee652c Mon Sep 17 00:00:00 2001 From: Francesco Pedrini Date: Tue, 4 Sep 2018 18:47:35 +0200 Subject: [PATCH 30/30] Add ability to pass encryption key as string to ChefApi --- chef/api.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/chef/api.py b/chef/api.py index aafc1dc..40ca6cd 100644 --- a/chef/api.py +++ b/chef/api.py @@ -53,14 +53,16 @@ class ChefAPI(object): 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) + 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, secret_file=None, encryption_version=1): + 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): @@ -78,11 +80,15 @@ def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=Tr 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: + 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):