Envelope encryption with a two-layer key hierarchy: a KEK (Key Encryption Key) inside your KMS wraps a DEK (Data Encryption Key); the DEK actually encrypts your column data.
+--------------------+ wrap +--------------------+
| KEK in KMS | <---------------------- | Plaintext DEK |
| (aws/gcp/azure/ | | 32 random bytes |
| vault/local) | ----------------------> | cached per-req |
+--------------------+ unwrap +---------+----------+
|
encrypt / decrypt |
field column v
+--------------------+
| Ciphertext bytes |
| stored in the DB |
| row (ag1:v1:...) |
+--------------------+
Lifecycle of a field write
- Eloquent calls the
Encryptedcast'sset(). - Cast calls
KeyManager::getOrCreateDek($ctx). - Manager checks
DekCache(request-scoped). If miss:- Query
DataKey::forContext(type,id)->active()->first(). - If no row:
createDek()→ provider generates or wraps DEK → insert row. - If row exists:
unwrapInto()→ provider unwraps → cache plaintext.
- Query
- Cast asks
CipherRegistryfor the configured cipher. - Cipher encrypts plaintext with the DEK, using
$ctx->toCanonicalBytes()as AAD. - Eloquent writes the resulting envelope string to the column.
Lifecycle of a field read
- Eloquent calls the
Encryptedcast'sget(). - Same DEK resolution path (cache hit on subsequent reads in the same request).
- Cast extracts the 3-char cipher id from the envelope via
CipherRegistry::peekId(). - Cipher decrypts using the DEK and the same AAD. Mismatch =
DecryptionFailedException.
Request termination
SealcraftServiceProvider::registerTerminatingFlush() wires DekCache::flush() into app()->terminating(). Every plaintext DEK in memory is overwritten with null bytes and dropped before the process releases the request.
DEK cache bounds
The plaintext DEK cache is bounded by sealcraft.dek_cache.max_entries (default 1024). When the cap is exceeded the least-recently-used entry is overwritten with null bytes and dropped. This prevents long-running workers (Horizon, Octane) that touch many tenants over time from accumulating an unbounded number of plaintext DEKs. Set SEALCRAFT_DEK_CACHE_MAX_ENTRIES=0 to disable the cap if your workload is strictly per-request.
Per-group vs per-row
| Dimension | per_group | per_row |
|---|---|---|
| DEKs per tenant | 1 | N (one per row) |
| KMS calls per request (cold cache) | 1 per tenant you read | 1 per row you read |
| Blast radius of DEK compromise | one tenant | one row |
| Shred granularity | tenant | row |
| Good for | multi-tenant SaaS | vault / per-patient data |
Choose per_group by default. Reach for per_row when one DEK per row is a compliance requirement (e.g. HIPAA "destroy this specific patient's data without touching anyone else").
Why active-DEK uniqueness is enforced in app code, not via a DB unique index
See ADR-0001.
Why per-row requires explicit backfill
See ADR-0002.
Contributors
Thank you to everyone who has contributed to this package. Every pull request, bug report, and idea makes a difference.