Merge branch 'master' into fernet
diff --git a/cryptography/fernet.py b/cryptography/fernet.py
new file mode 100644
index 0000000..668958b
--- /dev/null
+++ b/cryptography/fernet.py
@@ -0,0 +1,130 @@
+# 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.primitives import padding, hashes
+from cryptography.hazmat.primitives.hmac import HMAC
+from cryptography.hazmat.primitives.block import BlockCipher, ciphers, modes
+
+
+class InvalidToken(Exception):
+ pass
+
+
+ffi = cffi.FFI()
+ffi.cdef("""
+bool constant_time_compare(uint8_t *, size_t, uint8_t *, size_t);
+""")
+lib = ffi.verify("""
+#include <stdbool.h>
+
+bool constant_time_compare(uint8_t *a, size_t len_a, uint8_t *b,
+ size_t len_b) {
+ size_t i = 0;
+ int result = 0;
+ if (len_a != len_b) {
+ return false;
+ }
+ for (i = 0; i < len_a; i++) {
+ result |= a[i] ^ b[i];
+ }
+ return result == 0;
+}
+""")
+
+
+class Fernet(object):
+ def __init__(self, key, backend=None):
+ super(Fernet, self).__init__()
+ assert len(key) == 32
+ self.signing_key = key[:16]
+ self.encryption_key = key[16:]
+ self.backend = backend
+
+ 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), self.backend
+ ).encryptor()
+ ciphertext = encryptor.update(padded_data) + encryptor.finalize()
+
+ h = HMAC(self.signing_key, hashes.SHA256(), self.backend)
+ h.update(b"\x80")
+ h.update(struct.pack(">Q", current_time))
+ h.update(iv)
+ h.update(ciphertext)
+ hmac = h.finalize()
+ 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, 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
+ h = HMAC(self.signing_key, hashes.SHA256(), self.backend)
+ h.update(data[:-32])
+ hmac = h.finalize()
+
+ if not lib.constant_time_compare(hmac, len(hmac), data[-32:], 32):
+ raise InvalidToken
+
+ decryptor = BlockCipher(
+ ciphers.AES(self.encryption_key), modes.CBC(iv), self.backend
+ ).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
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 752517d..530ada9 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 0000000..02b9970
--- /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 1b88e24..b9bf173 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -30,6 +30,7 @@
.. toctree::
:maxdepth: 2
+ fernet
architecture
exceptions
glossary
diff --git a/tests/test_fernet.py b/tests/test_fernet.py
new file mode 100644
index 0000000..922f722
--- /dev/null
+++ b/tests/test_fernet.py
@@ -0,0 +1,81 @@
+# 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 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.encode("ascii")
+
+ @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.encode("ascii")
+
+ @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)
+ 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 0000000..d1f3e08
--- /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 0000000..d80e7b4
--- /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 0000000..08c480f
--- /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="
+ }
+]
diff --git a/tox.ini b/tox.ini
index dab22a6..b807b87 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,9 +3,10 @@
[testenv]
deps =
- pytest
coverage
+ iso8601
pretend
+ pytest
commands =
coverage run --source=cryptography/,tests/ -m pytest
coverage report -m