diff options
-rw-r--r-- | cryptography/fernet.py | 149 | ||||
-rw-r--r-- | dev-requirements.txt | 3 | ||||
-rw-r--r-- | docs/fernet.rst | 63 | ||||
-rw-r--r-- | docs/index.rst | 1 | ||||
-rw-r--r-- | tests/test_fernet.py | 80 | ||||
-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, 373 insertions, 2 deletions
diff --git a/cryptography/fernet.py b/cryptography/fernet.py new file mode 100644 index 00000000..6220e9c7 --- /dev/null +++ b/cryptography/fernet.py @@ -0,0 +1,149 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import binascii +import os +import struct +import time + +import cffi + +import six + +from cryptography.hazmat.bindings import default_backend +from cryptography.hazmat.primitives import padding, hashes +from cryptography.hazmat.primitives.hmac import HMAC +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +class InvalidToken(Exception): + pass + + +_ffi = cffi.FFI() +_ffi.cdef(""" +bool Cryptography_constant_time_compare(uint8_t *, size_t, uint8_t *, size_t); +""") +_lib = _ffi.verify(""" +#include <stdbool.h> + +bool Cryptography_constant_time_compare(uint8_t *a, size_t len_a, uint8_t *b, + size_t len_b) { + size_t i = 0; + uint8_t mismatch = 0; + if (len_a != len_b) { + return false; + } + for (i = 0; i < len_a; i++) { + mismatch |= a[i] ^ b[i]; + } + + /* Make sure any bits set are copied to the lowest bit */ + mismatch |= mismatch >> 4; + mismatch |= mismatch >> 2; + mismatch |= mismatch >> 1; + /* Now check the low bit to see if it's set */ + return (mismatch & 1) == 0; +} +""") + +_MAX_CLOCK_SKEW = 60 + + +class Fernet(object): + def __init__(self, key): + key = base64.urlsafe_b64decode(key) + assert len(key) == 32 + self.signing_key = key[:16] + self.encryption_key = key[16:] + self.backend = default_backend() + + @classmethod + def generate_key(cls): + return base64.urlsafe_b64encode(os.urandom(32)) + + 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(algorithms.AES.block_size).padder() + padded_data = padder.update(data) + padder.finalize() + encryptor = Cipher( + algorithms.AES(self.encryption_key), modes.CBC(iv), self.backend + ).encryptor() + ciphertext = encryptor.update(padded_data) + encryptor.finalize() + + basic_parts = ( + b"\x80" + struct.pack(">Q", current_time) + iv + ciphertext + ) + + h = HMAC(self.signing_key, hashes.SHA256(), self.backend) + h.update(basic_parts) + hmac = h.finalize() + return base64.urlsafe_b64encode(basic_parts + hmac) + + def decrypt(self, data, ttl=None): + if isinstance(data, six.text_type): + raise TypeError( + "Unicode-objects must be encoded before decryption" + ) + + current_time = int(time.time()) + + try: + data = base64.urlsafe_b64decode(data) + except (TypeError, binascii.Error): + 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 + if current_time + _MAX_CLOCK_SKEW < struct.unpack(">Q", timestamp)[0]: + raise InvalidToken + h = HMAC(self.signing_key, hashes.SHA256(), self.backend) + h.update(data[:-32]) + hmac = h.finalize() + valid = _lib.Cryptography_constant_time_compare( + hmac, len(hmac), data[-32:], 32 + ) + if not valid: + raise InvalidToken + + decryptor = Cipher( + algorithms.AES(self.encryption_key), modes.CBC(iv), self.backend + ).decryptor() + plaintext_padded = decryptor.update(ciphertext) + try: + plaintext_padded += decryptor.finalize() + except ValueError: + raise InvalidToken + unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() + + unpadded = unpadder.update(plaintext_padded) + try: + unpadded += unpadder.finalize() + except ValueError: + raise InvalidToken + return unpadded diff --git a/dev-requirements.txt b/dev-requirements.txt index cd975d5c..1ec04223 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,7 +1,8 @@ +coverage flake8 +iso8601 pretend pytest -coverage sphinx tox sphinx_rtd_theme diff --git a/docs/fernet.rst b/docs/fernet.rst new file mode 100644 index 00000000..241bf1ea --- /dev/null +++ b/docs/fernet.rst @@ -0,0 +1,63 @@ +Fernet +====== + +.. currentmodule:: cryptography.fernet + +`Fernet`_ is an implementation of symmetric (also known as "secret key") +authenticated cryptography. Fernet provides guarantees 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 + >>> key = Fernet.generate_key() + >>> f = Fernet(key) + >>> ciphertext = f.encrypt(b"my deep dark secret") + >>> ciphertext + '...' + >>> f.decrypt(ciphertext) + 'my deep dark secret' + + :param bytes key: A URL-safe base64-encoded 32-byte key. This **must** be + kept secret. Anyone with this key is able to create and + read messages. + + .. classmethod:: generate_key() + + Generates a fresh fernet key. Keep this some place safe! If you lose it + you'll no longer be able to decrypt messages; if anyone else gains + access to it, they'll be able to decrypt all of your 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. + :raises InvalidToken: If the ``ciphertext`` is in any way invalid, this + exception is raised. A ciphertext may be invalid + for a number of reasons: it is older than the + ``ttl``, it is malformed, or it does not have a + valid signature. + + +.. class:: InvalidToken + + See :meth:`Fernet.decrypt` for more information. + + +.. _`Fernet`: https://github.com/fernet/spec/ diff --git a/docs/index.rst b/docs/index.rst index 2263c32f..0b023276 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,7 @@ Contents .. toctree:: :maxdepth: 2 + fernet architecture exceptions glossary diff --git a/tests/test_fernet.py b/tests/test_fernet.py new file mode 100644 index 00000000..af64175e --- /dev/null +++ b/tests/test_fernet.py @@ -0,0 +1,80 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import calendar +import json +import os +import time + +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(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.encode("ascii") + + @json_parametrize( + ("secret", "now", "src", "ttl_sec", "token"), "verify.json", + ) + def test_verify(self, secret, now, src, ttl_sec, token, monkeypatch): + f = Fernet(secret.encode("ascii")) + current_time = calendar.timegm(iso8601.parse_date(now).utctimetuple()) + monkeypatch.setattr(time, "time", lambda: current_time) + payload = f.decrypt(token.encode("ascii"), ttl=ttl_sec) + assert payload == src.encode("ascii") + + @json_parametrize(("secret", "token", "now", "ttl_sec"), "invalid.json") + def test_invalid(self, secret, token, now, ttl_sec, monkeypatch): + f = Fernet(secret.encode("ascii")) + current_time = calendar.timegm(iso8601.parse_date(now).utctimetuple()) + monkeypatch.setattr(time, "time", lambda: current_time) + with pytest.raises(InvalidToken): + f.decrypt(token.encode("ascii"), ttl=ttl_sec) + + def test_unicode(self): + f = Fernet(base64.urlsafe_b64encode(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(Fernet.generate_key()) + assert f.decrypt(f.encrypt(message)) == 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 --strict coverage report -m |