EC-5: _load_private_key branches on whether a password was given so the normalized ValueError matches the actual cryptography TypeError case (was always claiming "encrypted but no password" even when a password was given for an unencrypted key). EC-6: encrypt_aes_key_with_rsa/decrypt_aes_key_with_rsa now raise a clear ValueError via _require_rsa for a non-RSA key (e.g. Ed25519/EC), instead of crashing raw with AttributeError at wrap/unwrap — this lib is RSA-envelope only. EC-7: initialize() requires exactly 32 bytes (isinstance bytes, len==32), rejecting a 16/24-byte key (silent AES-128/192 downgrade) or a str instead of failing late and opaquely at first encrypt. EC-8: fingerprint_data gains a default= handler (datetime/date/time, bytes/bytearray, and a type-tagged repr fallback) plus a key-type-tagging pre-pass so datetime/bytes/ ObjectId-like values no longer TypeError and int-vs-str dict keys no longer collide to the same fingerprint. Never logs the data being fingerprinted. Also compresses the essay-length docstrings (module + several methods) to cut narration while keeping the load-bearing footgun notes (RSA-only, AES-256 key length, never-log-key-material) intact — zero behavior change, re-verified after. Signed-off-by: disqualifier <dev@disqualifier.me>
envelope_crypto
Envelope encryption for dict records. A random AES-256-GCM data key (DEK) encrypts the data; that key is wrapped (RSA-OAEP) per authorized system's public key (KEK) and stored by the caller, keyed by fingerprint. Each system unwraps its own copy with its private key. The same envelope pattern KMS-style systems use — config-free and storage-agnostic.
Install
requirements.txt:
envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git@v0.1.6
Direct:
pip install "envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git@v0.1.6"
Requires cryptography (pulled transitively).
Drop the @v0.1.6 suffix from the line above to install the latest unpinned.
First-time setup
Run once, ever, to create the data key and authorize the first system. You need an RSA keypair first:
# generate an RSA keypair for the first system (PEM)
openssl genrsa -out local_priv.pem 4096
openssl rsa -in local_priv.pem -pubout -out local_pub.pem
from envelope_crypto import EnvelopeCrypto
# generate the DEK and wrap it for this system in one call
crypto, fingerprint, wrapped = EnvelopeCrypto.bootstrap("public_key.pem")
# verify the keypair actually round-trips BEFORE storing anything
crypto.self_test("public_key.pem", "private_key.pem") # raises if keys don't pair
# store the wrapped key — this is now the ONLY record of the DEK
await db.create_document("keys", {"_id": fingerprint, "key": wrapped})
The plaintext DEK is never stored. It survives only as the RSA-wrapped copy, and is re-derived each boot by unwrapping. Never persist the plaintext key.
Boot (already set up)
crypto = EnvelopeCrypto()
fingerprint = crypto.get_rsa_key_fingerprint("public_key.pem")
record = await db.get_document("keys", {"_id": fingerprint})
if not record:
raise RuntimeError("this system is not authorized")
crypto.initialize(crypto.decrypt_aes_key_with_rsa(record["key"], "private_key.pem"))
bot.crypto = crypto
The keys schema (_id = fingerprint, key = wrapped) is the caller's choice;
this lib only produces (fingerprint, wrapped_key).
decrypt_aes_key_with_rsa (like encrypt_aes_key_with_rsa and
get_rsa_key_fingerprint) takes is_file (default True). Pass is_file=False to
hand it PEM/OpenSSH key data directly — e.g. a private key sourced from a vault —
instead of a file path. self_test threads the same is_file through to both the
public and private key it loads, so self_test(pub_pem, priv_pem, is_file=False)
round-trips two in-memory PEM strings rather than treating them as paths.
Encrypt / decrypt
enc = crypto.encrypt_data({"ssn": "..."}) # -> {"secure": True, "iv": ..., "data": ...}
plain = crypto.decrypt_data(enc) # -> {"ssn": "..."}
Dict keys must be str. encrypt_data raises TypeError on a non-str key (e.g. an
int-keyed dict of Discord snowflakes) instead of silently stringifying it — the
underlying JSON encoding has no other key type, so a coerced key would come back out
of decrypt_data as a str and no longer match the original lookup key.
For whole records: decrypt_record(crypto, doc) decrypts every {secure, iv, data}
field (nested up to traversal_level, default 2); is_encrypted_record(doc) reports
whether any encrypted field exists. Both also detect doc itself being a bare
{secure, iv, data} blob (the file-storage pattern below, where the blob IS the whole
document) — not just blobs nested under a key.
from envelope_crypto import is_encrypted_record, decrypt_record
if is_encrypted_record(doc):
doc = decrypt_record(crypto, doc)
is_encrypted_record falls back to an unbounded-depth scan once traversal_level is
exhausted, so it reliably reports True for a blob left behind by a shallower
decrypt_record/reencrypt call — safe to use as a leftover-detecting audit after
rotation, regardless of how deep the blob is nested.
Naming aliases (same objects): EnvelopeCrypto = DocumentCrypto = RecordCrypto
= PCICrypto (deprecated legacy alias). decrypt_record = decrypt_document =
decrypt_dict; is_encrypted_record = is_encrypted_document = is_encrypted_dict.
Authorize another system
An initialized system wraps the DEK for another system's public key. Returns the record to store.
fingerprint, wrapped = crypto.authorize_system(other_pub_path)
await db.create_document("keys", {"_id": fingerprint, "key": wrapped})
Only a system that already holds the DEK can authorize others.
Deauthorize
Delete that fingerprint's key record — the system can no longer unwrap at boot. This does not revoke a DEK already held in memory by a running system; rotate if a system is compromised.
Rotate (new key + re-encrypt)
Generate a new DEK, wrap for the still-authorized set, then re-encrypt existing data.
new_key, wrapped = crypto.rotate_master_key([pub_a, pub_b]) # omit a system to drop it
new_crypto = EnvelopeCrypto()
new_crypto.initialize(new_key)
# re-encrypt every record (caller owns the DB loop)
for doc in await db.get_documents("settings", {}):
fresh = new_crypto.reencrypt(crypto, doc) # decrypt(old) -> encrypt(new)
await db.update_document("settings", {"_id": doc["_id"]}, fresh)
# replace the key records
await db.delete_documents("keys", {})
for fingerprint, wrapped_key in wrapped.items():
await db.create_document("keys", {"_id": fingerprint, "key": wrapped_key})
reencrypt(source_crypto, record) is a method on the destination (new-key)
instance: it decrypts each encrypted field with source_crypto (old key) and
re-encrypts with itself. Only {secure, ...} fields are touched — including record
itself if it IS a {secure, iv, data} blob (the file-storage pattern).
Rotation must fail loud: a per-field decrypt failure raises, and so does a blob nested
deeper than traversal_level — silently leaving it under the old key would strand it
once the old key's wrapped-key record is deleted below. If you nest blobs deeper than
the default traversal_level=2, pass a higher traversal_level or flatten the record
first.
Storage patterns
The encrypted blob is just a dict — store it wherever:
- Mongo — store the dict directly (Mongo is dict-native).
- MariaDB / Postgres —
json.dumps(enc)into aJSON(orTEXT) column;json.loadson read, thendecrypt_data. - File —
json.dump(enc, f).
The lib never touches a database; only the caller's storage layer differs.
Notes
shutdown()drops the key reference but cannot guarantee zeroing it from RAM (Python immutable bytes).- A failed field decryption in
decrypt_recordis logged and left encrypted (the blob stays visible) rather than silently dropped. - The scheme is envelope/hybrid encryption (AES-256-GCM data key wrapped by RSA-OAEP). Using it does not by itself confer PCI-DSS or any other compliance — that is a whole-system property.
initialize(master_key)requires exactly 32 bytes (bytes,len == 32) — a 16/24-byte key or astrraisesValueErrorinstead of silently downgrading to AES-128/192 or failing late at first encrypt.- Wrap/unwrap (
encrypt_aes_key_with_rsa,decrypt_aes_key_with_rsa,authorize_system,bootstrap,rotate_master_key) is RSA-only. A non-RSA key (e.g. Ed25519/EC) loads and fingerprints fine but raises a clearValueErrorat wrap/unwrap rather than a rawAttributeError. fingerprint_dataserializes non-JSON-native values (datetime/date/time, bytes/bytearray, ObjectId-like objects) via a stabledefault=handler instead of raisingTypeError, and type-tags dict keys internally so{1: "a"}and{"1": "a"}no longer collide to the same fingerprint. Never logs the data it fingerprints.
Versioning
Releases are tagged vX.Y.Z. The install line above pins a release; drop the @vX.Y.Z suffix to install the latest unpinned. Pin deliberately for reproducible installs.