Guarantees and limits

A library like this is only worth using if it is precise about where it stops. This page states the guarantees, the things that look like guarantees but are best effort, and the things that are outside its reach entirely.

Experimental and unaudited. This is a pre-1.0 implementation. Treat every hardening feature as defense in depth, not as a replacement for process isolation or hardware-backed key storage.

Why the heap is the problem

A key in an OCaml bytes is not in one place. The minor collector promotes a surviving block by copying it into the major heap and leaving the original bytes behind in the minor heap; Gc.compact moves major-heap blocks the same way. Neither erases the source. By the time you call Bytes.fill you are erasing the newest copy, not the ones the runtime left in its wake.

A Secret.t is a small custom block in the heap that owns a payload allocated in C memory. The handle may be copied and moved freely; the payload never is. There is exactly one place to erase, and the library erases it with a primitive the C compiler is not allowed to optimise away.

What is guaranteed

The payload is never copied by the GC.
It lives outside the heap for the whole life of the value. Promotion and compaction move the handle, not the bytes.
It is zeroized before its memory can be reused.
On destroy, on finalization of the handle, and on wipe_all at normal exit. The zeroization uses memset_explicit, explicit_bzero, explicit_memset, memset_s or SecureZeroMemory, whichever the platform probe finds, falling back to a volatile function pointer behind a barrier. The choice is compiled into its own translation unit so that only link-time optimisation could see across the call, and (Secret.capabilities ()).zeroize_primitive names it.
Comparison is constant time.
equal and equal_string compare the contents in C in time proportional to the length and independent of the values. Lengths are compared first with an ordinary branch: the length is not treated as secret. There is deliberately no compare and no hash.
The contents cannot leak by accident.
Polymorphic compare and Marshal raise Invalid_argument; Hashtbl.hash ignores the payload; pp prints <secret:32B>. Secret bytes reach OCaml values only through documented scratch-buffer callbacks and functions whose names contain expose, unsafe or view.
Use after destruction raises.
Every accessor through the owner raises Secret.Destroyed instead of returning stale bytes. destroy is idempotent, and length and status keep working. Unsafe views have their own lifetime rules below.

What is not guaranteed

None of the following is a bug in the library. They are the boundary of what any userspace library can do.

Use an HSM or a KMS when the threat model requires that the key never be readable by the process at all. This library narrows the window and the number of copies; it does not move the key out of your address space.

Where the wipe happens, and where it does not

Where the zeroization happens
Path outPayload zeroized
Secret.destroyYes, immediately.
Handle becomes unreachableYes, when the finalizer runs.
Secret.wipe_allYes, every live secret in the process.
Normal exit, exit n, uncaught exceptionYes, through at_exit.
Exit from a spawned domainYes.
fork child under `Wipe_in_childYes, in the child.
Unix._exitNo. No handler runs.
Fatal signalNo. No safe library callback is possible.
Runtime fatal errorNo.

Every row above the last two is asserted by test_atexit, test_gc or test_fork, mostly by running a child process and inspecting how it died. The two that cannot be asserted are the two that do not happen.

wipe_all is registered with Stdlib.at_exit when the module is initialised, which is before any handler registered by code that uses it. Handlers therefore run first and still see live secrets.

An explicit wipe_all requires a quiescent process: join or stop worker domains and finish blocking Secret_unix I/O first. Concurrent reads of a live secret are supported, but all mutation, destruction, and process-wide wiping require caller synchronization. Concurrent repeated calls to destroy are the exception and remain safe.

Two tiers

What each tier gives a single value
Property Default Hardened
Backing memorycallocPrivate mmap of its own
Out of the OCaml heapYesYes
Zeroized on releaseYesYes
Guard pages either sideYes
Canary before the block headerYes, checked on release
Locked into RAMBest effort, reported
Excluded from core dumpsBest effort, reported
Address space per secretPayload plus 16 bytesAt least three pages
Cost~75 ns~1.6 µs

Both tiers may pool released unviewed payload blocks by size class and reuse them only after zeroization. Any allocation that has produced an unscoped view is zeroized and permanently parked instead: it is never reused or unmapped. This prevents cross-secret disclosure at the cost of process-lifetime, potentially unbounded memory retention. Unviewed blocks above the pool's size limit go back to the OS.

~hardened:true is a request. Where the platform has no mmap — Windows, or a freestanding target — the allocation falls back to the default tier and status.page_backed is false.

Nothing is silent

Every hardening feature reports its outcome per value. Secret.status never raises and works after destroy.

Secret.status
FieldMeaning
page_backedThe hardened tier was actually obtained.
guard_pagesInaccessible pages sit on both sides of the payload.
canaryA canary precedes the header; corruption aborts on release.
lockThe outcome of mlock; see below.
no_core_dump`Yes, `Unsupported, or `Not_requested.
wipe_on_forkMADV_WIPEONFORK is in effect for this mapping.
viewedAn unscoped view was handed out at some point.
destroyedThe payload has been zeroized and released, pooled, or permanently parked.
The lock variant
ValueMeaning
`LockedThe pages are locked in RAM.
`Failed errnoENOMEM means RLIMIT_MEMLOCK was reached; EPERM means the process lacks IPC_LOCK.
`Lost_on_forkLocked before a fork. Locks are not inherited; call Secret.after_fork.
`UnsupportedThe platform cannot lock pages.
`Not_requestedThe secret is not hardened.

Reporting is the default, but it is not the only option. require_hardening takes a list of `Page_backed, `Guard_pages, `Canary, `Locked, `No_core_dump and `Wipe_on_fork, and destroys the secret before raising Hardening_unavailable with the first requirement it could not meet. A key that did not get the protection it asked for never reaches the caller.

Secret.capabilities () answers the same questions for the build and the platform as a whole, before any secret exists. It also reports the page size and the name of the zeroization primitive that was compiled in.

View lifetimes

A view is an ordinary OCaml string or bytes whose block header was written in front of the secret memory — a representation the runtime already supports and uses for static data. That is what lets an unmodified string-based API, including a C stub using String_val, read secret memory without a copy.

The rules that come with it:

Prefer scoped views. Their owner remains reachable throughout the callback, including when the callback raises or forces collection. A scoped view that escapes the callback is still a programming error: its unmarked storage may later be released or reused.

View access must obey the same concurrency contract: concurrent reads are supported, while mutation or destruction requires caller synchronization. No scoped or unscoped view may be in use during wipe_all.

On OCaml 4.14 the runtime classifies out-of-heap blocks through the page table, so polymorphic compare, =, Hashtbl.hash and Marshal treat a view as a foreign pointer: comparison is by address and marshalling fails. String.equal, every String and Bytes function, and C stubs using String_val behave identically on 4.14 and supported 5.0–5.5 releases. Use Secret.equal for contents, which is constant time on every supported compiler.

Fork

A forked child inherits copies of every secret, and memory locks are never inherited. The policy is explicit.

Secret.set_fork_policy
PolicyEffect in the child
`Keep (default) Secrets are inherited. Locks are lost, and status.lock becomes `Lost_on_fork until Secret.after_fork re-establishes them.
`Wipe_in_child An atfork handler zeroizes every secret in the child. On Linux, live and subsequently created hardened secrets also get MADV_WIPEONFORK, so the kernel gives the child zero pages instead. Switching back to `Keep revokes that advice.

Platform matrix

Features are probed by a compile-and-link test at build time. A probe that cannot run reports the feature as unavailable and the C code takes a portable path, so a missing feature is never a build failure.

Availability by platform
Feature Linux macOS BSD Windows
Out-of-heap payload, zeroizationyesyesyesyes
Constant-time equalyesyesyesyes
OS entropy for randomgetrandomgetentropygetentropy, arc4random_bufBCryptGenRandom
Guard pages and canaryyesyesyes
Page lockingmlockmlockmlock
Core-dump exclusionMADV_DONTDUMPMADV_NOCORE, MAP_CONCEAL
Wipe-on-fork adviceMADV_WIPEONFORK
atfork wipe policyyesyesyes
Debugger denial in Process.hardenPR_SET_DUMPABLEPT_DENY_ATTACH
Secret_unix descriptor I/Oyesyesyesnot built

On a freestanding target — solo5, and so MirageOS — the payload, its zeroization and constant-time equality remain, and everything below them in the table is gone: no OS entropy, no page-backed tier, no fork policy. secret_platform.h forces that profile from the target compiler's own macros, so a build cannot switch on an OS feature that is not there.

Where no OS entropy source exists, Secret.random raises Entropy_unavailable until a generator is installed with Secret.set_entropy_source; the callback fills a scratch buffer that is wiped afterwards. A feature being compiled in does not mean it will succeed at run time — mlock is bounded by RLIMIT_MEMLOCK on every platform. That is what status is for.

The numbers behind the timing and leak claims are on the benchmarks page.