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:
1 parent
e24da95fc5
commit
8cda08e7cf
3 files changed
+115
-8
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user