Add support for extracting timestamp from a Fernet token (#4229)

* Add API for retrieving the seconds-to-expiry for the token, given a TTL.

* Process PR feedback:

* Do compute the TTL, but just the age of the token. The caller
can decided what to do next.

* Factored out the HMAC signature verification to a separate function.

* Fixed a copy&paste mistake in the test cases

* Tests cleanup.

* `struct` no longer needed

* Document `def age()`

* typo in `age()` documentation

* token, not data

* remove test for TTL expiry that is already covered by the parameterized `test_invalid()`.

* let's call this extract_timestamp and just return timestamp

* review comments

* it's UNIX I know this
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 6ab6b04..4cabaf7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -8,6 +8,9 @@
 
 .. note:: This version is not yet released and is under active development.
 
+* Added :meth:`~cryptography.fernet.Fernet.extract_timestamp` to get the
+  authenticated timestamp of a :doc:`Fernet </fernet>` token.
+
 .. _v2-2-2:
 
 2.2.2 - 2018-03-27
diff --git a/docs/fernet.rst b/docs/fernet.rst
index a0ffe64..2d7d228 100644
--- a/docs/fernet.rst
+++ b/docs/fernet.rst
@@ -80,6 +80,22 @@
         :raises TypeError: This exception is raised if ``token`` is not
                            ``bytes``.
 
+    .. method:: extract_timestamp(token)
+
+        .. versionadded:: 2.3
+
+        Returns the timestamp for the token. The caller can then decide if
+        the token is about to expire and, for example, issue a new token.
+
+        :param bytes token: The Fernet token. This is the result of calling
+                            :meth:`encrypt`.
+        :returns int: The UNIX timestamp of the token.
+        :raises cryptography.fernet.InvalidToken: If the ``token``'s signature
+                                                  is invalid this exception
+                                                  is raised.
+        :raises TypeError: This exception is raised if ``token`` is not
+                           ``bytes``.
+
 
 .. class:: MultiFernet(fernets)
 
diff --git a/src/cryptography/fernet.py b/src/cryptography/fernet.py
index 1f33a12..ac2dd0b 100644
--- a/src/cryptography/fernet.py
+++ b/src/cryptography/fernet.py
@@ -74,6 +74,12 @@
         timestamp, data = Fernet._get_unverified_token_data(token)
         return self._decrypt_data(data, timestamp, ttl)
 
+    def extract_timestamp(self, token):
+        timestamp, data = Fernet._get_unverified_token_data(token)
+        # Verify the token was not tampered with.
+        self._verify_signature(data)
+        return timestamp
+
     @staticmethod
     def _get_unverified_token_data(token):
         if not isinstance(token, bytes):
@@ -93,6 +99,14 @@
             raise InvalidToken
         return timestamp, data
 
+    def _verify_signature(self, data):
+        h = HMAC(self._signing_key, hashes.SHA256(), backend=self._backend)
+        h.update(data[:-32])
+        try:
+            h.verify(data[-32:])
+        except InvalidSignature:
+            raise InvalidToken
+
     def _decrypt_data(self, data, timestamp, ttl):
         current_time = int(time.time())
         if ttl is not None:
@@ -102,12 +116,7 @@
             if current_time + _MAX_CLOCK_SKEW < timestamp:
                 raise InvalidToken
 
-        h = HMAC(self._signing_key, hashes.SHA256(), backend=self._backend)
-        h.update(data[:-32])
-        try:
-            h.verify(data[-32:])
-        except InvalidSignature:
-            raise InvalidToken
+        self._verify_signature(data)
 
         iv = data[9:25]
         ciphertext = data[25:-32]
diff --git a/tests/test_fernet.py b/tests/test_fernet.py
index 6558d11..75ecc35 100644
--- a/tests/test_fernet.py
+++ b/tests/test_fernet.py
@@ -122,6 +122,15 @@
         with pytest.raises(ValueError):
             Fernet(base64.urlsafe_b64encode(b"abc"), backend=backend)
 
+    def test_extract_timestamp(self, monkeypatch, backend):
+        f = Fernet(base64.urlsafe_b64encode(b"\x00" * 32), backend=backend)
+        current_time = 1526138327
+        monkeypatch.setattr(time, "time", lambda: current_time)
+        token = f.encrypt(b'encrypt me')
+        assert f.extract_timestamp(token) == current_time
+        with pytest.raises(InvalidToken):
+            f.extract_timestamp(b"nonsensetoken")
+
 
 @pytest.mark.requires_backend_interface(interface=CipherBackend)
 @pytest.mark.requires_backend_interface(interface=HMACBackend)