Repository navigation
[Feature Request] Add optional in-memory encryption wrapper for enhanced readout attack resistance #56
Description
Tracking issue for an opt-in, feature-gated family of secret wrappers that keep their contents encrypted in RAM while idle and decrypt only for the length of one closure call. Deferred until after 0.9.x stable; intended as an additive minor release.
The full analysis is in docs/design/sealed_memory.md, added in #263. This description replaces the original January proposal (still in this issue's edit history); the brainstorm comment below is kept for context, and the comment after it records what changed.
Model
sequoia-openpgp 2.4.1 (src/crypto/mem.rs), itself following OpenSSH: a 16 KiB random pre-key per process, a random 32-byte salt per sealed object, and a sealing key SHA-256(salt ‖ prekey) that is recomputed on every access and never stored. The point of the large pre-key is that a lossy readout (Spectre-class, Rowhammer, cold boot) that gets any one of its 131,072 bits wrong yields a useless key.
What the analysis changes relative to the original proposal and brainstorm
- No stored session key. The brainstorm's "Option 1" keeps a single 32-byte KEK in memory. That is exactly what a lossy readout can recover by search; the recompute-from-large-pre-key design is the part that gives the protection.
- No
expose_secret/ guard-returning access. Plaintext must not outlive the call.with_secret,with_secret_mut(re-seal under a fresh salt) andinto_innermap cleanly;expose_secret(&self) -> &Tcannot be implemented without keeping a decrypted copy alive. So sealed types are separate types, not a mode, and do not implementRevealSecret. - Byte-shaped only.
#[forbid(unsafe_code)]rules out a byte view of arbitraryT: Zeroize. Scope:SealedFixed<N>(fromFixed<[u8; N]>),SealedDynamic(fromDynamic<Vec<u8>>/Dynamic<String>). - Glob-import hazard (compile-checked). A scoped-access trait re-exported at the crate root with a
with_secretmethod breaksuse secure_gate::*;callers withE0034. Keep it out of the root or give its method a different name.
Proposed shape
- Feature
sealed(optional deps:chacha20poly1305,sha2; requiresstd+randfor the global pre-key). Default dependency set stayszeroizeonly. → shipped in 0.9.0-rc.13; alsoregionfor the page lock, and the pre-key is anArc<Prekey>owner rather than a global. -
SealedFixed<N> { ct: [u8; N], tag: [u8; 16], salt: [u8; 32] }— detached tag avoidsgeneric_const_exprs; unseals into a stackFixed<[u8; N]>so existingDropdoes the wiping. → shipped; the struct also holds theArc<Prekey>. -
SealedDynamic— ciphertext inBox<[u8]>; unseals into an exact-capacityDynamic<Vec<u8>>. → shipped asSealedDynamic<Vec<u8> | String>. - Conversions:
SealedFixed::from(Fixed),.unseal() -> Fixed; same forDynamic. → shipped asSealedFixed::seal(fixed, &prekey)/.unseal()(sealing needs the pre-key, so notFrom); same forSealedDynamic. -
RevealScopedtrait (blanket impl for everyRevealSecret, direct impl for sealed types), kept out of glob imports. →secure_gate::scoped::RevealScoped, not re-exported at the root. -
Debugfully redacted (no salt, no length);ct_eqvia unseal +subtle; noHash(sequoia's feeds plaintext into the hasher);Clonebehindcloneable. → shipped, exceptClone: dropped after review (owner decision), consistent withFixed<[u8; N]>. - Docs: narrow threat model (lossy readout only; nothing against a clean read, debugger,
/proc/<pid>/memor core dump), placed besideSECURITY.md§3, not instead of it. →SECURITY.md§3 table and thesealedmodule docs.
Before starting
- Benchmark
SealedFixed<32>access vsFixed<[u8; 32]>(expected tens of µs per access — not measured); choose SHA-256 vs BLAKE3 for the KDF on the number. → measured: ~8 µs per access vs ~0.4 ns; SHA-256 and BLAKE3 tie with SHA-NI (6.44 vs 6.29 µs), SHA-256 kept. - Decide whether
SealedDynamicis needed at all, or whether keys (SealedFixed) cover the real uses. → yes (EFV keeps passwords and an SSH key asDynamic<String>). -
SealedDynamic::with_secret_mut:&mut Vec<u8>with the growth caveat, or&mut [u8]? →&mut [u8]/&mut str; no growth. - Pre-key after
fork(): document, or re-key the child? → documented, not handled; a child creates its ownPrekey. - Pre-key locking /
MADV_DONTDUMP: worth a dependency that usesunsafe? → locking built throughregion(safe API; this crate keepsforbid(unsafe_code));MADV_DONTDUMPnot. - Is a
no_stdexplicit-context (&Prekeyper call) variant wanted? → not built; the owner object covers re-keying.
Related, smaller follow-ups (independent of sealing)
From the same analysis; each is additive and not needed for 0.9 stable:
-
impl<T: Zeroize> SecretLen for Dynamic<[T]>, mirroringDynamic<Vec<T>>. -
Dynamic::<[u8]>::new_with/try_new_with/from_random, making exact capacity the only construction path. - Encoding and serde impls for
Dynamic<[u8]>.
Activity
[-][Feature] Add optional in-memory encryption wrapper for enhanced readout attack resistance[/-][+][Feature Request] Add optional in-memory encryption wrapper for enhanced readout attack resistance[/+]Slurp9187 commentedon Jan 18, 2026
Brainstorming full ideas for in-memory encryption wrapper—stashing for post-0.7.0 (maybe 0.8 or 1.0 as an "advanced" feature). This is a high-value addition for defending against memory snapshots/dumps/forensics, but it's complex, so let's collect refined options here for future implementation.
Goal: A wrapper that keeps secrets encrypted in memory most of the time, only decrypting temporarily in guarded
expose()/with()scopes. Complements existing zeroize/guard patterns with "at-rest" protection.Core Requirements
Dynamic/Fixed—new(),expose(),with(fn), etc."encryption"or"mem-encrypt").Design Option 1: Single Global/Session Key (Simplest, Recommended Starting Point)
Generate one random 256-bit session key at startup (wrapped in existing secure-gate types). Use it as KEK to encrypt all secrets individually.
Pros:
Cons:
Impl Sketch:
init_encryption()early; wrap secrets withEncrypted::from_plain(...).Design Option 2: Per-Secret Derived KEKs (Higher Isolation)
Derive unique KEK per secret from global master + secret-specific salt (random per instance).
Pros: Compromise of one doesn't affect others.
Cons: More compute, storage (extra salt/nonce).
Variant: Use original #56 pre-key scattering (multi-page random bytes, BLAKE3 combine into KEK)—adds readout resistance via bitflip fragility.
AEAD Choices
chacha20poly1305crate (RustCrypto)—fast, constant-time, ubiquitous, XChaCha variant available.asconor emerging crates)—smaller footprint, NIST LWC.aeadtrait + feature flags to swap (e.g.,"encrypt-chacha"default,"encrypt-ascon"alt).Type Safety: Plain vs Encrypted Modes
To prevent misuse (e.g., exposing ciphertext as plain), use phantom markers:
Secret<..., Plain>: Current behavior.Secret<Vec<u8>, Encrypted>: Stores ciphertext + nonce, decrypt in expose().Extra Ideas
to_encrypted_bytes()/from_encrypted_bytes()for disk (with separate key)..encrypt()method that consumes plain and returns encrypted.This feature would make secure-gate uniquely strong for long-lived processes (servers, wallets). Single-session-key + ChaCha20-Poly1305 + phantom modes feels like the sweet spot for v1—low complexity, high protection.
docs(design): link sealed-memory note to tracking issue #264 and prior …
Slurp9187 commentedon Sep 30, 2026
Reopened: there is now a worked reference implementation to design against —
sequoia-openpgp2.4.1'scrypto::mem::Encrypted, which does the multi-page pre-key design this issue originally proposed.The analysis is in
docs/design/sealed_memory.md(PR #263), and the implementation checklist is now this issue's description (consolidated from #264, closed as a duplicate). Two points from the brainstorm above are revised:SHA-256(salt ‖ 16 KiB pre-key), so one wrong bit in the pre-key yields a useless key — i.e. the original proposal's design, not Option 1.expose(). Plaintext must not outlive the call, soexpose_secret(&self) -> &Tcannot be implemented. Sealed types offerwith_secret,with_secret_mut(re-seal under a fresh salt) andinto_inner, and are separate types rather than a mode onFixed/Dynamic.Still deferred until after 0.9.x stable; the path is additive (new feature, new types, no change to
RevealSecret).Generated by Claude Code
docs(design): point sealed-memory note at #56, the consolidated track…
6 remaining items
sealed: scrub the stack after each seal and open; refuse a lock on pa…
sealed: open SealedFixed in place inside the scrubbed frame; record t…
receipts: record the scrub falsification run (#56)
sealed: with_role / with_role_mut on sealed roles; salts drawn withou…
changelog: with_role on sealed roles (#56)
sealed: draw salts from SysRng words, not through Fixed (#56)
sealed: SealedDynamic opens through a method, as SealedFixed does (#56)
docs: say openly that Fixed can live on the stack, in SECURITY.md §3 …
docs: Fixed's rustdoc says to keep it short-lived and passed by refer…
sealed: act on two reviews of #265 (#56)
tests: say which configuration the compile-fail snapshots are pinned …
docs: measured stack headroom and Linux lock behaviour from the third…
Merge pull request #265 from Slurp9187/sealed-memory
Slurp9187 commentedon Oct 19, 2026
Shipped. The sealed-memory family is in secure-gate 0.9.0-rc.13 (#265) and refined in 0.9.0-rc.14 (#266), both on crates.io behind the opt-in
sealedfeature.What landed, against this issue's checklist:
sealed(chacha20poly1305,sha2,region; impliesstd+rand); default dependency set stillzeroizeonly.SealedFixed<N>(inline ciphertext, opens in place into a stackFixed) andSealedDynamic<Vec<u8> | String>(with_secret_muthands out&mut [u8]/&mut str, so it cannot grow).SHA-256(salt ‖ 16 KiB pre-key), recomputed per access, never stored; ChaCha20-Poly1305, detached tag, zero nonce, fresh salt on every seal and re-seal.Arc<Prekey>owner, not a static: locked in RAM (region), wiped while locked, then unlocked, then freed when the last reference drops; a refused lock is reported bylock_status(), never fatal. Since rc.14 the block is aligned per target (16 KiB on x86/x86_64/Apple aarch64, 64 KiB elsewhere) so it also locks on 64 KiB-page systems.docs/design/receipts/sealed-stack-residue/.scoped::RevealScoped(kept out of the crate root),sealed_fixed_newtype!withwith_role/with_role_mut; noexpose_secret, noHash, noClone;Debugredacted.SECURITY.md§3: lossy readout and swap covered; hibernation, crash dumps and clean reads not.The benchmark and the open questions are answered in
docs/design/sealed_memory.md("As built", "Open questions, resolved"). Reviewed in three passes plus a review of #266; EFV's integration test (#59) passed against it before the merge.