DEK in-memory safety and caching lifetime (REVISIT) ==================================================== Status: open question, parked. Nothing to do now, but the current code has a known soft spot (below) and there's promising runtime work to revisit. See also: secret-encryption.txt (the envelope-encryption design this builds on). The problem ----------- To decrypt a secret you must unwrap its DEK to plaintext and hold those key bytes in memory while AES-GCM runs. The KEK (for the KeyFile dev impl) is in memory for the whole process. So plaintext key material is unavoidably in RAM transiently. The only real question is: for how long? Caching lifetime spectrum ------------------------- No cache — DEK plaintext exists only during a single Seal/Open. Per-operation — lives for one request/operation. TTL + max-uses — lives minutes / N uses, across many requests. Permanent — forever. The security delta between "no cache" and "per-operation" is small: within one operation the DEK is being actively used anyway. The real exposure jump is the TTL / permanent end, where keys persist across idle time — widening the window for core dumps, swap-to-disk, memory-disclosure bugs, cold-boot attacks. Why caching is warranted at all ------------------------------- Do NOT reason from the KeyFile dev/demo impl, where Unwrap is a cheap local AES op. Production wrappers are network-backed (KMS / Vault), where each Unwrap is a round-trip. A List that decrypts N configs sharing one tenant DEK then costs N KEK round-trips. That is the point at which a DEK cache earns its keep. So the question is not "cache or not" — it's how the cache's lifetime is bounded. How the current code handles it ------------------------------- There is NO cache. A TenantEncryptor is bound to one tenant and unwraps that tenant's DEK once, when EncryptorFactory.For builds it; it holds the plaintext key for its own lifetime. Store methods build one encryptor per operation and reuse it, so a List unwraps the tenant DEK once (solving the N-round-trips problem) without any memo. This removed the earlier soft spot: the old design memoized plaintext DEKs in a map whose "per-operation" lifetime was ACCIDENTAL — it held only because the store factory happened to construct a fresh encryptor per call, and a refactor that reused the encryptor would have silently turned it into a process-lifetime cache. Now the lifetime is explicit: it is exactly the lifetime of the tenant-scoped encryptor a store method constructs and drops. Nothing to keep in sync, no map, no incidental invariant. DEKs are also no longer minted lazily — Provision creates them at tenant creation — so the read/write path never generates key material, only unwraps the one that already exists. Where a TTL cache would still come in ------------------------------------- The current model unwraps once per OPERATION. It does NOT amortize across operations: two requests for the same tenant each unwrap the DEK (one KEK round-trip each). If that per-request round-trip proves too costly under a network-backed wrapper, the next step is a deliberate TTL + max-uses cache (e.g. AWS Encryption SDK's caching CMM: cache a DEK for up to N minutes / M messages, then evict) — bounded and chosen, with its own exposure tradeoff. Not built now. Request-scoped ownership was considered and rejected as the general answer: in Go a "request scope" is just a value created at the boundary and passed down, which is essentially what per-operation encryptor construction already gives us, and it does not generalize to cross-request amortization. Go memory limits ---------------- - No RAII / destructors / deterministic teardown. When a handler returns the cache becomes unreachable, but the plaintext bytes sit on the heap until GC reuses that memory. GC does NOT zero freed memory. - You cannot reliably zero your own []byte: GC may have copied/moved the backing array; anything that escaped into a copy is beyond reach. defer wipe() is best-effort and leaky. - For strong guarantees (wipe + keep out of swap + guard pages) the tool is third-party: awnumar/memguard (mlock'd, off-heap, explicitly wipeable). runtime/secret (Go 1.26, experimental) — the thing to revisit ------------------------------------------------------------- Recent Go runtime work that targets exactly the "GC won't wipe freed memory" gap: - Added in Go 1.26, gated behind GOEXPERIMENT=runtimesecret. Experimental, NOT covered by the Go 1 compatibility promise. Aimed at crypto-library authors, not app code. - API: secret.Do(f func()). Runs f in "secret mode"; on return the runtime zeroes the registers and stack f used — including on panic / runtime.Goexit. Heap allocations made inside Do are erased once the GC finds them unreachable. - So it can deterministically scrub the stack and (eventually) heap allocations made in a secret scope — closing the gap that plain Go cannot. - Limitations: linux/amd64 and linux/arm64 only. Allocating inside Do raises GC sweep time and memory (the runtime tracks those allocations until erased). - It is about ERASURE / forward secrecy, not anti-swap or guard pages — the mlock role still looks like memguard's job. Confirm before relying on it. Implication for the design: secret.Do is scope-shaped (unwrap -> use -> erase at scope end). That is the OPPOSITE posture from a long-lived plaintext-DEK cache, whose whole point is to keep keys resident across operations. So it reinforces the tension rather than resolving it: secret mode is the right tool for "unwrap -> decrypt -> erase within one operation," and a TTL cache is exactly the thing whose keys secret mode would want gone. If anything it makes the no-cache path more attractive long-term, because the per-operation unwrap could be made genuinely erasable. Revisit when ------------ - A network-backed Wrapper (KMS / Vault) actually lands — that is when the cache-lifetime decision has to be made for real. - runtime/secret graduates from experimental and/or broadens past Linux-only, at which point per-operation unwrap-and-erase becomes a real option. Sources ------- - https://pkg.go.dev/runtime/secret - https://github.com/golang/go/issues/21865 - https://antonz.org/accepted/runtime-secret/