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.
| Name | Contents |
|---|---|
secret | The Secret module and the installed secret.h header. |
secret.unix | Secret_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.
| Call | Source of the bytes |
|---|---|
Secret.random n | OS entropy, written straight into the payload. |
Secret.init n f | A KDF, PRNG or decoder filling a scratch buffer that is copied in and then wiped. |
Secret.Unsafe.init n f | The same, but the producer writes into the secret memory itself. |
Secret_unix.read_file path | A file, read with read(2) into the payload. |
Secret.of_bytes ~wipe_source:true b | An existing buffer, zeroized afterwards. |
Secret.of_string s | An 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.