Deep dive
How it works
In: a secret as bytes and its owner's lifetime · Out: the secret usable only inside its region, then erased · State: one locked region per secret, a locked flag · Cost: wipe O(n), lock one syscall per range
- Allocate the secret once in a dedicated heap block with exact capacity (no
Vecgrowth, no later realloc); the owner moves a pointer, never the bytes.sodium_mallocgoes further: guard page after, canary before,mlocked, outside core dumps (libsodium docs). mlock(addr, len)the pages so they are never paged to swap. Whole pages are locked; unprivileged processes are limited byRLIMIT_MEMLOCK(ENOMEM when exceeded;CAP_IPC_LOCKlifts the limit) (mlock(2)). Locks are not inherited byforkchildren.- Exclude from dumps:
madvise(MADV_DONTDUMP)per range (Linux 3.4),prctl(PR_SET_DUMPABLE, 0)orsetrlimit(RLIMIT_CORE, 0)per process.MADV_WIPEONFORK(Linux 4.14) gives a forked child zero-filled pages instead of the secret. - Use the bytes through short borrows (
&[u8]); compare in constant time; never format or log them. - Erase with stores the compiler must keep: a volatile write per byte or word, then a compiler fence so later code is not reordered before it. C equivalents are
explicit_bzero(glibc 2.25, BSDs),memset_s(C11 Annex K, optional),memset_explicit(C23),SecureZeroMemory(Windows),sodium_memzero. munlockonly after the wipe (sodium_munlockzeroes first, then unlocks), then free the block.- Stack copies are the weak point: wipe locals and clear the stack after a batch of operations (
sodium_stackzero).
Alternative to steps 2-3: memfd_secret (Linux 5.14) maps pages that are removed from the kernel direct map, locked like mlock, and block hibernation while they exist. The kernel itself cannot access them, so a pointer cannot be passed to a syscall. It is hardening, not a 100 % guarantee (man page). Before Linux 6.5 it was off by default.
Code
use std::ffi::c_void;
use std::fmt;
use std::ptr::write_volatile;
use std::sync::atomic::{compiler_fence, Ordering};
extern "C" { // libc, already linked by std (Linux/macOS)
fn mlock(addr: *const c_void, len: usize) -> i32;
fn munlock(addr: *const c_void, len: usize) -> i32;
}
/// Fixed-size secret on the heap: the Box pointer moves, the bytes never do.
pub struct Secret<const N: usize> { buf: Box<[u8]>, locked: bool } // no Clone/Copy
impl<const N: usize> Secret<N> {
/// Takes ownership of the caller's bytes and wipes the source.
pub fn take(src: &mut [u8; N]) -> Self {
let buf: Box<[u8]> = src.to_vec().into_boxed_slice(); // capacity == N, no growth
for b in src.iter_mut() { unsafe { write_volatile(b, 0) } }
compiler_fence(Ordering::SeqCst);
Secret { buf, locked: false }
}
/// Best effort: fails with ENOMEM past RLIMIT_MEMLOCK (unprivileged).
pub fn lock(&mut self) -> bool {
self.locked = unsafe { mlock(self.buf.as_ptr().cast(), N) } == 0;
self.locked
}
pub fn expose(&self) -> &[u8] { &self.buf }
pub fn wipe(&mut self) {
let mut it = self.buf.chunks_exact_mut(8);
for c in &mut it { unsafe { write_volatile(c.as_mut_ptr().cast::<[u8; 8]>(), [0; 8]) } } // align 1: sound
for b in it.into_remainder() { unsafe { write_volatile(b, 0) } } // every store must happen
compiler_fence(Ordering::SeqCst); // and not be reordered past later code
}
pub fn ct_eq(&self, other: &[u8; N]) -> bool {
self.buf.iter().zip(other).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
}
}
impl<const N: usize> Drop for Secret<N> {
fn drop(&mut self) {
self.wipe();
if self.locked { unsafe { munlock(self.buf.as_ptr().cast(), N); } } // after wipe
}
}
impl<const N: usize> fmt::Debug for Secret<N> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("Secret(***)") }
}
fn main() {
let mut raw = [0xA5u8; 32];
let mut s = Secret::<32>::take(&mut raw);
assert_eq!(raw, [0u8; 32]); // source wiped
assert!(s.ct_eq(&[0xA5; 32]) && !s.ct_eq(&[0xA4; 32]));
let locked = s.lock(); // false is possible (RLIMIT_MEMLOCK)
assert_eq!(format!("{:?}", s), "Secret(***)");
println!("locked={locked}");
s.wipe();
assert!(s.expose().iter().all(|&b| b == 0)); // sound: read before drop
// A Vec that grows reallocates: push past capacity leaves the old block
// (holding the secret) freed but not wiped. Never read it; just avoid it.
let mut v: Vec<u8> = Vec::with_capacity(32);
v.extend_from_slice(&[7; 32]);
let cap = v.capacity();
v.push(8); // cap exceeded -> new allocation
assert!(v.capacity() > cap);
}
The run asserts that take wipes its source, ct_eq, Debug, and that wipe zeroes the buffer (read before drop; after drop it would be use-after-free). The Vec part shows only capacity growth: the old block is freed with the secret in it.
Complexity and constants
- Wipe is O(n) stores; with volatile stores the compiler cannot vectorise or merge them, so the cost is stores, not bandwidth. Measured on Apple M4 Pro (release, minimum of 5 runs,
black_boxaround every call so even the plain fill is kept):
| buffer | plain fill(0) (memset) | volatile per byte | volatile per 8-byte chunk |
|---|---|---|---|
| 32 B | 2.8 ns | 9.6 ns | 1.5-1.8 ns |
| 4 KiB | 35-41 ns | 1.05 µs | 140-240 ns (two builds) |
mlockcosts a syscall plus pinning page-table entries; not measured here. Locked memory counts againstRLIMIT_MEMLOCK, often a small default for unprivileged users.sodium_mallocneeds 3 or 4 extra pages of virtual memory per allocation and is slower thanmalloc(libsodium docs): allocate secrets from a few pools, not per request.
Efficiency
Cost: a wipe of a 32-byte key is a few ns, a 4 KiB buffer about 0.14-1 µs on Apple M4 Pro (measured above); the lock is one syscall per region. The bound is store count for the wipe and page-table work for the lock, not memory bandwidth.
Levers:
- Store wide words — chunked volatile stores were 4-7x faster than per byte at 4 KiB (measured) — any buffer above a few dozen bytes; use
[u8; 8]writes, which have alignment 1 and stay sound. - Lock one pool at start instead of per secret — one syscall and one page per many keys (mlock(2): locking is per whole page) — servers with many keys.
- Short lifetime, derive on demand — fewer seconds and copies exposed, costs a KDF per use — keys derived from passwords.
Naive → standard → state of the art:
- Naive:
Vec<u8>secret,fill(0)at the end · may be optimised away, old realloc copies stay - Standard: fixed heap region,
mlock, volatile/explicit_bzerowipe on drop,MADV_DONTDUMP· a few ns to about a µs per wipe, one syscall per region - State of the art: key never in the process (HSM, enclave, or sealed to hardware),
memfd_secretfor what must stay · off-device operations cost a round trip or an enclave transition
Implementation notes
to_vec().into_boxed_slice()on an exact-capacity vector does not reallocate, so no second copy appears;Box::new([0; N])may build the array on the stack first in debug builds.- Rust moves are memcpy: an inline
Secret<N>would be copied on every move, so the bytes sit behind a pointer (zeroize docs recommend heap storage; stack spills can still leave copies). - Wipe in
Drop(runs on unwinding too), beforemunlock/free.abortandSIGKILLskip it, so dump and swap exclusion must stand alone. prctl(PR_SET_DUMPABLE, 0)is reset when the process changes uid/gid or execs a setuid program (prctl man page); set it after privilege changes.- libsodium calls
mlockdefence in depth: on hosts with high-value keys also disable or encrypt swap and hibernation.
Pitfalls
- Trusting
memsetbeforefree: the as-if rule lets the compiler drop it (documented in the C standard library references and by libsodium). volatileon the secret variable itself: it can force a register value into RAM for its whole life (explicit_bzero man page); put the volatile on the wipe, not the data.Vec/Stringregrowth,clone,format!,serdeandDebugoutput each create copies the wipe never sees; the zeroize docs say itsVecimpl zeroes the capacity but cannot reach earlier reallocation copies.- Ignoring the
mlockresult: pastRLIMIT_MEMLOCKit fails, andsodium_mallocthen still returns unlocked memory (libsodium docs). - Stack and registers:
mlockcannot cover them (libsodium docs). - Zeroed values can still leak through microarchitectural channels; the zeroize docs give no guarantee against them.
Variants and alternatives
sodium_malloc)when you also want overflow detection: canary, guard pages (guard pages) and mprotect to noaccess between uses.memfd_secretLinux 5.14+, when kernel-level reads must be hard and hibernation blocked.In production
- Credential tools: OpenSSH
ssh-agentsetsRLIMIT_COREto 0 inssh-agent.c, and its key-shielding code keeps private keys encrypted in memory under a pre-key mapped withMAP_CONCEAL/MAP_NOCOREwhere available (sshkey.c). - Server and crypto libraries: libsodium ships
sodium_memzero,sodium_mlockand the guarded heap; the Rustzeroizecrate backsZeroize/ZeroizeOnDropthrough volatile writes plus atomic fences. - Operating systems: Linux
memfd_secret,MADV_DONTDUMP,MADV_WIPEONFORK; glibc and the BSDsexplicit_bzero; WindowsSecureZeroMemory.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| libsodium | C | jedisct1/libsodium | brew install libsodium | — | — | — | — | sodium_memzero, sodium_mlock, sodium_malloc guarded heap |
| zeroize | Rust | RustCrypto/utils | cargo add zeroize | — | — | — | — | volatile writes + fences; derive ZeroizeOnDrop |
| secrecy | Rust | iqlusioninc/crates | cargo add secrecy | — | — | — | — | redacting wrappers that zeroize on drop |
glibc explicit_bzero | C | man page | built in (glibc 2.25+) | — | — | — | — | erase call the compiler may not remove |
Sources
- Linux man-pages, mlock(2). doc
- Linux man-pages, madvise(2) (MADV_DONTDUMP, MADV_WIPEONFORK). doc
- Linux man-pages, memfd_secret(2). doc
- Linux man-pages, prctl(2) (PR_SET_DUMPABLE). doc
- Linux man-pages, explicit_bzero(3). doc
- libsodium documentation, Secure memory. doc · code
- RustCrypto, zeroize crate documentation. doc · code
- Microsoft Learn, SecureZeroMemory function. doc
- cppreference, memset, memset_explicit, memset_s (C11 Annex K K.3.7.4.1). doc
- OpenSSH portable,
sshkey.candssh-agent.c. code