Skip to content

Commit 6f17c8e

Browse files
authored
Merge pull request #2 from Mastercard/feature/first_release
Code commit and tests
2 parents f566cb1 + 0ea407c commit 6f17c8e

29 files changed

+2834
-1
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import json
2+
from functools import wraps
3+
from client_encryption.field_level_encryption_config import FieldLevelEncryptionConfig
4+
from client_encryption.session_key_params import SessionKeyParams
5+
from client_encryption.field_level_encryption import encrypt_payload, decrypt_payload
6+
7+
8+
class ApiEncryption(object):
9+
10+
def __init__(self, encryption_conf_file):
11+
"""Load and initialize FieldLevelEncryptionConfig object."""
12+
13+
if type(encryption_conf_file) is dict:
14+
self._encryption_conf = FieldLevelEncryptionConfig(encryption_conf_file)
15+
else:
16+
with open(encryption_conf_file, encoding='utf-8') as json_file:
17+
self._encryption_conf = FieldLevelEncryptionConfig(json_file.read())
18+
19+
def field_encryption(self, func):
20+
"""Decorator for API request. func is APIClient.request"""
21+
22+
@wraps(func)
23+
def request_function(*args, **kwargs):
24+
"""Wrap request and add field encryption layer to it."""
25+
26+
in_body = kwargs.get("body", None)
27+
kwargs["body"] = self._encrypt_payload(kwargs.get("headers", None), in_body) if in_body else in_body
28+
29+
response = func(*args, **kwargs)
30+
31+
if type(response.data) is not str:
32+
response_body = self._decrypt_payload(response.getheaders(), response.json())
33+
response._content = json.dumps(response_body, indent=4).encode('utf-8')
34+
35+
return response
36+
37+
return request_function
38+
39+
def _encrypt_payload(self, headers, body):
40+
"""Encryption enforcement based on configuration - encrypt and add session key params to header or body"""
41+
42+
conf = self._encryption_conf
43+
44+
if conf.use_http_headers:
45+
params = SessionKeyParams.generate(conf)
46+
47+
headers[conf.iv_field_name] = params.iv_value
48+
headers[conf.encrypted_key_field_name] = params.encrypted_key_value
49+
headers[conf.encryption_certificate_fingerprint_field_name] = conf.encryption_certificate_fingerprint
50+
headers[conf.encryption_key_fingerprint_field_name] = conf.encryption_key_fingerprint
51+
headers[conf.oaep_padding_digest_algorithm_field_name] = conf.oaep_padding_digest_algorithm
52+
53+
encrypted_payload = encrypt_payload(body, conf, params)
54+
else:
55+
encrypted_payload = encrypt_payload(body, conf)
56+
57+
return encrypted_payload
58+
59+
def _decrypt_payload(self, headers, body):
60+
"""Encryption enforcement based on configuration - decrypt using session key params from header or body"""
61+
62+
conf = self._encryption_conf
63+
64+
if conf.use_http_headers:
65+
if conf.iv_field_name in headers and conf.encrypted_key_field_name in headers:
66+
iv = headers.pop(conf.iv_field_name)
67+
encrypted_key = headers.pop(conf.encrypted_key_field_name)
68+
oaep_digest_algo = headers.pop(conf.oaep_padding_digest_algorithm_field_name) \
69+
if conf.oaep_padding_digest_algorithm_field_name in headers else None
70+
if conf.encryption_certificate_fingerprint_field_name in headers:
71+
del headers[conf.encryption_certificate_fingerprint_field_name]
72+
if conf.encryption_key_fingerprint_field_name in headers:
73+
del headers[conf.encryption_key_fingerprint_field_name]
74+
75+
params = SessionKeyParams(conf, encrypted_key, iv, oaep_digest_algo)
76+
payload = decrypt_payload(body, conf, params)
77+
else:
78+
# skip decryption if not iv nor key is in headers
79+
payload = body
80+
else:
81+
payload = decrypt_payload(body, conf)
82+
83+
return payload
84+
85+
86+
def add_encryption_layer(api_client, encryption_conf_file):
87+
"""Decorate APIClient.request with field level encryption"""
88+
89+
api_encryption = ApiEncryption(encryption_conf_file)
90+
api_client.request = api_encryption.field_encryption(api_client.request)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import base64
2+
from enum import Enum
3+
from client_encryption.encryption_exception import EncodingError
4+
5+
6+
def encode_bytes(_bytes, encoding):
7+
"""Encode byte sequence to Hex or Base64."""
8+
9+
if type(_bytes) is bytes:
10+
if encoding == Encoding.HEX:
11+
encoded = _bytes.hex()
12+
elif encoding == Encoding.BASE64:
13+
encoded = base64.b64encode(_bytes).decode('utf-8')
14+
else:
15+
raise EncodingError("Encode: Invalid encoding.")
16+
17+
return encoded
18+
else:
19+
raise ValueError("Encode: Invalid or missing input bytes.")
20+
21+
22+
def decode_value(value, encoding):
23+
"""Decode Hex or Base64 string to byte sequence."""
24+
25+
if type(value) is str:
26+
if encoding == Encoding.HEX:
27+
decoded = bytes.fromhex(value)
28+
elif encoding == Encoding.BASE64:
29+
decoded = base64.b64decode(value)
30+
else:
31+
raise EncodingError("Decode: Invalid encoding.")
32+
33+
return decoded
34+
else:
35+
raise ValueError("Decode: Invalid or missing input string.")
36+
37+
38+
class Encoding(Enum):
39+
BASE64 = 'BASE64'
40+
HEX = 'HEX'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
class EncryptionError(Exception):
2+
"""Encryption related exception for client-encryption module."""
3+
pass
4+
5+
6+
class EncodingError(Exception):
7+
"""Encoding not supported or invalid."""
8+
pass
9+
10+
11+
class CertificateError(Exception):
12+
"""Certificate exception for client-encryption module."""
13+
pass
14+
15+
16+
class PrivateKeyError(Exception):
17+
"""Private key exception for client-encryption module."""
18+
pass
19+
20+
21+
class HashAlgorithmError(Exception):
22+
"""Hash algorithm exception for client-encryption module."""
23+
pass
24+
25+
26+
class KeyWrappingError(Exception):
27+
"""Encryption exception on wrapping/unwrapping session key for client-encryption module."""
28+
pass
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from Crypto.PublicKey import RSA
2+
from Crypto.Hash import SHA1, SHA224, SHA256, SHA384, SHA512
3+
from OpenSSL.crypto import load_certificate, load_pkcs12, dump_privatekey, FILETYPE_PEM, FILETYPE_ASN1, Error
4+
from client_encryption.encryption_exception import CertificateError, PrivateKeyError, HashAlgorithmError
5+
6+
7+
_SUPPORTED_HASH = {"SHA1": SHA1, "SHA224": SHA224, "SHA256": SHA256, "SHA384": SHA384, "SHA512": SHA512}
8+
9+
10+
def load_encryption_certificate(certificate_path):
11+
"""Load X509 encryption certificate data at the given file path."""
12+
13+
try:
14+
with open(certificate_path, "rb") as cert_content:
15+
certificate = cert_content.read()
16+
x509 = load_certificate(__get_crypto_file_type(certificate), certificate)
17+
18+
return x509
19+
except IOError:
20+
raise CertificateError("Unable to load certificate.")
21+
except (ValueError, Error):
22+
raise CertificateError("Wrong encryption certificate format.")
23+
24+
25+
def load_decryption_key(key_file_path, decryption_key_password=None):
26+
"""Load a RSA decryption key."""
27+
28+
try:
29+
with open(key_file_path, "rb") as key_content:
30+
private_key = key_content.read()
31+
32+
# if key format is p12 (decryption_key_password is populated) then we have to retrieve the private key
33+
if decryption_key_password is not None:
34+
private_key = __load_pkcs12_private_key(private_key, decryption_key_password)
35+
36+
return RSA.importKey(private_key)
37+
except (Error, IOError):
38+
raise PrivateKeyError("Unable to load key file.")
39+
except ValueError:
40+
raise PrivateKeyError("Wrong decryption key format.")
41+
42+
43+
def __load_pkcs12_private_key(pkcs12_key, password):
44+
"""Load a private key in ASN1 format out of a PKCS#12 container."""
45+
46+
pkcs12 = load_pkcs12(pkcs12_key, password.encode("utf-8")).get_privatekey()
47+
return dump_privatekey(FILETYPE_ASN1, pkcs12)
48+
49+
50+
def __get_crypto_file_type(file_content):
51+
if file_content.startswith(b"-----BEGIN "):
52+
return FILETYPE_PEM
53+
else:
54+
return FILETYPE_ASN1
55+
56+
57+
def load_hash_algorithm(algo_str):
58+
"""Load a hash algorithm object of Crypto.Hash from a list of supported ones."""
59+
60+
if algo_str:
61+
algo_key = algo_str.replace("-", "").upper()
62+
63+
if algo_key in _SUPPORTED_HASH:
64+
return _SUPPORTED_HASH[algo_key]
65+
else:
66+
raise HashAlgorithmError("Hash algorithm invalid or not supported.")
67+
else:
68+
raise HashAlgorithmError("No hash algorithm provided.")
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import json
2+
import copy
3+
from Crypto.Cipher import AES
4+
from Crypto.Util.Padding import pad, unpad
5+
from client_encryption.session_key_params import SessionKeyParams
6+
from client_encryption.encoding_utils import encode_bytes, decode_value
7+
from client_encryption.json_path_utils import get_node, pop_node, update_node, cleanup_node
8+
from client_encryption.encryption_exception import EncryptionError
9+
10+
11+
def encrypt_payload(payload, config, _params=None):
12+
"""Encrypt some fields of a JSON payload using the given configuration."""
13+
14+
try:
15+
if type(payload) is str:
16+
json_payload = json.loads(payload)
17+
else:
18+
json_payload = copy.deepcopy(payload)
19+
20+
for elem, target in config.paths["$"].to_encrypt.items():
21+
if not _params:
22+
params = SessionKeyParams.generate(config)
23+
else:
24+
params = _params
25+
26+
try:
27+
value = pop_node(json_payload, elem)
28+
29+
try:
30+
encrypted_value = _encrypt_value(params.key, params.iv_spec, value)
31+
crypto_node = get_node(json_payload, target, create=True)
32+
crypto_node[config.encrypted_value_field_name] = encode_bytes(encrypted_value, config.data_encoding)
33+
34+
if not _params:
35+
_populate_node_with_key_params(crypto_node, config, params)
36+
37+
except KeyError:
38+
raise EncryptionError("Field " + target + " not found!")
39+
40+
except KeyError:
41+
pass # data-to-encrypt node not found, nothing to encrypt
42+
43+
return json_payload
44+
45+
except (IOError, ValueError, TypeError) as e:
46+
raise EncryptionError("Payload encryption failed!", e)
47+
48+
49+
def decrypt_payload(payload, config, _params=None):
50+
"""Decrypt some fields of a JSON payload using the given configuration."""
51+
52+
try:
53+
if type(payload) is str:
54+
json_payload = json.loads(payload)
55+
else:
56+
json_payload = payload
57+
58+
for elem, target in config.paths["$"].to_decrypt.items():
59+
try:
60+
node = get_node(json_payload, elem)
61+
62+
cipher_text = decode_value(node.pop(config.encrypted_value_field_name), config.data_encoding)
63+
64+
if not _params:
65+
try:
66+
encrypted_key = node.pop(config.encrypted_key_field_name)
67+
iv = node.pop(config.iv_field_name)
68+
except KeyError:
69+
raise EncryptionError("Encryption field(s) missing in payload.")
70+
71+
oaep_digest_algo = node.pop(config.oaep_padding_digest_algorithm_field_name,
72+
config.oaep_padding_digest_algorithm)
73+
74+
_remove_fingerprint_from_node(node, config)
75+
76+
params = SessionKeyParams(config, encrypted_key, iv, oaep_digest_algo)
77+
else:
78+
params = _params
79+
80+
cleanup_node(json_payload, elem)
81+
82+
try:
83+
update_node(json_payload, target, _decrypt_bytes(params.key, params.iv_spec, cipher_text))
84+
except KeyError:
85+
raise EncryptionError("Field '" + target + "' not found!")
86+
87+
except KeyError:
88+
pass # encrypted data node not found, nothing to decrypt
89+
90+
return json_payload
91+
92+
except (IOError, ValueError, TypeError) as e:
93+
raise EncryptionError("Payload encryption failed!", e)
94+
95+
96+
def _encrypt_value(_key, iv, node_str):
97+
padded_node = pad(node_str.encode('utf-8'), AES.block_size)
98+
99+
aes = AES.new(_key, AES.MODE_CBC, iv)
100+
return aes.encrypt(padded_node)
101+
102+
103+
def _decrypt_bytes(_key, iv, _bytes):
104+
aes = AES.new(_key, AES.MODE_CBC, iv)
105+
plain_bytes = aes.decrypt(_bytes)
106+
107+
return unpad(plain_bytes, AES.block_size).decode('utf-8')
108+
109+
110+
def _populate_node_with_key_params(node, config, params):
111+
node[config.encrypted_key_field_name] = params.encrypted_key_value
112+
node[config.iv_field_name] = params.iv_value
113+
if config.oaep_padding_digest_algorithm_field_name:
114+
node[config.oaep_padding_digest_algorithm_field_name] = params.oaep_padding_digest_algorithm_value
115+
if config.encryption_certificate_fingerprint_field_name:
116+
node[config.encryption_certificate_fingerprint_field_name] = config.encryption_certificate_fingerprint
117+
if config.encryption_key_fingerprint_field_name:
118+
node[config.encryption_key_fingerprint_field_name] = config.encryption_key_fingerprint
119+
120+
121+
def _remove_fingerprint_from_node(node, config):
122+
if config.encryption_certificate_fingerprint_field_name in node:
123+
del node[config.encryption_certificate_fingerprint_field_name]
124+
if config.encryption_key_fingerprint_field_name in node:
125+
del node[config.encryption_key_fingerprint_field_name]
126+

0 commit comments

Comments
 (0)