feat: keyed_fingerprint and key ids

keyed_fingerprint(data, purpose): HMAC-SHA256 under an HKDF-SHA256 subkey of
the DEK (info = envelope_crypto:keyed_fingerprint:<purpose>), for dedup on
secret values. dict input shares fingerprint_data's canonical json helper.

encrypt_data stamps a kid (first 16 hex of sha256(DEK)); decrypt_data raises
KeyMismatchError (ValueError subclass) naming both kids on a mismatch. blobs
without kid decrypt as before; decrypt_record keeps log-and-leave.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
dsql committed 2026-10-16 02:40:52 -04:00
1 parent e24da95fc5
commit 8cda08e7cf
3 files changed
+115 -8

No files matched your search

+41 -1
View File
@@ -76,7 +76,7 @@ round-trips two in-memory PEM strings rather than treating them as paths.
## Encrypt / decrypt
```python
enc = crypto.encrypt_data({"ssn": "..."}) # -> {"secure": True, "iv": ..., "data": ...}
enc = crypto.encrypt_data({"ssn": "..."}) # -> {"secure": True, "iv": ..., "data": ..., "kid": ...}
plain = crypto.decrypt_data(enc) # -> {"ssn": "..."}
```
@@ -110,6 +110,46 @@ Naming aliases (same objects): `EnvelopeCrypto` = `DocumentCrypto` = `RecordCryp
= `PCICrypto` (deprecated legacy alias). `decrypt_record` = `decrypt_document` =
`decrypt_dict`; `is_encrypted_record` = `is_encrypted_document` = `is_encrypted_dict`.
## Key ids
Every blob `encrypt_data` writes carries a `"kid"`: the first 16 hex chars of
`sha256(DEK)`. It identifies which data key sealed the blob without revealing it.
- `decrypt_data` on a blob whose `kid` doesn't match the loaded key raises
`KeyMismatchError` (a `ValueError` subclass, exported) naming both kids, e.g.
`blob was sealed under key id 3f9a..., but the loaded key is 81c0...` — instead of
the opaque `InvalidTag` you'd get from AES-GCM. Catch `KeyMismatchError` to tell
"sealed under another key" apart from tampering (`InvalidTag`).
- `decrypt_record` keeps its contract: a mismatched field is logged (the log line
names both kids) and left encrypted; it does not raise.
- `reencrypt` writes the destination key's `kid` automatically (it goes through
`encrypt_data`).
- Blobs without `kid` (written before v1.1.0) decrypt exactly as before. v1.0.0
readers ignore the extra field, so mixed versions reading the same store are safe.
## Keyed fingerprints
`fingerprint_data` is a plain SHA-256: fine for change detection on ordinary records,
but for a low-entropy secret (card number, email, phone) anyone with the digest can
brute-force the value back. Use `keyed_fingerprint` to dedup or look up records by a
secret value without storing it:
```python
fp = crypto.keyed_fingerprint(card_number, "card") # HMAC-SHA256 hex
await db.create_document("cards", {"card_fp": fp, "card": crypto.encrypt_data(card_number)})
```
The digest is an HMAC keyed by an HKDF subkey of the DEK, so it is only computable by a
system that holds the DEK. `purpose` is required and domain-separates digests: the same
email under `"email"` and `"login"` gives unrelated values, so a digest from one table
can't be matched against another. `data` may be `str` (UTF-8), `bytes`, or `dict` (the
same canonical JSON as `fingerprint_data`); `"abc"` and `b"abc"` give the same digest,
as do a dict and its canonical JSON string.
**A DEK rotation changes every keyed fingerprint.** After `rotate_master_key`, recompute
stored keyed fingerprints with the new instance (decrypt the field, re-fingerprint)
alongside `reencrypt`, or lookups against old digests will miss.
## Authorize another system
An initialized system wraps the DEK for another system's public key. Returns the