commit e57addd4f468feef3db5c536c0ceba1c85ed49b7 Author: Frédéric Péters Date: Sun Nov 4 15:39:05 2018 +0100 tarball import diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..9561fb1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.rst diff --git a/PKG-INFO b/PKG-INFO new file mode 100755 index 0000000..8a5e911 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,18 @@ +Metadata-Version: 1.1 +Name: http_ece +Version: 1.0.5 +Summary: Encrypted Content Encoding for HTTP +Home-page: https://github.com/martinthomson/encrypted-content-encoding +Author: Martin Thomson +Author-email: martin.thomson@gmail.com +License: MIT +Description-Content-Type: UNKNOWN +Description: Encipher HTTP Messages +Keywords: crypto http +Platform: UNKNOWN +Classifier: Development Status :: 4 - Beta +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 diff --git a/README.rst b/README.rst new file mode 100755 index 0000000..7923369 --- /dev/null +++ b/README.rst @@ -0,0 +1,28 @@ +encrypted-content-encoding +========================== + +A simple implementation of the `HTTP encrypted +content-encoding `_ + +Use +--- + +.. code-block:: python + + import http_ece + import os, base64 + + key = os.urandom(16) + salt = os.urandom(16) + data = os.urandom(100) + + encrypted = http_ece.encrypt(data, salt=salt, key=key) + decrypted = http_ece.decrypt(encrypted, salt=salt, key=key) + assert data == decrypted + +This also supports the static-ephemeral ECDH mode. + +TODO +---- + +Provide a streaming API diff --git a/http_ece.egg-info/PKG-INFO b/http_ece.egg-info/PKG-INFO new file mode 100755 index 0000000..5f71d86 --- /dev/null +++ b/http_ece.egg-info/PKG-INFO @@ -0,0 +1,18 @@ +Metadata-Version: 1.1 +Name: http-ece +Version: 1.0.5 +Summary: Encrypted Content Encoding for HTTP +Home-page: https://github.com/martinthomson/encrypted-content-encoding +Author: Martin Thomson +Author-email: martin.thomson@gmail.com +License: MIT +Description-Content-Type: UNKNOWN +Description: Encipher HTTP Messages +Keywords: crypto http +Platform: UNKNOWN +Classifier: Development Status :: 4 - Beta +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 diff --git a/http_ece.egg-info/SOURCES.txt b/http_ece.egg-info/SOURCES.txt new file mode 100755 index 0000000..67fd0ad --- /dev/null +++ b/http_ece.egg-info/SOURCES.txt @@ -0,0 +1,10 @@ +MANIFEST.in +README.rst +setup.cfg +setup.py +http_ece/__init__.py +http_ece.egg-info/PKG-INFO +http_ece.egg-info/SOURCES.txt +http_ece.egg-info/dependency_links.txt +http_ece.egg-info/requires.txt +http_ece.egg-info/top_level.txt \ No newline at end of file diff --git a/http_ece.egg-info/dependency_links.txt b/http_ece.egg-info/dependency_links.txt new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/http_ece.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/http_ece.egg-info/requires.txt b/http_ece.egg-info/requires.txt new file mode 100755 index 0000000..8884489 --- /dev/null +++ b/http_ece.egg-info/requires.txt @@ -0,0 +1 @@ +cryptography>=1.9 diff --git a/http_ece.egg-info/top_level.txt b/http_ece.egg-info/top_level.txt new file mode 100755 index 0000000..7411b2f --- /dev/null +++ b/http_ece.egg-info/top_level.txt @@ -0,0 +1 @@ +http_ece diff --git a/http_ece/__init__.py b/http_ece/__init__.py new file mode 100755 index 0000000..8f12a09 --- /dev/null +++ b/http_ece/__init__.py @@ -0,0 +1,393 @@ +import functools +import os +import struct + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.ciphers import ( + Cipher, algorithms, modes +) +from cryptography.hazmat.primitives.asymmetric import ec + +MAX_RECORD_SIZE = pow(2, 31) - 1 +MIN_RECORD_SIZE = 3 +KEY_LENGTH = 16 +NONCE_LENGTH = 12 +TAG_LENGTH = 16 + +# Valid content types (ordered from newest, to most obsolete) +versions = { + "aes128gcm": {"pad": 1}, + "aesgcm": {"pad": 2}, + "aesgcm128": {"pad": 1}, +} + +class ECEException(Exception): + """Exception for ECE encryption functions""" + def __init__(self, message): + self.message = message + +def derive_key(mode, version, salt, key, + private_key, dh, auth_secret, + keyid, keylabel="P-256"): + """Derive the encryption key + + :param mode: operational mode (encrypt or decrypt) + :type mode: enumerate('encrypt', 'decrypt) + :param salt: encryption salt value + :type salt: str + :param key: raw key + :type key: str + :param private_key: DH private key + :type key: object + :param dh: Diffie Helman public key value + :type dh: str + :param keyid: key identifier label + :type keyid: str + :param keylabel: label for aesgcm/aesgcm128 + :type keylabel: str + :param auth_secret: authorization secret + :type auth_secret: str + :param version: Content Type identifier + :type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128') + + """ + context = b"" + keyinfo = "" + nonceinfo = "" + + def build_info(base, info_context): + return b"Content-Encoding: " + base + b"\0" + info_context + + def derive_dh(mode, version, private_key, dh, keylabel): + def length_prefix(key): + return struct.pack("!H", len(key)) + key + + if isinstance(dh, ec.EllipticCurvePublicKey): + pubkey = dh + dh = dh.public_numbers().encode_point() + else: + numbers = ec.EllipticCurvePublicNumbers.from_encoded_point(ec.SECP256R1(), dh) + pubkey = numbers.public_key(default_backend()) + + encoded = private_key.public_key().public_numbers().encode_point() + if mode == "encrypt": + sender_pub_key = encoded + receiver_pub_key = dh + else: + sender_pub_key = dh + receiver_pub_key = encoded + + if version == "aes128gcm": + context = b"WebPush: info\x00" + receiver_pub_key + sender_pub_key + else: + context = (keylabel.encode('utf-8') + b"\0" + + length_prefix(receiver_pub_key) + + length_prefix(sender_pub_key)) + + return private_key.exchange(ec.ECDH(), pubkey), context + + if version not in versions: + raise ECEException(u"Invalid version") + if mode not in ['encrypt', 'decrypt']: + raise ECEException(u"unknown 'mode' specified: " + mode) + if salt is None or len(salt) != KEY_LENGTH: + raise ECEException(u"'salt' must be a 16 octet value") + if dh is not None: + if private_key is None: + raise ECEException(u"DH requires a private_key") + (secret, context) = derive_dh(mode=mode, version=version, + private_key=private_key, dh=dh, + keylabel=keylabel) + else: + secret = key + + if secret is None: + raise ECEException(u"unable to determine the secret") + + if version == "aesgcm": + keyinfo = build_info(b"aesgcm", context) + nonceinfo = build_info(b"nonce", context) + elif version == "aesgcm128": + keyinfo = b"Content-Encoding: aesgcm128" + nonceinfo = b"Content-Encoding: nonce" + elif version == "aes128gcm": + keyinfo = b"Content-Encoding: aes128gcm\x00" + nonceinfo = b"Content-Encoding: nonce\x00" + if dh is None: + # Only mix the authentication secret when using DH for aes128gcm + auth_secret = None + + if auth_secret is not None: + if version == "aes128gcm": + info = context + else: + info = build_info(b'auth', b'') + hkdf_auth = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=auth_secret, + info=info, + backend=default_backend() + ) + secret = hkdf_auth.derive(secret) + + hkdf_key = HKDF( + algorithm=hashes.SHA256(), + length=KEY_LENGTH, + salt=salt, + info=keyinfo, + backend=default_backend() + ) + hkdf_nonce = HKDF( + algorithm=hashes.SHA256(), + length=NONCE_LENGTH, + salt=salt, + info=nonceinfo, + backend=default_backend() + ) + return hkdf_key.derive(secret), hkdf_nonce.derive(secret) + + +def iv(base, counter): + """Generate an initialization vector. + + """ + if (counter >> 64) != 0: + raise ECEException(u"Counter too big") + (mask,) = struct.unpack("!Q", base[4:]) + return base[:4] + struct.pack("!Q", counter ^ mask) + + +def decrypt(content, salt=None, key=None, + private_key=None, dh=None, auth_secret=None, + keyid=None, keylabel="P-256", + rs=4096, version="aes128gcm"): + """ + Decrypt a data block + + :param content: Data to be decrypted + :type content: str + :param salt: Encryption salt + :type salt: str + :param key: local public key + :type key: str + :param private_key: DH private key + :type key: object + :param keyid: Internal key identifier for private key info + :type keyid: str + :param dh: Remote Diffie Hellman sequence (omit for aes128gcm) + :type dh: str + :param rs: Record size + :type rs: int + :param auth_secret: Authorization secret + :type auth_secret: str + :param version: ECE Method version + :type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128') + :return: Decrypted message content + :rtype str + + """ + def parse_content_header(content): + """Parse an aes128gcm content body and extract the header values. + + :param content: The encrypted body of the message + :type content: str + + """ + id_len = struct.unpack("!B", content[20:21])[0] + return { + "salt": content[:16], + "rs": struct.unpack("!L", content[16:20])[0], + "keyid": content[21:21 + id_len], + "content": content[21 + id_len:], + } + + def decrypt_record(key, nonce, counter, content): + decryptor = Cipher( + algorithms.AES(key), + modes.GCM(iv(nonce, counter), tag=content[-TAG_LENGTH:]), + backend=default_backend() + ).decryptor() + return decryptor.update(content[:-TAG_LENGTH]) + decryptor.finalize() + + def unpad_legacy(data): + pad_size = versions[version]['pad'] + pad = functools.reduce( + lambda x, y: x << 8 | y, struct.unpack( + "!" + ("B" * pad_size), data[0:pad_size]) + ) + if pad_size + pad > len(data) or \ + data[pad_size:pad_size+pad] != (b"\x00" * pad): + raise ECEException(u"Bad padding") + return data[pad_size + pad:] + + def unpad(data, last): + i = len(data) - 1 + for i in range(len(data) - 1, -1, -1): + v = struct.unpack('B', data[i:i+1])[0] + if v != 0: + if not last and v != 1: + raise ECEException(u'record delimiter != 1') + if last and v != 2: + raise ECEException(u'last record delimiter != 2') + return data[0:i] + raise ECEException(u'all zero record plaintext') + + if version not in versions: + raise ECEException(u"Invalid version") + + overhead = versions[version]['pad'] + if version == "aes128gcm": + try: + content_header = parse_content_header(content) + except: + raise ECEException("Could not parse the content header") + salt = content_header['salt'] + rs = content_header['rs'] + keyid = content_header['keyid'] + if private_key is not None and not dh: + dh = keyid + else: + keyid = keyid.decode('utf-8') + content = content_header['content'] + overhead += 16 + + (key_, nonce_) = derive_key("decrypt", version=version, + salt=salt, key=key, + private_key=private_key, dh=dh, + auth_secret=auth_secret, + keyid=keyid, keylabel=keylabel) + if rs <= overhead: + raise ECEException(u"Record size too small") + chunk = rs + if version != "aes128gcm": + chunk += 16 # account for tags in old versions + if len(content) % chunk == 0: + raise ECEException(u"Message truncated") + + result = b'' + counter = 0 + try: + for i in list(range(0, len(content), chunk)): + data = decrypt_record(key_, nonce_, counter, content[i:i + chunk]) + if version == 'aes128gcm': + last = (i + chunk) >= len(content) + result += unpad(data, last) + else: + result += unpad_legacy(data) + counter += 1 + except InvalidTag as ex: + raise ECEException("Decryption error: {}".format(repr(ex))) + return result + + +def encrypt(content, salt=None, key=None, + private_key=None, dh=None, auth_secret=None, + keyid=None, keylabel="P-256", + rs=4096, version="aes128gcm"): + """ + Encrypt a data block + + :param content: block of data to encrypt + :type content: str + :param salt: Encryption salt + :type salt: str + :param key: Encryption key data + :type key: str + :param private_key: DH private key + :type key: object + :param keyid: Internal key identifier for private key info + :type keyid: str + :param dh: Remote Diffie Hellman sequence + :type dh: str + :param rs: Record size + :type rs: int + :param auth_secret: Authorization secret + :type auth_secret: str + :param version: ECE Method version + :type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128') + :return: Encrypted message content + :rtype str + + """ + def encrypt_record(key, nonce, counter, buf, last): + encryptor = Cipher( + algorithms.AES(key), + modes.GCM(iv(nonce, counter)), + backend=default_backend() + ).encryptor() + + if version == 'aes128gcm': + data = encryptor.update(buf + (b'\x02' if last else b'\x01')) + else: + data = encryptor.update((b"\x00" * versions[version]['pad']) + buf) + data += encryptor.finalize() + data += encryptor.tag + return data + + def compose_aes128gcm(salt, content, rs, keyid): + """Compose the header and content of an aes128gcm encrypted + message body + + :param salt: The sender's salt value + :type salt: str + :param content: The encrypted body of the message + :type content: str + :param rs: Override for the content length + :type rs: int + :param keyid: The keyid to use for this message + :type keyid: str + + """ + if len(keyid) > 255: + raise ECEException("keyid is too long") + header = salt + if rs > MAX_RECORD_SIZE: + raise ECEException("Too much content") + header += struct.pack("!L", rs) + header += struct.pack("!B", len(keyid)) + header += keyid + return header + content + + if version not in versions: + raise ECEException(u"Invalid version") + + if salt is None: + salt = os.urandom(16) + + (key_, nonce_) = derive_key("encrypt", version=version, + salt=salt, key=key, + private_key=private_key, dh=dh, + auth_secret=auth_secret, + keyid=keyid, keylabel=keylabel) + + overhead = versions[version]['pad'] + if version == 'aes128gcm': + overhead += 16 + end = len(content) + else: + end = len(content) + 1 + if rs <= overhead: + raise ECEException(u"Record size too small") + chunk_size = rs - overhead + + result = b"" + counter = 0 + + # the extra one on the loop ensures that we produce a padding only + # record if the data length is an exact multiple of the chunk size + for i in list(range(0, end, chunk_size)): + result += encrypt_record(key_, nonce_, counter, + content[i:i + chunk_size], + (i + chunk_size) >= end) + counter += 1 + if version == "aes128gcm": + if keyid is None and private_key is not None: + kid = private_key.public_key().public_numbers().encode_point() + else: + kid = (keyid or '').encode('utf-8') + return compose_aes128gcm(salt, result, rs, keyid=kid) + return result diff --git a/setup.cfg b/setup.cfg new file mode 100755 index 0000000..31fd9bf --- /dev/null +++ b/setup.cfg @@ -0,0 +1,10 @@ +[bdist_wheel] +universal = 1 + +[nosetests] +config = .noserc + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..e343360 --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +#!/usr/bin/python +import io +import os + +from setuptools import setup + +here = os.path.abspath(os.path.dirname(__file__)) +with io.open(os.path.join(here, 'README.rst'), encoding='utf8') as f: + README = f.read() + +setup( + name='http_ece', + version='1.0.5', + author='Martin Thomson', + author_email='martin.thomson@gmail.com', + scripts=[], + packages=['http_ece'], + description='Encrypted Content Encoding for HTTP', + long_description='Encipher HTTP Messages', + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], + keywords='crypto http', + install_requires=[ + 'cryptography>=1.9', + ], + tests_require=[ + 'nose', + 'mock', + 'coverage', + 'flake8', + ], + test_suite="nose.collector", + url='https://github.com/martinthomson/encrypted-content-encoding', + license='MIT' +)