encryption: memoize KEK unwraps across startup (Stage 9C-3) - #1226
Conversation
Startup unwraps every wrapped DEK twice: once in the §9.1 guards to prove the configured KEK matches the sidecar, and again in HydrateKeystoreFromSidecar to populate the keystore. The 6D-6c-2 design deferred fixing that explicitly, on the grounds that the file KEK made the second pass a free local AES operation — and noted the fix would be needed "once Stage 9 lands KMS providers". Stage 9B landed them. A node with N wrapped DEKs has been making 2N network round-trips to boot ever since, so the deferral's trigger had already fired. StartupUnwrapCache memoizes wrapped -> DEK. The mapping is deterministic, so caching cannot change a result; it only removes the second call. Failures are deliberately not cached — a transient KMS timeout must not become a permanent startup refusal — and Wrap is not memoized, since providers may add fresh randomness per call. The constructor returns the kek.Wrapper INTERFACE rather than the concrete type. Returning a typed nil pointer would give a non-nil interface holding nil, and startup decides whether encryption mutators may run from `kekWrapper != nil`; a node with no KEK would have reported one. The test for this routes through an interface conversion because both require.Nil and a concrete-typed `== nil` accept a typed nil and pass with the bug present. The cache sits outside the §9.2 latency decorator so a cache hit is not recorded as a zero-duration KMS call. Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@codex review |
|
@claude review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude encountered an error after 3m 7s —— View job Claude finished @bootjp's review request
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7d5361946
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // The cache sits OUTSIDE the timer on purpose: a cache hit must | ||
| // not be recorded as a zero-duration KMS call, which would flatten | ||
| // elastickv_encryption_kek_unwrap_seconds. | ||
| kekWrapper = encryption.NewStartupUnwrapCache(kekWrapper) |
There was a problem hiding this comment.
Stop caching unwraps after startup
When an encryption-enabled process reaches normal serving, this wrapper is retained by every applier, but production never calls Reset; moreover, Reset only clears the map and does not disable future caching. Consequently the sidecar DEKs and random KEK-preflight DEK remain as extra plaintext copies for the process lifetime, and each runtime rotation adds another cached DEK, contrary to the startup-only contract. End memoization after both startup hydration passes and bypass the cache thereafter before handing the wrapper to runtime code.
Useful? React with 👍 / 👎.

How this was found
Auditing
*_implemented_*design docs for deferred follow-ups — my earlier inventory only counted docs by filename marker, which hides work that a shipped doc explicitly parked.2026_05_25_implemented_6d6c2_production_storage_envelope_wiring.md§5 says:Stage 9B landed those providers (AWS KMS, GCP KMS, Vault Transit). The condition the deferral was waiting on has already arrived, so a node with N wrapped DEKs has been making 2N network round-trips to boot ever since.
What
encryption.StartupUnwrapCachememoizeswrapped → DEKacross the startup phase, collapsing the §9.1 guard pass andHydrateKeystoreFromSidecarback to one provider call per DEK. Wired at the single KEK load site, so both hydration call sites benefit with no plumbing change.Decisions worth reviewing
The constructor returns the
kek.Wrapperinterface, not*StartupUnwrapCache. Returning a typed nil pointer produces a non-nil interface holding nil — and startup decides whether encryption mutators may run fromkekWrapper != nil. A node with no KEK configured would have reported one. I hit this while wiring it and caught it before pushing.That test needed three attempts to be real.
require.Nilis reflection-based and accepts a typed nil; so doesgot == nilwhengotis the concrete pointer type. Both passed with the bug present. The property only shows through an interface conversion, so the test routes the value through akek.Wrapper-typed parameter — and now fails when the return type is reverted.Failures are not cached. A transient KMS timeout must not become a permanent startup refusal.
Wrapis not memoized. Providers may add fresh randomness per call, so a cache there would be wrong rather than merely wasteful.The cache sits OUTSIDE the §9.2 latency decorator (
cache(timed(raw))), so a cache hit is not recorded as a zero-duration KMS round-trip and cannot flattenelastickv_encryption_kek_unwrap_seconds.Resetzeroes and drops the entries. The keystore already holds every unretired DEK for the process lifetime, so this adds no new class of exposure — but there is no reason to keep a second copy of the plaintext alive past hydration.Behavior change / risk
Startup makes fewer KMS calls; nothing else changes.
wrapped → DEKis deterministic, so memoizing cannot alter a result. Errors are wrapped witherrors.Wrapf, which preservesIs/As, so the §9.1 guards still matchErrKEKMismatchthrough the decorator — covered by the existing guard tests, which pass unchanged.Test evidence
go test . ./internal/encryption/... -race -count=1— all passgolangci-lint run(full repo) — 0 issues; no//nolintin the new source (the twowrapcheckhits were resolved by wrapping properly, per the repo convention)TestStartupUnwrapCacheReturnsANilInterfaceWithoutAKEKFAILsTestStartupUnwrapCacheCollapsesTheDuplicateStartupUnwrapFAILsTestStartupUnwrapCacheDoesNotCacheFailuresFAILs6 tests: duplicate-unwrap collapse, typed-nil, failure non-caching, caller-isolation (a caller zeroing its DEK must not corrupt the cache),
Reset, and Wrap/Name delegation.Self-review (five passes)
wrapped → DEKis deterministic and stable, so the cache is semantically transparent. Errors preserveIs/Asfor the startup guards.https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE