encryption: implement DEK caching - #4
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in, in-process decrypted DEK cache to the encryption.Pool to avoid repeated DynamoDB/KMS/Shamir work on repeated encrypt/decrypt operations for the same keyRef.
Changes:
- Introduces
CacheConfig, an in-memory LRU+TTLdekCache, and singleflight-style miss deduplication. - Updates
Poolconstruction to acceptPoolOptions (notablyWithCache) and threads cache usage through Encrypt/Decrypt/RotateKey. - Adds unit tests covering cache hit/miss behavior, cache population, invalidation, and singleflight behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| encryption/pool.go | Adds cache plumbing to Pool plus a new fetchDEK helper to centralize the miss path and deduplicate concurrent fetches. |
| encryption/pool_test.go | Adds integration-style tests proving cache behavior across Encrypt/Decrypt/RotateKey and concurrent decrypts. |
| encryption/cache.go | Implements the LRU+TTL DEK cache with key zeroing on eviction and singleflight coordination. |
| encryption/cache_test.go | Adds unit tests for cache correctness (copy semantics, TTL/LRU eviction, delete/clear, singleflight). |
Comments suppressed due to low confidence (1)
encryption/pool.go:235
Decryptno longer annotates the trace span with the resolved key generation / key index (these annotations existed before the refactor). SincefetchDEKhas thekeyobject, consider re-adding these annotations (e.g., by setting them on the active span inctxor returning key metadata) so cache misses still emit the same observability signals.
key, found, err := p.keysTable.GetLatestByKeyRef(ctx, keyRef, false)
if err != nil {
return nil, fmt.Errorf("get latest key: %w", err)
}
if !found {
return nil, fmt.Errorf("key not found")
}
if err := p.VerifyKey(ctx, att, key); err != nil {
return nil, fmt.Errorf("verify key: %w", err)
}
config, err := p.getConfig(key.Generation)
if err != nil {
return nil, fmt.Errorf("get config: %w", err)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Encrypt consulted the DEK cache before VerifyKey, so a hit skipped the attestation check on a row read from DynamoDB — letting a tampered row choose which cached DEK encrypts the data. Verify first, then cache. Also: drop the unzeroed DEK copy the singleflight kept, make waiters respect context cancellation, evict deleted keys in CleanupUnusedKeys, and restore the generation/key_index span annotations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records hit/miss/coalesced on the Encrypt or Decrypt span, so a slow operation shows whether it paid for a KMS round-trip. Annotated on the caller's span rather than loadDEK's child, which only exists on a miss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
53f12ea to
968420f
Compare
Replaces the hand-rolled inflight map with singleflight.Group. DoChan keeps the context cancellation, and Result.Shared is the coalesced annotation, so fetchDEK loses the wait-then-re-read-then-load-anyway path. copyBytes was slices.Clone. x/sync was already in the module graph; this only promotes it to direct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the hand-rolled LRU, TTL and eviction bookkeeping with hashicorp/golang-lru's expirable.LRU, which waas already has in its module graph at the same version. Evicted keys are no longer zeroed. The LRU releases its lock before the caller finishes copying a value and its expiry sweeper runs on its own goroutine, so clearing an evicted slice can hand out a half-cleared key and fail a decryption. The enclave already keeps unscrubbed copies from Shamir recombination and the AES key schedule, and its memory is neither swappable nor readable from the parent instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caching it suppressed the retry until the entry expired; previously every decrypt re-attempted. Also corrects Decrypt's doc comment, which still claimed verification and migration happen on every call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
marino39
left a comment
There was a problem hiding this comment.
Nice work overall. The core design holds up:
VerifyKeyruns on Encrypt before the cache is consulted.- A Decrypt hit skips DynamoDB and KMS.
- Keys that fail verification or migration are never cached.
- TTL or size 0 disables the cache.
- The key ref ↔ DEK immutability argument checks out against
migrateKeyandRotateKey. get/putcopy the key bytes, so callers never share memory with the cache.
One blocker: the description says cache-internal key material is zeroed on every eviction path, but nothing zeroes it (inline on cache.go:26). Either add zeroing or drop the claim.
The description has also drifted from the code in two other places:
- It says
sync.Mutex + container/list; the implementation useshashicorp/golang-lru/v2/expirable(since 52c29ce). - It says waiters "read the winner's result from the cache"; they actually get a copy of the singleflight result (
res.Val), which lives outside the cache's eviction handling.
Worth updating both so the description matches what ships.
Considered and not raised:
- A load in flight can re-cache a key that
RotateKey/CleanupUnusedKeysjust evicted. This comes with caching, and impact is negligible because Cleanup only deletes keys nothing references. - A TTL of 1–99ns makes
expirable's ticker panic (ttl/100 == 0). That's not a realistic config. cache_test.gousest.Fatalrather than testify. Style only.
CI: green.
expirable.LRU starts a cleanup goroutine per instance and v2.0.7 has no way to stop it, so a Pool built WithCache holds it for the process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The concurrent decrypts could serialize, in which case later callers hit a warm cache and the test passed without any coalescing. The reverse order failed it: a caller that missed the cache before the first load's put could start a second load. Blocking the share decrypt until every caller is inside Decrypt keeps the cache empty for the whole window, so one load is the only possible outcome. Verified by failing with 9 loads when the singleflight key is made unique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches pool_test.go in the same package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
expirable.LRU runs its eviction ticker at TTL/100. Under 100ns that panics outright; between 100ns and a millisecond it silently spins the cleanup loop against the mutex every get and put takes, which is the worse of the two. A bare integer literal is a legal time.Duration, so `TTL: 600` compiles, reads as ten minutes, and lands in that range. Warn and run uncached instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds an optional in-memory LRU cache for decrypted data encryption keys (DEKs), eliminating KMS round-trips on cache hits. Each decrypt/encrypt currently recovers the DEK via a DynamoDB read plus one KMS call per Shamir share; the cache reduces that to zero on hit.
Design
hashicorp/golang-lru/v2/expirablex/sync/singleflight) so concurrent misses on the same keyRef don't stampede KMS. Waiters each get their own copy of the winner's result, and they respect context cancellation, so a slow or hung loader can't pin them. Encrypt's miss path is not deduplicated: it picks a random key index per call, so misses spread across the pool rather than converging on one keyRefWithCache()— no cache by default, existing callers unaffected. A TTL under one second logs a warning and runs uncached:expirableticks eviction at TTL/100, which panics below 100ns and spins against the cache mutex below a millisecondSecurity
VerifyKeyalways runs on the Encrypt path, before the cache is consulted. The key row is an unverified DynamoDB read and itsKeyRefselects which DEK encrypts the data, so a cache hit must not be able to skip the attestation check. Verification is local (COSE + cert chain), so the KMS round-trips are still what the cache saves.TestPool_EncryptVerifiesKeyOnCacheHitcovers it.VerifyKeynormally, and an attacker rewriting the key row cannot reach the cached value.migrateKeyre-splits the same private key under the same keyRef, andRotateKeykeeps the shares. Cached DEKs cannot go stale.RotateKeyandCleanupUnusedKeysboth evict, so deleted or retired key material is dropped from the cache.get/putcopy, so no caller ever shares memory with the cache.WithCache.Migration
A generation change requires a restart, so a key needing migration is migrated on the first load after startup. Migration is attempted on every cache miss and remains non-fatal; a failed migration is retried on the next miss, i.e. after the entry's TTL.
Not included
No cache hit/miss metrics yet — see the discussion on instrumenting this from the consumer side.
🤖 Generated with Claude Code