diff options
-rw-r--r-- | cryptography/fernet.py | 102 | ||||
-rw-r--r-- | dev-requirements.txt | 3 | ||||
-rw-r--r-- | docs/fernet.rst | 52 | ||||
-rw-r--r-- | docs/index.rst | 1 | ||||
-rw-r--r-- | tests/test_fernet.py | 70 | ||||
-rw-r--r-- | tests/vectors/fernet/generate.json | 9 | ||||
-rw-r--r-- | tests/vectors/fernet/invalid.json | 58 | ||||
-rw-r--r-- | tests/vectors/fernet/verify.json | 9 | ||||
-rw-r--r-- | tox.ini | 3 |
9 files changed, 305 insertions, 2 deletions
diff --git a/cryptography/fernet.py b/cryptography/fernet.py new file mode 100644 index 00000000..ef64b7e9 --- /dev/null +++ b/cryptography/fernet.py @@ -0,0 +1,102 @@ +import base64 +import os +import struct +import time + +import six + +from cryptography.hazmat.primitives import padding, hashes +from cryptography.hazmat.primitives.hmac import HMAC +from cryptography.hazmat.primitives.block import BlockCipher, ciphers, modes + + +class InvalidToken(Exception): + pass + + +class Fernet(object): + def __init__(self, key): + super(Fernet, self).__init__() + assert len(key) == 32 + self.signing_key = key[:16] + self.encryption_key = key[16:] + + def encrypt(self, data): + current_time = int(time.time()) + iv = os.urandom(16) + return self._encrypt_from_parts(data, current_time, iv) + + def _encrypt_from_parts(self, data, current_time, iv): + if isinstance(data, six.text_type): + raise TypeError( + "Unicode-objects must be encoded before encryption" + ) + + padder = padding.PKCS7(ciphers.AES.block_size).padder() + padded_data = padder.update(data) + padder.finalize() + encryptor = BlockCipher( + ciphers.AES(self.encryption_key), modes.CBC(iv) + ).encryptor() + ciphertext = encryptor.update(padded_data) + encryptor.finalize() + + h = HMAC(self.signing_key, digestmod=hashes.SHA256) + h.update(b"\x80") + h.update(struct.pack(">Q", current_time)) + h.update(iv) + h.update(ciphertext) + hmac = h.digest() + return base64.urlsafe_b64encode( + b"\x80" + struct.pack(">Q", current_time) + iv + ciphertext + hmac + ) + + def decrypt(self, data, ttl=None, current_time=None): + if isinstance(data, six.text_type): + raise TypeError( + "Unicode-objects must be encoded before decryption" + ) + + if current_time is None: + current_time = int(time.time()) + + try: + data = base64.urlsafe_b64decode(data) + except TypeError: + raise InvalidToken + + assert six.indexbytes(data, 0) == 0x80 + timestamp = data[1:9] + iv = data[9:25] + ciphertext = data[25:-32] + if ttl is not None: + if struct.unpack(">Q", timestamp)[0] + ttl < current_time: + raise InvalidToken + h = HMAC(self.signing_key, digestmod=hashes.SHA256) + h.update(data[:-32]) + hmac = h.digest() + + if not constant_time_compare(hmac, data[-32:]): + raise InvalidToken + + decryptor = BlockCipher( + ciphers.AES(self.encryption_key), modes.CBC(iv) + ).decryptor() + plaintext_padded = decryptor.update(ciphertext) + decryptor.finalize() + unpadder = padding.PKCS7(ciphers.AES.block_size).unpadder() + + unpadded = unpadder.update(plaintext_padded) + try: + unpadded += unpadder.finalize() + except ValueError: + raise InvalidToken + return unpadded + + +def constant_time_compare(a, b): + # TOOD: replace with a cffi function + assert isinstance(a, bytes) and isinstance(b, bytes) + if len(a) != len(b): + return False + result = 0 + for i in range(len(a)): + result |= six.indexbytes(a, i) ^ six.indexbytes(b, i) + return result == 0 diff --git a/dev-requirements.txt b/dev-requirements.txt index 752517dd..530ada91 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,7 @@ +coverage flake8 +iso8601 pretend pytest -coverage sphinx tox diff --git a/docs/fernet.rst b/docs/fernet.rst new file mode 100644 index 00000000..02b99705 --- /dev/null +++ b/docs/fernet.rst @@ -0,0 +1,52 @@ +Fernet +====== + +.. currentmodule:: cryptography.fernet + +.. testsetup:: + + import binascii + key = binascii.unhexlify(b"0" * 64) + + +`Fernet`_ is an implementation of symmetric (also known as "secret key") +authenticated cryptography. Fernet provides guarntees that a message encrypted +using it cannot be manipulated or read without the key. + +.. class:: Fernet(key) + + This class provides both encryption and decryption facilities. + + .. doctest:: + + >>> from cryptography.fernet import Fernet + >>> f = Fernet(key) + >>> ciphertext = f.encrypt(b"my deep dark secret") + >>> ciphertext + '...' + >>> f.decrypt(ciphertext) + 'my deep dark secret' + + :param bytes key: A 32-byte key. This **must** be kept secret. Anyone with + this key is able to create and read messages. + + + .. method:: encrypt(plaintext) + + :param bytes plaintext: The message you would like to encrypt. + :returns bytes: A secure message which cannot be read or altered + without the key. It is URL safe base64-encoded. + + .. method:: decrypt(ciphertext, ttl=None) + + :param bytes ciphertext: An encrypted message. + :param int ttl: Optionally, the number of seconds old a message may be + for it to be valid. If the message is older than + ``ttl`` seconds (from the time it was originally + created) an exception will be raised. If ``ttl`` is not + provided (or is ``None``), the age of the message is + not considered. + :returns bytes: The original plaintext. + + +.. _`Fernet`: https://github.com/fernet/spec/ diff --git a/docs/index.rst b/docs/index.rst index 4fd5d3be..b9c5b5fb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,7 @@ Contents .. toctree:: :maxdepth: 2 + fernet architecture contributing security diff --git a/tests/test_fernet.py b/tests/test_fernet.py new file mode 100644 index 00000000..a42011a6 --- /dev/null +++ b/tests/test_fernet.py @@ -0,0 +1,70 @@ +import base64 +import calendar +import json +import os + +import iso8601 + +import pytest + +import six + +from cryptography.fernet import Fernet, InvalidToken + + +def json_parametrize(keys, fname): + path = os.path.join(os.path.dirname(__file__), "vectors", "fernet", fname) + with open(path) as f: + data = json.load(f) + return pytest.mark.parametrize(keys, [ + tuple([entry[k] for k in keys]) + for entry in data + ]) + + +class TestFernet(object): + @json_parametrize( + ("secret", "now", "iv", "src", "token"), "generate.json", + ) + def test_generate(self, secret, now, iv, src, token): + f = Fernet(base64.urlsafe_b64decode(secret.encode("ascii"))) + actual_token = f._encrypt_from_parts( + src.encode("ascii"), + calendar.timegm(iso8601.parse_date(now).utctimetuple()), + b"".join(map(six.int2byte, iv)) + ) + assert actual_token == token + + @json_parametrize( + ("secret", "now", "src", "ttl_sec", "token"), "verify.json", + ) + def test_verify(self, secret, now, src, ttl_sec, token): + f = Fernet(base64.urlsafe_b64decode(secret.encode("ascii"))) + current_time = calendar.timegm(iso8601.parse_date(now).utctimetuple()) + payload = f.decrypt( + token.encode("ascii"), ttl=ttl_sec, current_time=current_time + ) + assert payload == src + + @json_parametrize(("secret", "token", "now", "ttl_sec"), "invalid.json") + def test_invalid(self, secret, token, now, ttl_sec): + f = Fernet(base64.urlsafe_b64decode(secret.encode("ascii"))) + current_time = calendar.timegm(iso8601.parse_date(now).utctimetuple()) + with pytest.raises(InvalidToken): + f.decrypt( + token.encode("ascii"), ttl=ttl_sec, current_time=current_time + ) + + def test_unicode(self): + f = Fernet(b"\x00" * 32) + with pytest.raises(TypeError): + f.encrypt(six.u("")) + with pytest.raises(TypeError): + f.decrypt(six.u("")) + + @pytest.mark.parametrize("message", [b"", b"Abc!", b"\x00\xFF\x00\x80"]) + def test_roundtrips(self, message): + f = Fernet(b"\x00" * 32) + ciphertext = f.encrypt(message) + plaintext = f.decrypt(ciphertext) + assert plaintext == message diff --git a/tests/vectors/fernet/generate.json b/tests/vectors/fernet/generate.json new file mode 100644 index 00000000..d1f3e083 --- /dev/null +++ b/tests/vectors/fernet/generate.json @@ -0,0 +1,9 @@ +[ + { + "token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==", + "now": "1985-10-26T01:20:00-07:00", + "iv": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "src": "hello", + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + } +] diff --git a/tests/vectors/fernet/invalid.json b/tests/vectors/fernet/invalid.json new file mode 100644 index 00000000..d80e7b4a --- /dev/null +++ b/tests/vectors/fernet/invalid.json @@ -0,0 +1,58 @@ +[ + { + "desc": "incorrect mac", + "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykQUFBQUFBQUFBQQ==", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + }, + { + "desc": "too short", + "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPA==", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + }, + { + "desc": "invalid base64", + "token": "%%%%%%%%%%%%%AECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + }, + { + "desc": "payload size not multiple of block size", + "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPOm73QeoCk9uGib28Xe5vz6oxq5nmxbx_v7mrfyudzUm", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + }, + { + "desc": "payload padding error", + "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0ODz4LEpdELGQAad7aNEHbf-JkLPIpuiYRLQ3RtXatOYREu2FWke6CnJNYIbkuKNqOhw==", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + }, + { + "desc": "far-future TS (unacceptable clock skew)", + "token": "gAAAAAAdwStRAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAnja1xKYyhd-Y6mSkTOyTGJmw2Xc2a6kBd-iX9b_qXQcw==", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + }, + { + "desc": "expired TTL", + "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==", + "now": "1985-10-26T01:21:31-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + }, + { + "desc": "incorrect IV (causes padding error)", + "token": "gAAAAAAdwJ6xBQECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAkLhFLHpGtDBRLRTZeUfWgHSv49TF2AUEZ1TIvcZjK1zQ==", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + } +] diff --git a/tests/vectors/fernet/verify.json b/tests/vectors/fernet/verify.json new file mode 100644 index 00000000..08c480f5 --- /dev/null +++ b/tests/vectors/fernet/verify.json @@ -0,0 +1,9 @@ +[ + { + "token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==", + "now": "1985-10-26T01:20:01-07:00", + "ttl_sec": 60, + "src": "hello", + "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" + } +] @@ -3,9 +3,10 @@ envlist = py26,py27,pypy,py32,py33,docs,pep8,py3pep8 [testenv] deps = - pytest coverage + iso8601 pretend + pytest commands = coverage run --source=cryptography/,tests/ -m pytest coverage report -m --fail-under 100 |