Skip to content

[Feature Request] Add optional in-memory encryption wrapper for enhanced readout attack resistance #56

Closed

Description

@Slurp9187

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) and into_inner map cleanly; expose_secret(&self) -> &T cannot be implemented without keeping a decrypted copy alive. So sealed types are separate types, not a mode, and do not implement RevealSecret.
  • Byte-shaped only. #[forbid(unsafe_code)] rules out a byte view of arbitrary T: Zeroize. Scope: SealedFixed<N> (from Fixed<[u8; N]>), SealedDynamic (from Dynamic<Vec<u8>> / Dynamic<String>).
  • Glob-import hazard (compile-checked). A scoped-access trait re-exported at the crate root with a with_secret method breaks use secure_gate::*; callers with E0034. Keep it out of the root or give its method a different name.

Proposed shape

  • Feature sealed (optional deps: chacha20poly1305, sha2; requires std + rand for the global pre-key). Default dependency set stays zeroize only. → shipped in 0.9.0-rc.13; also region for the page lock, and the pre-key is an Arc<Prekey> owner rather than a global.
  • SealedFixed<N> { ct: [u8; N], tag: [u8; 16], salt: [u8; 32] } — detached tag avoids generic_const_exprs; unseals into a stack Fixed<[u8; N]> so existing Drop does the wiping. → shipped; the struct also holds the Arc<Prekey>.
  • SealedDynamic — ciphertext in Box<[u8]>; unseals into an exact-capacity Dynamic<Vec<u8>>. → shipped as SealedDynamic<Vec<u8> | String>.
  • Conversions: SealedFixed::from(Fixed), .unseal() -> Fixed; same for Dynamic. → shipped as SealedFixed::seal(fixed, &prekey) / .unseal() (sealing needs the pre-key, so not From); same for SealedDynamic.
  • RevealScoped trait (blanket impl for every RevealSecret, direct impl for sealed types), kept out of glob imports. → secure_gate::scoped::RevealScoped, not re-exported at the root.
  • Debug fully redacted (no salt, no length); ct_eq via unseal + subtle; no Hash (sequoia's feeds plaintext into the hasher); Clone behind cloneable. → shipped, except Clone: dropped after review (owner decision), consistent with Fixed<[u8; N]>.
  • Docs: narrow threat model (lossy readout only; nothing against a clean read, debugger, /proc/<pid>/mem or core dump), placed beside SECURITY.md §3, not instead of it. → SECURITY.md §3 table and the sealed module docs.

Before starting

  • Benchmark SealedFixed<32> access vs Fixed<[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 SealedDynamic is needed at all, or whether keys (SealedFixed) cover the real uses. → yes (EFV keeps passwords and an SSH key as Dynamic<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 own Prekey.
  • Pre-key locking / MADV_DONTDUMP: worth a dependency that uses unsafe? → locking built through region (safe API; this crate keeps forbid(unsafe_code)); MADV_DONTDUMP not.
  • Is a no_std explicit-context (&Prekey per 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]>, mirroring Dynamic<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

  1. changed the title [-][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[/+] on Jan 18, 2026
  2. Slurp9187 commented on Jan 18, 2026

    @Slurp9187
    OwnerAuthor

    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

    • Transparent API: Similar ergonomics to current Dynamic/Fixed—new(), expose(), with(fn), etc.
    • no_std compatible where possible.
    • Optional feature (e.g., "encryption" or "mem-encrypt").
    • Defends against: Core dumps, hibernation files, process snapshots, cold-boot (partial), memory forensics.
    • Not full HSM—assumes attacker can't execute code or hook runtime.

    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:

    • Easy to implement/audit.
    • Fast (no per-secret derivation).
    • Single init call.

    Cons:

    • Single point of failure (compromise session key → all secrets).
    • No forward secrecy.

    Impl Sketch:

    // Global lazy/static (thread-safe init)
    static SESSION_KEY: OnceCell<Dynamic<[u8; 32]>> = OnceCell::new();
    
    pub fn init_encryption() {
        let mut key = [0u8; 32];
        getrandom::getrandom(&mut key).unwrap();
        SESSION_KEY.get_or_init(|| Dynamic::new(key));
    }
    
    // New wrapper type (or mode on existing)
    #[derive(Zeroize, ZeroizeOnDrop)]
    pub struct Encrypted<T> {
        ciphertext: Zeroizing<Vec<u8>>,
        nonce: [u8; 12],  // Or 24 for XChaCha
    }
    
    impl Encrypted<Vec<u8>> {  // Generic over plaintext type, but store as bytes
        pub fn from_plain(plain: &[u8]) -> Self {
            let key_guard = SESSION_KEY.get().unwrap().expose();
            let cipher = ChaCha20Poly1305::new((&*key_guard).into());
    
            let nonce = ChaCha20Poly1305::generate_nonce(&mut rand::thread_rng());
            let ciphertext = cipher.encrypt(&nonce.into(), plain).unwrap();
    
            Self {
                ciphertext: Zeroizing::new(ciphertext),
                nonce: nonce.into(),
            }
        }
    
        pub fn expose(&self) -> Guard<&[u8]> {
            let key_guard = SESSION_KEY.get().unwrap().expose();
            let cipher = ChaCha20Poly1305::new((&*key_guard).into());
    
            let plaintext = cipher.decrypt(&self.nonce.into(), self.ciphertext.as_ref()).unwrap();
            Guard::new_temp(plaintext)  // Zeroizing guard
        }
    }
    • Users: Call init_encryption() early; wrap secrets with Encrypted::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

    • Top Recommendation: chacha20poly1305 crate (RustCrypto)—fast, constant-time, ubiquitous, XChaCha variant available.
    • Lightweight Alternative: Ascon128a (from ascon or emerging crates)—smaller footprint, NIST LWC.
    • Abstraction: Use aead trait + 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:

    pub struct Plain;
    pub struct Encrypted;
    
    pub struct Secret<T, Mode = Plain> {
        data: Zeroizing<T>,
        _mode: PhantomData<Mode>,
        // Encrypted mode: extra nonce/ciphertext fields
    }
    • Secret<..., Plain>: Current behavior.
    • Secret<Vec<u8>, Encrypted>: Stores ciphertext + nonce, decrypt in expose().

    Extra Ideas

    • Re-encryption on Mutation: If wrappers allow in-place changes, re-encrypt after.
    • Persistence Hook: Optional to_encrypted_bytes() / from_encrypted_bytes() for disk (with separate key).
    • Hybrid: Default plain wrappers; opt-in encrypted via .encrypt() method that consumes plain and returns encrypted.
    • Benchmarks Needed: Measure expose() latency for 32-byte vs 4KB secrets.
    • Threat Add-ons: Combine with mlock/page guarding if platform allows (feature-gated).

    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.

  3. Slurp9187 commented on Sep 30, 2026

    @Slurp9187
    OwnerAuthor

    Reopened: there is now a worked reference implementation to design against — sequoia-openpgp 2.4.1's crypto::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:

    • No stored session key (Option 1). A single 32-byte KEK held in memory is what a lossy readout recovers by search. The protection comes from recomputing the key per access as 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.
    • No guard-returning expose(). Plaintext must not outlive the call, so expose_secret(&self) -> &T cannot be implemented. Sealed types offer with_secret, with_secret_mut (re-seal under a fresh salt) and into_inner, and are separate types rather than a mode on Fixed/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

  4. added a commit that references this issue on Sep 30, 2026
  5. 6 remaining items

  6. Slurp9187 commented on Oct 19, 2026

    @Slurp9187
    OwnerAuthor

    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 sealed feature.

    What landed, against this issue's checklist:

    • Feature sealed (chacha20poly1305, sha2, region; implies std + rand); default dependency set still zeroize only.
    • SealedFixed<N> (inline ciphertext, opens in place into a stack Fixed) and SealedDynamic<Vec<u8> | String> (with_secret_mut hands out &mut [u8] / &mut str, so it cannot grow).
    • Sealing key 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.
    • Pre-key as an 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 by lock_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.
    • Every access ends with a stack scrub, on panic paths too, after an audit found the cipher leaving the key on the stack. Probes and runs: docs/design/receipts/sealed-stack-residue/.
    • scoped::RevealScoped (kept out of the crate root), sealed_fixed_newtype! with with_role / with_role_mut; no expose_secret, no Hash, no Clone; Debug redacted.
    • Threat model in 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions