Skip to content

encryption: implement DEK caching - #4

Merged
patrislav merged 12 commits into
masterfrom
dek-caching
Sep 24, 2026
Merged

patrislav merged 12 commits into
masterfrom
dek-caching

Conversation

@patrislav

@patrislav patrislav commented May 8, 2026 •

Copy link
Copy Markdown
Member

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

  • LRU + TTL eviction with configurable max size and TTL, backed by hashicorp/golang-lru/v2/expirable
  • Singleflight dedup on the Decrypt path (x/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 keyRef
  • Opt-in via WithCache() — no cache by default, existing callers unaffected. A TTL under one second logs a warning and runs uncached: expirable ticks eviction at TTL/100, which panics below 100ns and spins against the cache mutex below a millisecond
  • Encrypt path: still reads DynamoDB to resolve key index → key ref, but skips KMS on hit
  • Decrypt path: skips DynamoDB and KMS entirely on hit

Security

  • VerifyKey always runs on the Encrypt path, before the cache is consulted. The key row is an unverified DynamoDB read and its KeyRef selects 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_EncryptVerifiesKeyOnCacheHit covers it.
  • Decrypt may serve a hit without re-verifying: a cached DEK was verified when it was loaded, a miss goes through VerifyKey normally, and an attacker rewriting the key row cannot reach the cached value.
  • DEK ↔ keyRef is immutable: migrateKey re-splits the same private key under the same keyRef, and RotateKey keeps the shares. Cached DEKs cannot go stale.
  • RotateKey and CleanupUnusedKeys both evict, so deleted or retired key material is dropped from the cache.
  • Cached DEKs are not zeroed on eviction — the LRU drops its reference and the copy is collected. DEKs handed to callers are not zeroed either, unchanged from before this PR. get/put copy, so no caller ever shares memory with the cache.
  • A key row tampered with at the DB level is detected within one TTL rather than immediately. Documented on 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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+TTL dekCache, and singleflight-style miss deduplication.
  • Updates Pool construction to accept PoolOptions (notably WithCache) 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

  • Decrypt no longer annotates the trace span with the resolved key generation / key index (these annotations existed before the refactor). Since fetchDEK has the key object, consider re-adding these annotations (e.g., by setting them on the active span in ctx or 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.

Comment thread encryption/pool.go Outdated
Comment thread encryption/pool.go Outdated
Comment thread encryption/pool.go Outdated
Comment thread encryption/cache_test.go Outdated
@patrislav
patrislav requested a review from a team September 22, 2026 19:48
patrislav and others added 3 commits September 23, 2026 14:07
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>
patrislav and others added 5 commits September 23, 2026 15:54
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 marino39 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work overall. The core design holds up:

  • VerifyKey runs 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 migrateKey and RotateKey.
  • get/put copy 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 uses hashicorp/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/CleanupUnusedKeys just 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.go uses t.Fatal rather than testify. Style only.

CI: green.

Comment thread encryption/cache.go
Comment thread encryption/cache.go
Comment thread encryption/pool.go
Comment thread encryption/pool.go
Comment thread encryption/pool.go
Comment thread encryption/pool_test.go
patrislav and others added 4 commits September 24, 2026 13:40
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>
@patrislav
patrislav merged commit cfb7b31 into master Sep 24, 2026
5 checks passed
@patrislav
patrislav deleted the dek-caching branch September 24, 2026 12:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants