cryptography MultiFernet key ring with rotation: working versions (tested 2026-10-15)
Wrap cryptography's MultiFernet in a small key ring that encrypts with the newest key, still decrypts tokens from older keys, re-encrypts them with rotate(), and rejects tokens once their key is dropped, all tested offline.
passed6 passed · 0 failed2026-10-15 18:59 UTC- Status
- passed
- Last passed
- 2026-10-15 18:59 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38790162748
The code
Exactly the files that passed. Your agent can pull them with get_recipe("cryptography-fernet-multi-key") (install), or see them on GitHub.
cryptography==50.0.2 pytest==9.1.1
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
def new_key() -> str:
return Fernet.generate_key().decode()
class KeyRing:
def __init__(self, keys: list[str]):
self.keys = list(keys)
self._mf = MultiFernet([Fernet(k.encode()) for k in self.keys])
@classmethod
def generate(cls) -> "KeyRing":
return cls([new_key()])
def add(self, key: str) -> None:
self.keys.insert(0, key)
self._mf = MultiFernet([Fernet(k.encode()) for k in self.keys])
def drop(self, key: str) -> None:
self.keys.remove(key)
self._mf = MultiFernet([Fernet(k.encode()) for k in self.keys])
def encrypt(self, data: str) -> str:
return self._mf.encrypt(data.encode()).decode()
def decrypt(self, token: str) -> str:
return self._mf.decrypt(token.encode()).decode()
def rotate(self, token: str) -> str:
return self._mf.rotate(token.encode()).decode()
import pytest
from cryptography.fernet import InvalidToken
from rotation import KeyRing, new_key
def test_roundtrip():
ring = KeyRing.generate()
assert ring.decrypt(ring.encrypt("secret")) == "secret"
def test_encrypt_is_nondeterministic():
ring = KeyRing.generate()
a, b = ring.encrypt("x"), ring.encrypt("x")
assert a != b
assert ring.decrypt(a) == ring.decrypt(b) == "x"
def test_old_key_still_decrypts_during_rotation():
old = KeyRing.generate()
token = old.encrypt("payload")
ring = KeyRing([new_key(), *old.keys])
assert ring.decrypt(token) == "payload"
def test_rotate_moves_token_to_primary_key():
old = KeyRing.generate()
token = old.encrypt("payload")
ring = KeyRing([new_key(), *old.keys])
rotated = ring.rotate(token)
assert KeyRing([ring.keys[0]]).decrypt(rotated) == "payload"
def test_dropping_key_invalidates_old_tokens():
old = KeyRing.generate()
token = old.encrypt("payload")
ring = KeyRing([new_key(), *old.keys])
ring.drop(old.keys[0])
with pytest.raises(InvalidToken):
ring.decrypt(token)
def test_tampered_token_rejected():
ring = KeyRing.generate()
token = ring.encrypt("payload")
bad = token[:-2] + ("AA" if token[-2:] != "AA" else "BB")
with pytest.raises(InvalidToken):
ring.decrypt(bad)
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
passlib bcrypt hashing with a cryptography HMAC pepper
Hash and verify passwords with passlib's CryptContext bcrypt scheme, prefixing each password with a cryptography HMAC-SHA256 pepper so bcrypt's 72 byte limit cannot silently truncate, and cover identify, unknown hashes and work factor rehash detection offline.
RS256 JWTs with PyJWT and cryptography-generated keys
Generate an RSA keypair with cryptography, sign RS256 JWTs with PyJWT, and verify them with a public key while enforcing algorithm, audience, expiry and signature checks, all tested offline.