Encryption¶
arvel's Crypt encrypts arbitrary values with AES-256-GCM, an authenticated cipher (AEAD): a
tampered ciphertext is rejected outright, never silently decrypted into different-but-plausible
data. Values are JSON-serialized before encryption (not pickled) — pickle's own history of
key-leak-to-RCE is exactly what arvel's encryption is designed to avoid.
Basic usage¶
from arvel.support.facades import Crypt
token = Crypt.encrypt({"user_id": 1, "roles": ["admin"]})
Crypt.decrypt(token) # {"user_id": 1, "roles": ["admin"]} — the equal object back
token = Crypt.encrypt_string("a plain string")
Crypt.decrypt_string(token) # "a plain string" — no JSON envelope
Or construct an Encrypter directly:
from arvel.security import Encrypter
enc = Encrypter(app_key)
enc.encrypt({"a": 1})
enc.encrypt_string("x")
Use encrypt/decrypt for any JSON-serializable value (dict, list, str, int, bool, None);
use encrypt_string/decrypt_string when you know the value is already a string and don't want
the JSON envelope (e.g. behind an encrypted model cast, or when the payload is already serialized).
JSON-serializable values only
encrypt/decrypt go through json.dumps/json.loads. A value that isn't JSON-serializable
(e.g. a datetime, a custom class) raises before it ever reaches the cipher — serialize it to
a plain dict/string yourself first.
Generating a key¶
from arvel.security import Encrypter
Encrypter.generate_key() # "base64:<32 random bytes, base64-encoded>"
A key is 32 raw bytes (AES-256). It's accepted either as a base64:<b64>-prefixed
string, or as bare base64 (standard or urlsafe alphabet) — whichever decodes to exactly 32 bytes.
Tamper detection¶
A single flipped byte anywhere in the payload — nonce or ciphertext — is rejected:
from arvel.security import DecryptionFailed, Encrypter
enc = Encrypter(Encrypter.generate_key())
token = enc.encrypt_string("secret")
tampered = token[:-1] + ("A" if not token.endswith("A") else "B")
try:
enc.decrypt_string(tampered)
except DecryptionFailed:
print("rejected — never a silently-wrong plaintext")
DecryptionFailed is also raised for a malformed payload (wrong number of .-separated parts,
invalid base64, an unsupported version prefix) — any input a caller doesn't control gets the same
treatment, never a different exception type leaking why it failed.
Key rotation¶
Pass one or more previous_keys so ciphertext encrypted under an old key still decrypts, while
new data is always encrypted under the (first) primary key:
from arvel.security import Encrypter
old_key = Encrypter.generate_key()
new_key = Encrypter.generate_key()
old_token = Encrypter(old_key).encrypt_string("secret")
enc = Encrypter(new_key, old_key) # new primary, old kept for decrypt
enc.decrypt_string(old_token) # "secret" — still readable
# Re-encrypt an old token under the new primary key, once, e.g. in a migration:
migrated = enc.rotate(old_token)
Encrypter(new_key).decrypt_string(migrated) # "secret" — now the old key isn't needed at all
Payload format¶
Every ciphertext is v1.<base64 nonce>.<base64 ciphertext+tag> — a version tag (so the format
can evolve), a random 96-bit nonce, and the AES-256-GCM output (ciphertext with the authentication
tag appended, as cryptography's AESGCM already does). The nonce is fresh per call to
encrypt/encrypt_string, so encrypting the same value twice produces different tokens.
High-volume nonce budget
Each encryption draws a random 96-bit GCM nonce. Per NIST SP 800-38D, keep a single key under
~2³² encryptions to make a nonce collision negligible — rotate APP_KEY (the previous_keys
mechanism keeps old ciphertext readable) well before that.
Common mistakes & gotchas¶
- Encrypting a password. Passwords are hashed, never encrypted — encryption is reversible, and
a password should never be recoverable. Use
Hash.make— see Hashing. - Losing
APP_KEY. The key is the data — lose it (and itsprevious_keys) and every ciphertext is gone for good. Back it up like any other secret; never commit it. - Encrypting a non-JSON value.
encryptgoes throughjson.dumps; adatetimeor custom object raises before the cipher. Serialize to a plain dict/string first (or useencrypt_string). - Rotating a key too early. When you rotate, keep the old key in
previous_keysuntil you've re-encrypted (rotate) every ciphertext under the new primary — drop it sooner and old data becomes unreadable. - Treating
DecryptionFailedas corruption. It's also what a tampered or malformed token raises — by design, one exception type, so an attacker learns nothing from which error came back.
How it works¶
Encrypter wraps cryptography.hazmat.primitives.ciphers.aead.AESGCM directly (no Fernet, no
pickle). encrypt/decrypt add a {"j": value} JSON envelope around the plaintext before
sealing it; encrypt_string/decrypt_string seal/open raw bytes. Decryption tries each held key
in order (primary first) and only raises DecryptionFailed once every key's AEAD tag check has
failed — so previous_keys and rotate cost nothing on the common (primary-key) path.
Signing (tamper-evident, not secret)¶
When you don't need to hide a value but do need to know it wasn't tampered with — a value you
hand to a client and expect back unchanged — sign it instead of encrypting it. Signer appends a
keyed MAC (via itsdangerous); the value stays readable, but any edit invalidates the signature:
from arvel.security import Signer, SignatureInvalid, SignatureExpired
signer = Signer(config("app.key"))
token = signer.sign({"order_id": 42}) # readable, but tamper-evident
try:
data = signer.unsign(token, max_age=3600) # optional TTL (seconds)
except SignatureExpired:
... # valid signature, but older than max_age — "link expired, request a new one"
except SignatureInvalid:
... # forged / corrupted / wrong key
SignatureExpired is a subclass of SignatureInvalid, so a broad except SignatureInvalid still
catches an expired token — but the distinct type lets you tell "expired" (re-issue) from "tampered"
(reject) when you care. For signing URLs specifically, use the higher-level helpers in
URLs → Signed URLs, which build on this. Sign when the value is public but
must be trusted; encrypt (above) when it must also be hidden.
See also¶
- Hashing — for passwords; never
encrypta password,Hash.makeit. - Configuration — where
APP_KEYis read from. - URLs — signed URLs, built on
Signer.