Merge branch 'master' into fernet
diff --git a/cryptography/fernet.py b/cryptography/fernet.py
new file mode 100644
index 0000000..9f4294f
--- /dev/null
+++ b/cryptography/fernet.py
@@ -0,0 +1,129 @@
+# 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 six
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import padding, hashes, constant_time
+from cryptography.hazmat.primitives.hmac import HMAC
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+
+class InvalidToken(Exception):
+    pass
+
+
+_MAX_CLOCK_SKEW = 60
+
+
+class Fernet(object):
+    def __init__(self, key, backend=None):
+        if backend is None:
+            backend = default_backend()
+
+        key = base64.urlsafe_b64decode(key)
+        if len(key) != 32:
+            raise ValueError(
+                "Fernet key must be 32 url-safe base64-encoded bytes"
+            )
+
+        self._signing_key = key[:16]
+        self._encryption_key = key[16:]
+        self._backend = 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(), backend=self._backend)
+        h.update(basic_parts)
+        hmac = h.finalize()
+        return base64.urlsafe_b64encode(basic_parts + hmac)
+
+    def decrypt(self, token, ttl=None):
+        if isinstance(token, six.text_type):
+            raise TypeError(
+                "Unicode-objects must be encoded before decryption"
+            )
+
+        current_time = int(time.time())
+
+        try:
+            data = base64.urlsafe_b64decode(token)
+        except (TypeError, binascii.Error):
+            raise InvalidToken
+
+        if six.indexbytes(data, 0) != 0x80:
+            raise InvalidToken
+
+        try:
+            timestamp, = struct.unpack(">Q", data[1:9])
+        except struct.error:
+            raise InvalidToken
+        if ttl is not None:
+            if timestamp + ttl < current_time:
+                raise InvalidToken
+        if current_time + _MAX_CLOCK_SKEW < timestamp:
+            raise InvalidToken
+        h = HMAC(self._signing_key, hashes.SHA256(), backend=self._backend)
+        h.update(data[:-32])
+        hmac = h.finalize()
+        if not constant_time.bytes_eq(hmac, data[-32:]):
+            raise InvalidToken
+
+        iv = data[9:25]
+        ciphertext = data[25:-32]
+        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 cd975d5..1ec0422 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/cryptography-docs.py b/docs/cryptography-docs.py
index ea7e8ee..0252d69 100644
--- a/docs/cryptography-docs.py
+++ b/docs/cryptography-docs.py
@@ -6,17 +6,29 @@
 DANGER_MESSAGE = """
 This is a "Hazardous Materials" module. You should **ONLY** use it if you're
 100% absolutely sure that you know what you're doing because this module is
-full of land mines, dragons, and dinosaurs with laser guns. """
+full of land mines, dragons, and dinosaurs with laser guns.
+"""
+
+DANGER_ALTERNATE = """
+
+You may instead be interested in :doc:`{alternate}`.
+"""
 
 
 class HazmatDirective(Directive):
+    has_content = True
+
     def run(self):
+        message = DANGER_MESSAGE
+        if self.content:
+            message += DANGER_ALTERNATE.format(alternate=self.content[0])
+
         ad = make_admonition(
             Hazmat,
             self.name,
             [],
             self.options,
-            nodes.paragraph("", DANGER_MESSAGE),
+            nodes.paragraph("", message),
             self.lineno,
             self.content_offset,
             self.block_text,
diff --git a/docs/fernet.rst b/docs/fernet.rst
new file mode 100644
index 0000000..2fe2b86
--- /dev/null
+++ b/docs/fernet.rst
@@ -0,0 +1,76 @@
+Fernet (Symmetric encryption)
+=============================
+
+.. 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)
+        >>> token = f.encrypt(b"my deep dark secret")
+        >>> token
+        '...'
+        >>> f.decrypt(token)
+        '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, and
+        they'll also be able forge arbitrary messages which will be
+        authenticated and decrypted.
+
+    .. 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. This is
+                        refered to as a "Fernet token".
+
+        .. note::
+
+            The encrypted message contains the current time when it was
+            generated in *plaintext*, the time a message was created will
+            therefore be visible to a possible attacker.
+
+    .. method:: decrypt(token, ttl=None)
+
+        :param bytes token: The Fernet token. This is the result of calling
+                            :meth:`encrypt`.
+        :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 cryptography.fernet.InvalidToken: If the ``token`` is in any
+                                                  way invalid, this exception
+                                                  is raised. A token 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/hazmat/primitives/symmetric-encryption.rst b/docs/hazmat/primitives/symmetric-encryption.rst
index 30896a0..e05248f 100644
--- a/docs/hazmat/primitives/symmetric-encryption.rst
+++ b/docs/hazmat/primitives/symmetric-encryption.rst
@@ -1,4 +1,4 @@
-.. hazmat::
+.. hazmat:: /fernet
 
 
 Symmetric Encryption
diff --git a/docs/index.rst b/docs/index.rst
index 70558bd..7271117 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -54,6 +54,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..45188c4
--- /dev/null
+++ b/tests/test_fernet.py
@@ -0,0 +1,100 @@
+# 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
+from cryptography.hazmat.backends import default_backend
+
+
+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, backend):
+        f = Fernet(secret.encode("ascii"), backend=backend)
+        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, backend,
+                    monkeypatch):
+        f = Fernet(secret.encode("ascii"), backend=backend)
+        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, backend, monkeypatch):
+        f = Fernet(secret.encode("ascii"), backend=backend)
+        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_invalid_start_byte(self, backend):
+        f = Fernet(Fernet.generate_key(), backend=backend)
+        with pytest.raises(InvalidToken):
+            f.decrypt(base64.urlsafe_b64encode(b"\x81"))
+
+    def test_timestamp_too_short(self, backend):
+        f = Fernet(Fernet.generate_key(), backend=backend)
+        with pytest.raises(InvalidToken):
+            f.decrypt(base64.urlsafe_b64encode(b"\x80abc"))
+
+    def test_unicode(self, backend):
+        f = Fernet(base64.urlsafe_b64encode(b"\x00" * 32), backend=backend)
+        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, backend):
+        f = Fernet(Fernet.generate_key(), backend=backend)
+        assert f.decrypt(f.encrypt(message)) == message
+
+    def test_default_backend(self):
+        f = Fernet(Fernet.generate_key())
+        assert f._backend is default_backend()
+
+    def test_bad_key(self, backend):
+        with pytest.raises(ValueError):
+            Fernet(base64.urlsafe_b64encode(b"abc"), backend=backend)
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 6114311..1174a6f 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 --capture=no --strict
     coverage report -m