๐ memsafe
Locked, sealed, zero-on-drop memory for secrets.
Secrets live in a locked memory page: kept out of swap, excluded from core dumps (Linux), inaccessible between uses (on Unix even your own process can't read it), never on the regular heap, and volatile-wiped on drop. All behind one safe Rust type.
๐ง Linux ย ยทย ๐ macOS ย ยทย ๐ช Windows ย ยทย โ๏ธ other Unixes CI-tested on 10 OS/architecture targets โ x86_64 ยท aarch64 ยท ARM ยท i686 ยท RISC-V ยท PowerPC
Dropping a String doesn't erase it. The bytes stay in the freed heap block, can get paged out to swap, and show up in core dumps. memsafe exists to close those paths:
use Secret;
let mut api_key = new_with.unwrap;
mmap โโโถ mlock โโโถ MADV_DONTDUMP โโโถ write in place โโโถ PROT_NONE
page pinned excluded from secret never locked down
alloc in RAM core dumpsยน touches the until *you*
heap or stack read() it
ยน Linux only. See platform notes for what each OS actually provides.
Compared to a plain String
plain String |
memsafe::Secret |
|
|---|---|---|
| Swap under memory pressure | may be paged to disk | stays in RAM โ page is mlock'd |
| Core dumps | included | excluded on Linux (MADV_DONTDUMP) |
| While your code isn't using it | readable through any stray pointer or overflow | page is PROT_NONE on Unix โ even your own process faults on access (PAGE_READONLY on Windows) |
| Bytes after drop | linger on the freed heap | volatile-zeroed + compiler-fenced โ the optimizer cannot elide the wipe |
A stray {:?} in a log line |
leaks it | does not compile โ Secret has no Debug, no Display |
A few things that may help you evaluate the crate. The public API needs no unsafe on your side. There are two dependencies, both platform-gated (libc on Unix, winapi on Windows). The test suite includes tests where the kernel itself verifies the page sealing: a child process reads the sealed page and must die by SIGSEGV for the test to pass. And the threat model below spells out what this crate does not protect against, which for a security library matters as much as what it does.
Contents โ Quick start ยท Going deeper ยท What you get ยท How it compares ยท Performance ยท Threat model ยท Platform notes ยท Under the hood ยท Roadmap
Quick start
cargo add memsafe
Store an API key, password, or token in a Secret<N>. N is the buffer size; pick one large enough for your secret, unused bytes stay zero:
use Secret;
// Write the secret straight into protected memory โ it never exists
// on the regular heap or in a stack temporary.
let mut secret = new_with.unwrap;
// Read it back through a guard. The page is unlocked only while
// `view` is alive, and re-sealed the moment it drops.
let view = secret.read.unwrap;
assert_eq!;
If the secret already sits in a String or Vec<u8>, hand it over. The source gets volatile-zeroed after the copy, so no readable trace stays behind:
use Secret;
let key = b"my-api-key".to_vec;
let mut secret = from_bytes.unwrap;
// Or via TryFrom:
let mut secret: = Stringfrom.try_into.unwrap;
Reading a password straight from stdin, without it ever touching a String:
use Secret;
use Read;
let mut secret = new_with.unwrap;
Construction can fail (buffer too small, mlock limit hit). When it does, the owned-input constructors give you the source back along with the error instead of eating it:
use Secret;
let too_long = Stringfrom;
match try_from
If all you need is a secret guard, that's the whole API. The rest of this document is detail.
Going deeper
The crate exports two types:
| Type | Use it for |
|---|---|
Secret<N> |
Secrets. Every constructor keeps all N bytes inline inside the protected page; the heap-pointer pitfall is unrepresentable. |
MemSafe<T> |
Non-secret protected memory. Any T that wants mlock + mprotect semantics โ FFI scratch buffers, guard regions, data you want fault-on-touch while idle. |
Secret<N> constructors
| Constructor | Input | Source returned on error | Source zeroized |
|---|---|---|---|
Secret::<N>::new_with(F) (F: FnOnce(&mut [u8; N])) |
(closure writes in place) | N/A โ no source | N/A โ no source |
Secret::<N>::from_bytes(T) (T: AsMut<[u8]>) |
Owned bytes | Yes, with the error | Yes โ len bytes overwritten before drop |
<Secret<N> as TryFrom<String>>::try_from |
Owned String |
Yes, with the error | Yes โ via from_bytes on into_bytes() |
<Secret<N> as TryFrom<&str>>::try_from |
Borrowed | N/A โ borrow returned to caller | No (caller-managed) |
Capacity vs. length on
Vec/String.from_bytesonly zeroes the bytes exposed byAsMut::as_mut, which islen, notcapacity. If yourVecorStringhas unused trailing capacity that may have held earlier secret content, callshrink_to_fit()before passing it in.
MemSafe<T> โ the general-purpose protected container
MemSafe<T> is the lower-level building block: a protected memory region holding any T. It does not carry the secret-handling machinery โ no from_bytes, no TryFrom, no source zeroization.
use MemSafe;
let mut buffer = new.unwrap;
Why not just use
MemSafe<String>for a password? Because it doesn't do what it looks like it does. AStringkeeps its bytes on the regularmallocheap; wrapping it inMemSafeonly protects the 24-byte header (pointer, length, capacity). The actual secret stays on the unprotected heap: swappable, dumpable, and not zeroed when freed. This is exactly the mistakeSecret<N>is designed to rule out, since it can only wrap[u8; N]and there is no indirection to get wrong.
Type-state API โ access states checked by the compiler
With the type-state feature, the buffer's access state is carried in the type, so reading a no-access buffer or writing through a read-only handle is a compile-time error:
cargo add memsafe --features type-state
use MemSafe;
// initialize a 32-byte buffer in no-access state
let secret = new.unwrap;
// transition to read-write and write into it
let info = "my-secret-info";
let mut secret = secret.read_write.unwrap;
secret.copy_from_slice;
// transition to read-only and read from it
let secret = secret.read_only.unwrap;
println!;
Secret<N>does not yet have a type-state variant โ secret bytes go through the runtime-stateSecretAPI regardless of thetype-statefeature. If you want compile-time-enforced access state on a secret, open an issue or PR; the building blocks (Cell+type_state::MemSafe) are already in place.
What you get
| Property | How it's enforced |
|---|---|
| Not paged to disk | mlock (Unix) / VirtualLock (Windows) on the backing page |
| Not in core dumps | madvise(MADV_DONTDUMP) โ Linux only |
| Zeroed in forked children | madvise(MADV_WIPEONFORK) โ Linux only; the kernel wipes the child's copy of the page |
| Inaccessible between operations | mprotect(PROT_NONE) on Unix; PAGE_READONLY on Windows (the strictest level Windows offers in this model) |
| Zero-on-drop | Byte-wise write_volatile + compiler_fence(SeqCst) over the page immediately before munmap / VirtualFree |
| No allocator round-trip | Bytes are copied straight from the source into the protected page; they never reach the regular malloc heap |
| Source zeroization | Secret::from_bytes(T) volatile-overwrites the source byte slice before drop, for any T: AsMut<[u8]> |
| Recoverable on error | Secret constructors return the original source alongside the error so the caller can retry, inspect, or further zeroize it |
| In-place initialization | Secret::new_with lets a closure write straight into the protected page โ the secret never visits a stack temporary or the regular heap |
No Debug, no Display |
Secret implements neither, so a stray {:?} in a log line cannot leak it |
| Heap-pointer pitfall unrepresentable | Secret<N> only ever wraps [u8; N] โ by construction it is impossible to protect a header while leaving the payload on the unprotected heap |
| Compile-time access enforcement | Optional, behind the type-state feature |
How it compares
There are several good crates in this space, and they solve different layers of the problem. Where memsafe fits: it automates the OS-level protections behind a typed API, instead of giving you primitives or a wrapper type alone.
| Capability | zeroize |
secrecy |
memsec |
region |
memsafe |
|---|---|---|---|---|---|
| Elision-resistant zero-on-drop | โ | โ (via zeroize) | manual (memzero) |
โ | โ automatic |
Locked out of swap (mlock) |
โ | โ | manual | manual | โ automatic |
Page inaccessible between uses (mprotect) |
โ | โ | manual | manual | โ automatic per-access |
| Core-dump exclusion | โ | โ | via its mlock (Linux) |
โ | โ automatic (Linux) |
| Secret bytes kept off the global heap | โ | โ | your responsibility | your responsibility | โ
by construction (Secret<N>) |
Safe API (no unsafe for the caller) |
โ | โ | โ raw primitives | โ | โ |
| Source returned on constructor failure | โ | โ | โ | โ | โ |
These combine well rather than compete. zeroize and secrecy are the right tools for secrets that have to live in ordinary structs. memsec and region are the right tools if you want to build your own container. memsafe is for when you want the container already built.
Performance
Protection isn't free, and it's better to know the cost up front than to discover it in a profile. Construction does about five syscalls (mmap, mlock, madvise twice on Linux, mprotect), and every guard cycle is two mprotect calls. Measured with the criterion benches in benches/ on an Apple Silicon macOS machine, release build:
| Operation | with memsafe | plain heap (Vec<u8>) |
|---|---|---|
| Create + drop a 64 B secret | ~2.8 ยตs | ~9.5 ns |
| Create + drop a 4 KiB secret | ~3.8 ยตs | โ |
Ingest an owned Vec (from_bytes, 64 B) |
~2.9 ยตs | โ |
| Read access (unseal, read, reseal) | ~1.1 ยตs | ~0.35 ns |
| Write access (unseal, write, reseal) | ~1.1 ยตs | โ |
So a protected secret costs roughly 300ร a plain allocation to create, and each access costs about a microsecond. Two things follow from that:
- For credentials, keys, and tokens this is noise. An API key read a few times per process lifetime costs single-digit microseconds in total.
- Don't put a per-request hot loop behind a guard cycle. If you need the secret thousands of times per second, take one guard and do the batch inside it (the page stays readable for exactly that scope), rather than opening and dropping a guard per operation.
Also note the memory cost: each secret occupies at least one OS page (typically 4 KiB) and counts against the locked-memory limit (RLIMIT_MEMLOCK), so size is effectively free up to a page but the number of live secrets is not unbounded.
Absolute numbers are dominated by syscall cost and will differ on your OS and hardware โ cargo bench reproduces the table for your machine.
Threat model
We list this explicitly because vague "secure" claims are how secrets get leaked.
memsafe is designed to defend against:
- Secrets being written to disk via swap (mitigated by
mlock/VirtualLock). - Secrets appearing in a Linux core dump (mitigated by
madvise(MADV_DONTDUMP)). - The Rust / system allocator handing freed-but-not-zeroed bytes to a later caller (mitigated by zero-on-drop and by never routing the secret through the global allocator).
- Accidental reads or writes while the buffer is logically inactive (mitigated by
mprotect/VirtualProtectbetween operations and, optionally, by the type-state API). - The "owned bytes" case: the secret being silently left on the regular heap after the source is dropped (mitigated by
Secret::from_bytes's explicit volatile zeroization of the source slice before drop). - The "wrong wrapper" case: a developer wrapping a
StringorVec<u8>inMemSafeand unknowingly leaving the payload on the unprotected heap (mitigated structurally bySecret<N>โ by construction it can only wrap[u8; N], where the secret lives inline). - A forked child inheriting a copy of the secret โ Linux only, via
madvise(MADV_WIPEONFORK): the kernel replaces the child's view of the page with zeros. On other Unixes the child still receives an unlocked copy-on-write copy; see below.
memsafe does NOT defend against:
- A process running with privilege equivalent to or greater than your own โ for example, a debugger attached via
ptrace, or root on the same machine. OS protection ends at the process boundary. - Hibernation (suspend-to-disk). The hibernation image contains all of physical RAM, including
mlock'd pages โmlockprevents swapping, not hibernation. If laptops are in your threat model, pair this crate with encrypted swap/hibernation or full-disk encryption. fork()inheritance on non-Linux Unixes. Outside Linux, a forked child receives a copy-on-write copy of the secret page without themlockโ an unlocked, swappable copy in a process that may not know it holds one. (On Linux the page is kernel-zeroed in the child viaMADV_WIPEONFORK.) On macOS and the BSDs, avoid fork-based worker models while secrets are live, or construct secrets after forking.- Side-channel attacks (cache timing, Spectre/Meltdown-class speculation, power analysis, etc.).
- Bytes left behind in CPU registers, vector lanes, or temporary spill slots after a
copy_from_slice. The protection is at the memory-page level, not the microarchitectural level. - Hardware attackers with physical bus access, cold-boot capability, or DMA to the host.
- Hardware/microarchitectural attacks against zeroization itself: bytes evicted into cache lines that get speculatively read out, RowHammer, and similar. The crate's wipes are language-level (volatile + fence); the silicon is not in scope.
If your threat model includes any of the items in the second list, memsafe is a useful layer of defense in depth but is not sufficient on its own.
Platform-specific notes
mlocklimits. On Linux, unprivileged processes are subject toRLIMIT_MEMLOCK(commonly 64 KiB by default). EachMemSafeallocation is rounded up to a full page (typically 4 KiB), so a handful of secrets fit easily inside the default limit; allocating thousands may require raising the limit. macOS has its own per-process locked-memory caps.- Page rounding.
mmapandVirtualAllocallocate in page-sized units (typically 4 KiB). AMemSafe<[u8; 32]>therefore consumes a full page; the trailing bytes of the page are part of the same locked, access-controlled, zero-on-drop region. - Linux-only protections.
madvise(MADV_DONTDUMP)is Linux-only. macOS, the BSDs, and Windows do not currently have an equivalent in this crate; on those systems, the page may still appear in process dumps. Windows core-dump exclusion is on the Roadmap. - Windows access model. Windows lacks an exact
PROT_NONEanalogue in this crate's model โ the lowest-privilege state isPAGE_READONLY. This means reads through the protected handle are always possible on Windows; only writes are guarded behindwrite()/read_write(). Unix systems usePROT_NONEbetween operations, blocking both reads and writes. - Cross-platform by construction.
libcis pulled in only oncfg(unix)andwinapionly oncfg(windows); the correct backend is selected automatically โ consumers never pick a platform feature flag. CI covers Linux (x86_64, aarch64, i686, armv7, ppc64le, riscv64), macOS (x86_64, aarch64), and Windows (x86_64, i686), with both default andtype-statefeature variants.
Under the hood
The implementation behind each guarantee, for readers who want the details:
-
Allocation and locking. Each instance allocates one or more pages via
mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)on Unix orVirtualAlloc(NULL, len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE)on Windows. The page is then pinned withmlock/VirtualLockso the kernel cannot page it out to swap. (Note:mlockdoes not exclude the page from a hibernation image โ see the threat model.) On Linux,madvise(MADV_DONTDUMP)additionally tells the kernel to omit the page from any core dump produced for the process. -
Access transitions. All access to the backing page goes through the internal
Cellabstraction, which callsmprotect(Unix) orVirtualProtect(Windows) to elevate the page for the duration of a read or write. As soon as the temporary access guard is dropped, the page is restored to its lowest-privilege state (PROT_NONEon Unix,PAGE_READONLYon Windows). Thetype-statefeature lifts this state machine into the type system. -
In-place initialization avoids a stack copy.
Secret::<N>::new_with(|buf| ...)allocates the protected page first, then hands the closure a&mut [u8; N]that points inside the locked region. The secret bytes are written directly into protected memory; they never live in a stack-allocated[u8; N]that the compiler would otherwise leave behind in the calling frame. -
Panic-safe construction. Construction is guarded by an internal RAII rollback guard: if any setup step fails, or a caller-provided
new_withclosure panics after writing partial secret bytes, unwinding volatile-zeroes the page, unlocks it, and unmaps it before the error or panic propagates. -
Source zeroization for
Secret::from_bytes. After copying the source bytes into the protected page,from_byteswalks the source slice (everythingAsMut::as_mutexposes) and overwrites every byte withptr::write_volatile(0), followed by acompiler_fence(Ordering::SeqCst). Volatile stores are guaranteed side effects the compiler may not elide, and the fence prevents the wipe from being reordered past later code. On a memory-protection error the zeroization has already run; on a length-mismatch error the source is returned untouched so the caller can decide what to do with it. -
Drop-time zeroization is volatile and fenced. The
Dropimplementation re-elevates the page to read/write, runsdrop_in_placeon the contents, overwrites the page byte-by-byte withwrite_volatile, emits acompiler_fence(SeqCst), releases the lock withmunlock/VirtualUnlock, and finally returns the page to the OS withmunmap/VirtualFree. The volatile + fence guarantee is language-level โ independent of whether the following syscall acts as an opaque barrier. -
Heap-pointer footgun is structurally prevented.
Secret<N>is a newtype overMemSafe<[u8; N]>. Itsnew_with,from_bytes,TryFrom<&str>, andTryFrom<String>constructors all produce inline byte storage; there is no way to instantiate aSecretwhose payload lives behind an indirection.
Roadmap
Planned hardening work, roughly in priority order:
fork()hygiene beyond Linux. Linux already getsmadvise(MADV_WIPEONFORK); addminherit(INHERIT_ZERO)on the BSDs where available.- Closure-based access API. Replace
read()/write()guards withread(|r| ...)/write(|w| ...)closures, so the page is provably returned to its lowest-privilege state when the closure returns. - Guard pages. Allocate adjacent inaccessible pages around the protected region to turn over- and under-runs into immediate faults.
- Windows core-dump exclusion. Mark protected pages so they are omitted from minidumps.
- In-memory encryption at rest. Encrypt the page contents while the buffer is in its lowest-privilege state, decrypting only inside
read()/write(). Defense in depth against attackers who can read the process's memory. - Anti-debugging hooks. Detect or refuse
ptrace/ debugger attachment. - Side-channel mitigation. Constant-time read/write paths to resist timing and speculative-execution attacks.
- Custom allocator. Randomized placement and integrity checks to obscure memory locations.
See the Milestones for live status.
Security notice
memsafe is designed against the threat model documented above and has not undergone a formal third-party security audit. We recommend reviewing the Threat model section against your own requirements before relying on this crate for sensitive data. Bug reports and security findings are very welcome โ please open an issue or a PR.
Contributing
Issues and pull requests are welcome at github.com/po0uyan/memsafe โ enhancements, security fixes, and anything that pushes the crate toward the Roadmap.
cargo test # default features
cargo test --features type-state # compile-time state machine
cargo clippy --all-targets # lints
License
Licensed under the MIT License. See LICENSE for details.