Usage

The rule that shapes every API here: a secret should be born in secret memory, be read without leaving it, and be destroyed as soon as it is no longer needed. Each section below is one step of that lifecycle.

Install

The library is not in the opam repository yet. Pin it:

opam pin add secret https://github.com/thevilledev/ocaml-secret.git

OCaml 4.14 or a 5.0–5.5 release is required. There are no runtime dependencies; dune-configurator probes the platform at build time and the C stubs fall back to portable code when a feature is missing.

Libraries
NameContents
secretThe Secret module and the installed secret.h header.
secret.unixSecret_unix: read(2) and write(2) directly on secret memory. Not built on Windows.

Create a secret

Prefer a constructor that gets the bytes into secret memory without an intermediate OCaml string. Every one of these does, except the last.

Constructors
CallSource of the bytes
Secret.random nOS entropy, written straight into the payload.
Secret.init n fA KDF, PRNG or decoder filling a scratch buffer that is copied in and then wiped.
Secret.Unsafe.init n fThe same, but the producer writes into the secret memory itself.
Secret_unix.read_file pathA file, read with read(2) into the payload.
Secret.of_bytes ~wipe_source:true bAn existing buffer, zeroized afterwards.
Secret.of_string sAn OCaml string, which cannot be wiped. Last resort.
let key = Secret.random 32

(* or hand a producer a buffer to fill *)
let key =
  Secret.init 32 (fun buf ->
      Mirage_crypto_rng.generate_into buf (Bytes.length buf))

init passes a temporary scratch buffer, copies it into the secret, and wipes it when the callback returns or raises; a callback that retains the buffer sees only zeros afterwards. Reach for Secret.Unsafe.init only when the producer must write into the payload with no intermediate copy at all — it hands out a mutable view of the secret memory, with the lifetime rules that come with any view.

of_string is honest about its cost: the argument stays in the OCaml heap until it is collected, and nothing in this library can reach it. Use it only when the bytes already arrived as a string.

Destroy it

Secret.destroy zeroizes the contents immediately and releases or pools unviewed memory. Storage that has produced an unscoped view is permanently parked instead. Destruction is idempotent, and every accessor through the owner afterwards raises Secret.Destroyed.

Secret.destroy key

Use Secret.with_secret when the lifetime is a scope; it destroys the secret whether the body returns or raises. Secret.with_random is the same thing for a freshly generated key, and is the shortest correct way to use one:

let aes =
  Secret.with_random 32 (fun key ->
    Secret.Unsafe.with_string_view key Mirage_crypto.AES.GCM.of_secret)

with_secret is the same scope when the bytes come from elsewhere:

Secret.with_secret 32 (fun k ->
    Secret_unix.read_exactly fd k ~off:0 ~len:32;
    authenticate k)

Collection and normal process exit are backstops, not the plan. Secret.wipe_all is registered with Stdlib.at_exit at module initialisation, so it runs after every handler registered by code that uses the library. It does not run on Unix._exit, on a signal, or after a runtime fatal error. Do not call it directly from an asynchronous signal handler; arrange an orderly shutdown on a normal execution path instead.

Concurrent reads are supported. Synchronize every mutation and destruction with all other accesses, and quiesce worker domains and blocking I/O before an explicit wipe_all. Concurrent repeated destruction is safe.

Use the bytes

Three ways to get at the contents, in order of how much they cost you.

Zero-copy views

A view is an ordinary OCaml string or bytes whose block header sits in front of the secret memory. Any existing API that takes a string — including C stubs using String_val — works on it without a copy.

let view = Secret.Unsafe.string_view key in
let aes = Mirage_crypto.AES.GCM.of_secret view

Scoped variants make the lifetime explicit and are the better default:

Secret.Unsafe.with_string_view key (fun s ->
    Digestif.SHA256.hmac_string ~key:s msg)

Secret.Unsafe.with_bigstring key (fun ba ->
    Digestif.SHA256.digest_bigstring ba)

A view is valid only while its owner is alive. Keep the owner reachable for as long as any unscoped view exists, do not let a scoped view escape its callback, and remember that String.sub, ^, compare and Marshal copy a view like any other string. The rules are spelled out under view lifetimes.

Unscoped-view storage is zeroized and permanently retained after destruction, so it can never expose a later secret. This retention is potentially unbounded; prefer scoped views.

Temporary copies

Secret.expose hands the callback a copy in a scratch buffer allocated directly in the major heap, so the minor collector never duplicates it, and wipes that buffer when the callback returns or raises. Major-heap compaction can move the buffer and leave a historical copy that the later wipe cannot reach, so this is best-effort hygiene.

let digest =
  Secret.expose key (fun buf -> Digestif.SHA256.digest_bytes buf)

Use it when the callee needs a buffer it may mutate, or when there is no view-shaped entry point. Whatever the callback does with the bytes — Bytes.to_string, Buffer.add_bytes, passing them to a string-keyed API — makes copies this library cannot wipe.

Permanent copies

Secret.unsafe_to_string returns a fresh immutable string that lives in the OCaml heap until it is collected and can never be wiped. It exists for legacy APIs that retain their key argument. The name is the warning.

Compare in constant time

There is deliberately no compare and no hash. Contents are compared in time that depends only on the length.

if Secret.equal_string expected_mac received then accept ()

Lengths are compared first with an ordinary branch, so the length is not treated as secret. Polymorphic compare and Marshal on a Secret.t raise Invalid_argument; pp prints <secret:32B>.

Harden the long-lived keys

Pass ~hardened:true to any constructor to put the payload in its own mapping, between guard pages, behind a canary, locked into RAM and excluded from core dumps.

let root = Secret.random ~hardened:true 32

Every one of those is best effort and depends on the OS and on resource limits. Hardening is disabled by default. Nothing is assumed: request it per value and ask what the value actually received.

match (Secret.status root).lock with
| `Locked -> ()
| `Failed errno -> Printf.eprintf "mlock failed with errno %d\n" errno
| `Lost_on_fork -> Secret.after_fork ()
| `Unsupported | `Not_requested -> ()

When a missing protection should stop the program rather than warn it, require_hardening checks the list and destroys the secret before raising Hardening_unavailable, so a half-protected key can never escape into the rest of the program:

let root =
  Secret.random ~hardened:true 32
  |> Secret.require_hardening [ `Page_backed; `Locked ]

The requirements are `Page_backed, `Guard_pages, `Canary, `Locked, `No_core_dump and `Wipe_on_fork. The exception carries the first one that was not met.

Secret.capabilities () answers the same question for the build and platform as a whole, before any secret exists. The full meaning of each field is in status reporting.

Process-wide hardening is a separate, explicit step:

let outcomes = Secret.Process.harden ()

By default that disables core dumps, marks the process non-dumpable on Linux, and denies debugger attachment on macOS. It returns what happened for each feature rather than raising. Secret.Process.scrub_env zeroizes and removes a variable from the process environment block after you have read it. Apply these process-global controls during single-threaded startup; environment scrubbing must not race another environment access.

Fork and exit

A forked child inherits copies of every secret, and memory locks are not inherited. Choose a policy before starting worker domains or forking:

Secret.set_fork_policy `Wipe_in_child

With `Wipe_in_child an atfork handler zeroizes every secret in the child, and on Linux hardened mappings also get MADV_WIPEONFORK. With the default `Keep, call Secret.after_fork () in the child to re-establish mlock.

Read a secret from a descriptor

Unix.read copies through a 64 KiB buffer on the C stack and In_channel through a channel buffer in the heap; neither copy is ever zeroized. Secret_unix calls read(2) and write(2) on the secret memory itself.

let key = Secret_unix.read_file ~hardened:true "/run/secrets/api-key"
let key = Secret.create 32 in
Secret_unix.read_exactly fd key ~off:0 ~len:32

These read straight into the payload even while blocked. The caller must not mutate or destroy the secret, or call Secret.wipe_all, until the blocking operation returns.

The kernel page cache still holds its own copy of any file that was read. That is outside this library's reach.

Accept a secret from C

The installed secret.h lets a C stub take a Secret.t directly, or serve both a string and a secret from one entry point.

#include <secret.h>

const unsigned char *p;
size_t len;
if (secret_borrow_string_or_secret(v, &p, &len) != SECRET_OK)
  caml_invalid_argument("destroyed secret");

The pointer is stable while the value is reachable and undestroyed. Never hold it across an OCaml allocation that could make the value unreachable.

A stub that releases the runtime lock around a blocking write should finish with secret_rewipe_if_destroyed as defense in depth. This check does not make overlapping destruction supported: keep the value rooted and synchronize mutation and destruction.

Next: what the library guarantees, and the measurements behind those claims.