DEV Community

Dhruv Sharma
Dhruv Sharma

Posted on

How We Ensured API Keys Never Linger in RAM

Rust's ownership model cleans up memory automatically — but it doesn't overwrite it. A dropped String containing an API key still has its bytes sitting in physical RAM until something else claims that page. The zeroize crate fixes that. Here's every pattern we used in a production secrets vault.

The Problem

When you store and retrieve API keys in a credentials vault, the sensitive bytes touch several places in memory:

  • The Argon2-derived encryption key (lives for the session)
  • The raw key value as a String (lives during add/retrieve operations)
  • The master password from stdin (lives until validated)

Rust's drop frees the allocation, but the OS doesn't zero it — it just marks the page as reusable. A memory dump, cold boot attack, or crash dump can recover the value seconds to minutes after drop.

Three Patterns, Applied

Pattern 1 — Zeroize on a custom struct with Drop

The encryption key is a fixed-size byte array stored in a struct that holds it for the lifetime of the vault session. We implement Drop manually to ensure it's overwritten before the memory is released:

struct LockedSecretboxKey {
    key: [u8; DERIVED_KEY_LEN],
    locked: bool,
}

impl Drop for LockedSecretboxKey {
    fn drop(&mut self) {
        if self.locked {
            unsafe { libc::munlock(self.key.as_ptr().cast(), self.key.len()); }
        }
        self.key.zeroize(); // overwrite with zeros before dealloc
    }
}
Enter fullscreen mode Exit fullscreen mode

The mlock call prevents the OS from swapping the page to disk. zeroize clears it from RAM. Together they close both attack surfaces.

Pattern 2 — Zeroizing<T> wrapper for automatic zeroing

For the decrypted credential returned to callers, we wrap the value type in Zeroizing<String>. It implements Drop internally — you get automatic zeroing without writing any Drop code:

pub struct DecryptedCredential {
    pub id: String,
    pub key: Zeroizing<String>, // zeros itself on drop
}
Enter fullscreen mode Exit fullscreen mode

This also prevents Clone and Copy from being derived, which is exactly what you want — no accidental duplication of secret values.

Pattern 3 — Explicit .zeroize() before end of scope

During add_credential, the raw key string lives as a local while we encrypt it. After encryption completes, we call .zeroize() explicitly rather than waiting for the scope to end:

key_value.zeroize(); // explicit: zero now, not at brace
Enter fullscreen mode Exit fullscreen mode

And during key derivation, we wrap the intermediate buffer in Zeroizing::new() so even if hash_password_into returns an error partway through, the partial derivation is wiped:

let mut derived = Zeroizing::new(vec![0u8; DERIVED_KEY_LEN]);
argon2.hash_password_into(master_password.as_bytes(), salt, &mut derived)?;
Enter fullscreen mode Exit fullscreen mode

The Pitfalls

Drop order matters during error paths. In LockedSecretboxKey::new, if mlock fails and require_mlock is true, we call key.zeroize() before returning the error — because the key still exists in that stack frame and we would otherwise return with sensitive bytes uncleared.

String is special. The Zeroize trait works on String and Vec<u8> because they own their heap allocation. You cannot use it with &str — there's no ownership to zero through.

Clone/Copy on secret types is a footgun. We assert in tests that DecryptedCredential does not implement Copy or Clone. If it did, callers could silently duplicate the key into a plain String that never gets zeroed.

Takeaway

zeroize is a one-crate solution to a real gap in Rust's memory model: ownership handles cleanup, but not sanitization. The three patterns cover the full lifecycle — long-lived session keys, short-lived plaintext values, and intermediate derivation buffers. Pair it with mlock for anything that should never hit swap.

Top comments (0)