Skip to content

Commit c68156d

Browse files
committed
Pull request coderanger#27 from kamilbednarz: Add support for encrypted data bag items v2
1 parent 9eb0ea2 commit c68156d

File tree

10 files changed

+402
-3
lines changed

10 files changed

+402
-3
lines changed

chef/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from chef.api import ChefAPI, autoconfigure
66
from chef.client import Client
77
from chef.data_bag import DataBag, DataBagItem
8+
from chef.encrypted_data_bag_item import EncryptedDataBagItem
89
from chef.exceptions import ChefError
910
from chef.node import Node
1011
from chef.role import Role

chef/aes.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import os
2+
3+
from ctypes import *
4+
from rsa import load_crypto_lib, SSLError
5+
6+
_eay = load_crypto_lib()
7+
8+
c_int_p = POINTER(c_int)
9+
10+
# void EVP_CIPHER_CTX_init(EVP_CIPHER_CTX *a);
11+
EVP_CIPHER_CTX_init = _eay.EVP_CIPHER_CTX_init
12+
EVP_CIPHER_CTX_init.argtypes = [c_void_p]
13+
EVP_CIPHER_CTX_init.restype = None
14+
15+
#int EVP_CipherUpdate(EVP_CIPHER_CTX *ctx, unsigned char *out,
16+
# int *outl, unsigned char *in, int inl);
17+
EVP_CipherUpdate = _eay.EVP_CipherUpdate
18+
EVP_CipherUpdate.argtypes = [c_void_p, c_char_p, c_int_p, c_char_p, c_int]
19+
EVP_CipherUpdate.restype = c_int
20+
21+
#int EVP_CipherFinal(EVP_CIPHER_CTX *ctx, unsigned char *out,
22+
# int *outl);
23+
EVP_CipherFinal = _eay.EVP_CipherFinal
24+
EVP_CipherFinal.argtypes = [c_void_p, c_char_p, c_int_p]
25+
EVP_CipherFinal.restype = c_int
26+
27+
#EVP_CIPHER *EVP_aes_256_cbc(void);
28+
EVP_aes_256_cbc = _eay.EVP_aes_256_cbc
29+
EVP_aes_256_cbc.argtypes = []
30+
EVP_aes_256_cbc.restype = c_void_p
31+
32+
#EVP_MD *EVP_sha1(void);
33+
EVP_sha1 = _eay.EVP_sha1
34+
EVP_sha1.argtypes = []
35+
EVP_sha1.restype = c_void_p
36+
37+
#int EVP_CipherInit(EVP_CIPHER_CTX *ctx, const EVP_CIPHER *type,
38+
# unsigned char *key, unsigned char *iv, int enc);
39+
EVP_CipherInit = _eay.EVP_CipherInit
40+
EVP_CipherInit.argtypes = [c_void_p, c_void_p, c_char_p, c_char_p, c_int]
41+
EVP_CipherInit.restype = c_int
42+
43+
#int EVP_CIPHER_CTX_set_padding(EVP_CIPHER_CTX *x, int padding);
44+
EVP_CIPHER_CTX_set_padding = _eay.EVP_CIPHER_CTX_set_padding
45+
EVP_CIPHER_CTX_set_padding.argtypes = [c_void_p, c_int]
46+
EVP_CIPHER_CTX_set_padding.restype = c_int
47+
48+
# Structures required for ctypes
49+
50+
EVP_MAX_IV_LENGTH = 16
51+
EVP_MAX_BLOCK_LENGTH = 32
52+
AES_BLOCK_SIZE = 16
53+
54+
class EVP_CIPHER(Structure):
55+
_fields_ = [
56+
("nid", c_int),
57+
("block_size", c_int),
58+
("key_len", c_int),
59+
("iv_len", c_int),
60+
("flags", c_ulong),
61+
("init", c_voidp),
62+
("do_cipher", c_voidp),
63+
("cleanup", c_voidp),
64+
("set_asn1_parameters", c_voidp),
65+
("get_asn1_parameters", c_voidp),
66+
("ctrl", c_voidp),
67+
("app_data", c_voidp)
68+
]
69+
70+
class EVP_CIPHER_CTX(Structure):
71+
_fields_ = [
72+
("cipher", POINTER(EVP_CIPHER)),
73+
("engine", c_voidp),
74+
("encrypt", c_int),
75+
("buflen", c_int),
76+
("oiv", c_ubyte * EVP_MAX_IV_LENGTH),
77+
("iv", c_ubyte * EVP_MAX_IV_LENGTH),
78+
("buf", c_ubyte * EVP_MAX_BLOCK_LENGTH),
79+
("num", c_int),
80+
("app_data", c_voidp),
81+
("key_len", c_int),
82+
("flags", c_ulong),
83+
("cipher_data", c_voidp),
84+
("final_used", c_int),
85+
("block_mask", c_int),
86+
("final", c_ubyte * EVP_MAX_BLOCK_LENGTH) ]
87+
88+
89+
class AES256Cipher(object):
90+
def __init__(self, key, iv, salt='12345678'):
91+
self.key_data = create_string_buffer(key)
92+
self.iv = create_string_buffer(iv)
93+
self.encryptor = self.decryptor = None
94+
self.salt = create_string_buffer(salt)
95+
96+
self.encryptor = EVP_CIPHER_CTX()
97+
self._init_cipher(byref(self.encryptor), 1)
98+
99+
self.decryptor = EVP_CIPHER_CTX()
100+
self._init_cipher(byref(self.decryptor), 0)
101+
102+
def _init_cipher(self, ctypes_cipher, crypt_mode):
103+
""" crypt_mode parameter is a flag deciding whether the cipher should be
104+
used for encryption (1) or decryption (0) """
105+
EVP_CIPHER_CTX_init(ctypes_cipher)
106+
EVP_CipherInit(ctypes_cipher, EVP_aes_256_cbc(), self.key_data, self.iv, c_int(crypt_mode))
107+
EVP_CIPHER_CTX_set_padding(ctypes_cipher, c_int(1))
108+
109+
def _process_data(self, ctypes_cipher, data):
110+
length = c_int(len(data))
111+
buf_length = c_int(length.value + AES_BLOCK_SIZE)
112+
buf = create_string_buffer(buf_length.value)
113+
114+
final_buf = create_string_buffer(AES_BLOCK_SIZE)
115+
final_length = c_int(0)
116+
117+
EVP_CipherUpdate(ctypes_cipher, buf, byref(buf_length), create_string_buffer(data), length)
118+
EVP_CipherFinal(ctypes_cipher, final_buf, byref(final_length))
119+
120+
return string_at(buf, buf_length) + string_at(final_buf, final_length)
121+
122+
123+
def encrypt(self, data):
124+
return self._process_data(byref(self.encryptor), data)
125+
126+
def decrypt(self, data):
127+
return self._process_data(byref(self.decryptor), data)

chef/api.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class ChefAPI(object):
5656
env_value_re = re.compile(r'ENV\[(.+)\]')
5757
ruby_string_re = re.compile(r'^\s*(["\'])(.*?)\1\s*$')
5858

59-
def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=True):
59+
def __init__(self, url, key, client, version='0.10.8', headers={}, secret_file=None, encryption_version=1):
6060
self.url = url.rstrip('/')
6161
self.parsed_url = six.moves.urllib.parse.urlparse(self.url)
6262
if not isinstance(key, Key):
@@ -66,12 +66,19 @@ def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=Tr
6666
self.key = key
6767
self.client = client
6868
self.version = version
69+
self.encryption_version = encryption_version
6970
self.headers = dict((k.lower(), v) for k, v in six.iteritems(headers))
7071
self.version_parsed = pkg_resources.parse_version(self.version)
7172
self.platform = self.parsed_url.hostname == 'api.opscode.com'
7273
self.ssl_verify = ssl_verify
7374
if not api_stack_value():
7475
self.set_default()
76+
self.encryption_key = None
77+
if secret_file is not None:
78+
self.secret_file = secret_file
79+
if os.path.exists(self.secret_file):
80+
self.encryption_key = open(self.secret_file).read().strip()
81+
7582

7683
@classmethod
7784
def from_config_file(cls, path):
@@ -83,7 +90,7 @@ def from_config_file(cls, path):
8390
# Can't even read the config file
8491
log.debug('Unable to read config file "%s"', path)
8592
return
86-
url = key_path = client_name = None
93+
url = key_path = client_name = encryption_version = None
8794
ssl_verify = True
8895
for line in open(path):
8996
if not line.strip() or line.startswith('#'):
@@ -123,6 +130,9 @@ def _ruby_value(match):
123130
elif key == 'node_name':
124131
log.debug('Found client name: %r', value)
125132
client_name = value
133+
elif key == 'data_bag_encrypt_version':
134+
log.debug('Found data bag encryption version: %r', value)
135+
encryption_version = value
126136
elif key == 'client_key':
127137
log.debug('Found key path: %r', value)
128138
key_path = value
@@ -158,7 +168,9 @@ def _ruby_value(match):
158168
return
159169
if not client_name:
160170
client_name = socket.getfqdn()
161-
return cls(url, key_path, client_name, ssl_verify=ssl_verify)
171+
if not encryption_version:
172+
encryption_version = 1
173+
return cls(url, key_path, client_name, encryption_version=encryption_version)
162174

163175
@staticmethod
164176
def get_global():

chef/encrypted_data_bag_item.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from chef.exceptions import ChefUnsupportedEncryptionVersionError, ChefDecryptionError
2+
from chef.aes import AES256Cipher
3+
from chef.utils import json
4+
from chef.data_bag import DataBagItem
5+
6+
import os
7+
import hmac
8+
import base64
9+
import chef
10+
import hashlib
11+
import itertools
12+
13+
class EncryptedDataBagItem(DataBagItem):
14+
SUPPORTED_ENCRYPTION_VERSIONS = (1,2)
15+
AES_MODE = 'aes_256_cbc'
16+
17+
def __getitem__(self, key):
18+
if key == 'id':
19+
return self.raw_data[key]
20+
else:
21+
return create_decryptor(self.api.encryption_key, self.raw_data[key]).decrypt()
22+
23+
def __setitem__(self, key, value):
24+
if key == 'id':
25+
self.raw_data[key] = value
26+
else:
27+
self.raw_data[key] = create_encryptor(self.api.encryption_key, value, self.api.encryption_version).to_dict()
28+
29+
def create_encryptor(key, data, version):
30+
try:
31+
return {
32+
1: EncryptorVersion1(key, data),
33+
2: EncryptorVersion2(key, data)
34+
}[version]
35+
except KeyError:
36+
raise ChefUnsupportedEncryptionVersionError(version)
37+
38+
class EncryptorVersion1(object):
39+
VERSION = 1
40+
41+
def __init__(self, key, data):
42+
self.plain_key = key
43+
self.key = hashlib.sha256(key).digest()
44+
self.data = data
45+
self.iv = os.urandom(8).encode('hex')
46+
self.encryptor = AES256Cipher(key=self.key, iv=self.iv)
47+
self.encrypted_data = None
48+
49+
def encrypt(self):
50+
if self.encrypted_data is None:
51+
data = json.dumps({'json_wrapper': self.data})
52+
self.encrypted_data = self.encryptor.encrypt(data)
53+
return self.encrypted_data
54+
55+
def to_dict(self):
56+
return {
57+
"encrypted_data": base64.standard_b64encode(self.encrypt()),
58+
"iv": base64.standard_b64encode(self.iv),
59+
"version": self.VERSION,
60+
"cipher": "aes-256-cbc"
61+
}
62+
63+
class EncryptorVersion2(EncryptorVersion1):
64+
VERSION = 2
65+
66+
def __init__(self, key, data):
67+
super(EncryptorVersion2, self).__init__(key, data)
68+
self.hmac = None
69+
70+
def encrypt(self):
71+
self.encrypted_data = super(EncryptorVersion2, self).encrypt()
72+
self.hmac = (self.hmac if self.hmac is not None else self._generate_hmac())
73+
return self.encrypted_data
74+
75+
def _generate_hmac(self):
76+
raw_hmac = hmac.new(self.plain_key, base64.standard_b64encode(self.encrypted_data), hashlib.sha256).digest()
77+
return raw_hmac
78+
79+
def to_dict(self):
80+
result = super(EncryptorVersion2, self).to_dict()
81+
result['hmac'] = base64.standard_b64encode(self.hmac)
82+
return result
83+
84+
def get_decryption_version(data):
85+
if data.has_key('version'):
86+
if str(data['version']) in map(str, EncryptedDataBagItem.SUPPORTED_ENCRYPTION_VERSIONS):
87+
return data['version']
88+
else:
89+
raise ChefUnsupportedEncryptionVersionError(data['version'])
90+
else:
91+
return 1
92+
93+
def create_decryptor(key, data):
94+
version = get_decryption_version(data)
95+
if version == 1:
96+
return DecryptorVersion1(key, data['encrypted_data'], data['iv'])
97+
elif version == 2:
98+
return DecryptorVersion2(key, data['encrypted_data'], data['iv'], data['hmac'])
99+
100+
class DecryptorVersion1(object):
101+
def __init__(self, key, data, iv):
102+
self.key = hashlib.sha256(key).digest()
103+
self.data = base64.standard_b64decode(data)
104+
self.iv = base64.standard_b64decode(iv)
105+
self.decryptor = AES256Cipher(key=self.key, iv=self.iv)
106+
107+
def decrypt(self):
108+
value = self.decryptor.decrypt(self.data)
109+
# After decryption we should get a string with JSON
110+
try:
111+
value = json.loads(value)
112+
except ValueError:
113+
raise ChefDecryptionError("Error decrypting data bag value. Most likely the provided key is incorrect")
114+
return value['json_wrapper']
115+
116+
class DecryptorVersion2(DecryptorVersion1):
117+
def __init__(self, key, data, iv, hmac):
118+
super(DecryptorVersion2, self).__init__(key, data, iv)
119+
self.hmac = base64.standard_b64decode(hmac)
120+
self.encoded_data = data
121+
122+
def _validate_hmac(self):
123+
expected_hmac = hmac.new(self.key, self.encoded_data, hashlib.sha256).digest()
124+
valid = len(expected_hmac) ^ len(self.hmac)
125+
for expected_char, candidate_char in itertools.izip_longest(expected_hmac, self.hmac):
126+
valid |= ord(expected_char) ^ ord(candidate_char)
127+
return valid == 0
128+
129+
def decrypt(self):
130+
if self._validate_hmac():
131+
return super(DecryptorVersion2, self).decrypt()
132+
else:
133+
raise ChefDecryptionError("Error decrypting data bag value. HMAC validation failed.")

chef/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ class ChefServerNotFoundError(ChefServerError):
3131
class ChefAPIVersionError(ChefError):
3232
"""An incompatible API version error"""
3333

34+
class ChefUnsupportedEncryptionVersionError(ChefError):
35+
def __init__(self, version):
36+
message = "This version of chef does not support encrypted data bag item format version %s" % version
37+
return super(ChefError, self).__init__(message)
38+
39+
class ChefDecryptionError(ChefError):
40+
"""Error decrypting data bag value. Most likely the provided key is incorrect"""
41+
3442

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

chef/tests/configs/encryption.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
chef_server_url 'http://chef:4000'
2+
client_key '../client.pem'
3+
node_name "test_1"
4+
5+
data_bag_encrypt_version '2'

chef/tests/encryption_key

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1FNnCAvhritIPDerwUYKFNFZ8NaAIWyCXV43hqSVDeGk5pDt253E+RlUHlL7H/3HEFo/gnZWsk9Y5bEyOP7tUQSnT8enCbFqtvyBpiVep+4BYHss2aWBqqsm7aiPXa+BQHagmVHySleU+sFdLcNOASNMLiUB6azk8Xme1Gris8Awavrn/s5vRB7Bsl7xl84nSmu7Lg3C6Vezyye6K4ZmJOA1p0QPSMVGEJC5RkwAmA+W6G5MilBDMdxxN7mxy49WRSFLT35xFQNJOJ+Rvk53FJrhOCmiHkVNumF2MuhIpLsbrqpcdsU5UIxibjd2Dt+yz7/qytCsGSyZkVws09MgAH5icjZYV6DL8Y9CRa39KEyHl5DjHmWiRiuoFTc6oiUa0QAh08X64jz8OvcTWCJD9Fi5PdNkJblDMp9g6vvn/UPTos2s0KjzkLKdRbLrJovCSs52kkhTzfYXOYt4rmi5mQbdtcr2vsXFs+CT68Yfs56RFA2BA/+KLdaNzHFeH/Wl3h/hrciQfpAW62jnttBGr7sMV0pevXQTr2npPWq0fZHWO4gxkrL729najiDPOEeA2TeHV6+h6znZNYvfpNIRPIOMDLG7bdq2+/G7OvuE7u15qHYzWlJpvouhLA55upDK6CK1ONQw14JIK4+s9Dt2gYpV//G7MqnFMsnq3Y9ptt4=

chef/tests/test_aes.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from chef.tests import ChefTestCase, TEST_ROOT
2+
from chef.aes import AES256Cipher
3+
from chef.rsa import SSLError
4+
5+
import base64
6+
import os
7+
import hashlib
8+
import json
9+
10+
class AES256CipherTestCase(ChefTestCase):
11+
def setUp(self):
12+
super(AES256CipherTestCase, self).setUp()
13+
key = hashlib.sha256(open(os.path.join(TEST_ROOT, 'encryption_key')).read()).digest()
14+
iv = base64.standard_b64decode('GLVikZLxG0SWYnb68Pr8Ag==\n')
15+
self.cipher = AES256Cipher(key, iv)
16+
17+
def test_encrypt(self):
18+
encrypted_value = self.cipher.encrypt('{"json_wrapper":"secr3t c0d3"}')
19+
self.assertEquals(base64.standard_b64encode(encrypted_value).strip(), "Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=")
20+
21+
def test_decrypt(self):
22+
decrypted_value = self.cipher.decrypt(base64.standard_b64decode('Ym5T8umtSd0wgjDYq1ZDK5dAh6OjgrTxlloGNf2xYhg=\n'))
23+
self.assertEquals(decrypted_value, '{"json_wrapper":"secr3t c0d3"}')

chef/tests/test_api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ def test_env_variables(self):
2727
finally:
2828
del os.environ['_PYCHEF_TEST_']
2929

30+
def test_encryption(self):
31+
api = self.load('encryption.rb')
32+
self.assertEqual(api.encryption_version, '2')
33+
3034
def test_bad_key_raises(self):
3135
invalids = [None, '']
3236
for item in invalids:

0 commit comments

Comments
 (0)