Add Multifernet.rotate method (#3979)

* add rotate method

* add some more tests for the failure modes

* start adding some documentation for the rotate method

* operate on a single token at a time, leave lists to the caller

* add versionadded
add versionadded, drop rotate from class doctest

* give rotate a doctest

* single level, not aligned

* add changelog for mf.rotate

* show that, once rotated, the old fernet instance can no longer decrypt the token

* add the instead of just the how

* update docs to reflect removal of ttl from rotate

* update tests

* refactor internal methods so that we can extract the timestamp

* implement rotate

* update wordlist (case sensitive?)

* lints

* consistent naming

* get_token_data/get_unverified_token_data -> better name

* doc changes

* use the static method, do not treat as imethod

* move up to MultiFernet docs

* add to authors

* alter wording

* monkeypatch time to make it less possible for the test to pass simply due to calls occuring in less than one second

* set the time after encryption to make sure that the time is preserved as part of re-encryption
diff --git a/tests/test_fernet.py b/tests/test_fernet.py
index dbce44f..6558d11 100644
--- a/tests/test_fernet.py
+++ b/tests/test_fernet.py
@@ -6,6 +6,7 @@
 
 import base64
 import calendar
+import datetime
 import json
 import os
 import time
@@ -156,3 +157,55 @@
     def test_non_iterable_argument(self, backend):
         with pytest.raises(TypeError):
             MultiFernet(None)
+
+    def test_rotate(self, backend):
+        f1 = Fernet(base64.urlsafe_b64encode(b"\x00" * 32), backend=backend)
+        f2 = Fernet(base64.urlsafe_b64encode(b"\x01" * 32), backend=backend)
+
+        mf1 = MultiFernet([f1])
+        mf2 = MultiFernet([f2, f1])
+
+        plaintext = b"abc"
+        mf1_ciphertext = mf1.encrypt(plaintext)
+
+        assert mf2.decrypt(mf1_ciphertext) == plaintext
+
+        rotated = mf2.rotate(mf1_ciphertext)
+
+        assert rotated != mf1_ciphertext
+        assert mf2.decrypt(rotated) == plaintext
+
+        with pytest.raises(InvalidToken):
+            mf1.decrypt(rotated)
+
+    def test_rotate_preserves_timestamp(self, backend, monkeypatch):
+        f1 = Fernet(base64.urlsafe_b64encode(b"\x00" * 32), backend=backend)
+        f2 = Fernet(base64.urlsafe_b64encode(b"\x01" * 32), backend=backend)
+
+        mf1 = MultiFernet([f1])
+        mf2 = MultiFernet([f2, f1])
+
+        plaintext = b"abc"
+        mf1_ciphertext = mf1.encrypt(plaintext)
+
+        later = datetime.datetime.now() + datetime.timedelta(minutes=5)
+        later_time = time.mktime(later.timetuple())
+        monkeypatch.setattr(time, "time", lambda: later_time)
+
+        original_time, _ = Fernet._get_unverified_token_data(mf1_ciphertext)
+        rotated_time, _ = Fernet._get_unverified_token_data(
+            mf2.rotate(mf1_ciphertext)
+        )
+
+        assert later_time != rotated_time
+        assert original_time == rotated_time
+
+    def test_rotate_decrypt_no_shared_keys(self, backend):
+        f1 = Fernet(base64.urlsafe_b64encode(b"\x00" * 32), backend=backend)
+        f2 = Fernet(base64.urlsafe_b64encode(b"\x01" * 32), backend=backend)
+
+        mf1 = MultiFernet([f1])
+        mf2 = MultiFernet([f2])
+
+        with pytest.raises(InvalidToken):
+            mf2.rotate(mf1.encrypt(b"abc"))