Key material in buffers the runtime will not copy, erased on demand
README.md

secret #

Key material in a buffer the runtime will not copy, erased when you say so.

You can only scrub a secret if you can reach every copy of it, and OCaml does not make that easy. When the collector promotes a small buffer out of the minor heap it copies it. Overwriting the promoted buffer then leaves the old one behind, where nothing can reach it to erase it.

So a Secret.t owns a whole byte buffer of at least 2,048 bytes: 257 words on a 64-bit host, counting the padding word. That is over the 256-word limit of the minor heap (Max_young_wosize) and over the 128-word largest size class of the OCaml 5 major-heap pools, so the runtime allocates it by itself, straight in the major heap. Nothing moves it after that, not promotion and not the compactor, even under an explicit Gc.compact. Overwrite it and the key is gone.

Buffers are never shared and never reused. A stale alias can only ever read its own storage, already wiped. Destroying a secret is one atomic state change; the wipe and release happen once, when the last active borrow exits. After fork, the child's handles are refused by every accessor.

Installation #

opam install secret

If opam cannot find the package, it may not yet be released in the public opam-repository. Add the overlay repository, then install it:

opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
opam update
opam install secret

Usage #

# let key = Secret.v 32;;
val key : Secret.t = <abstr>
# Secret.fill key 'k';;
- : unit = ()
# Secret.length key;;
- : int = 32
# Secret.destroy key;;
- : unit = ()
# Secret.is_zeroed key;;
- : bool = true

Material only moves inwards. No operation copies a secret back out, since nobody could wipe that copy afterwards. To compare two secrets use equal, and to duplicate one use copy; both keep the bytes inside the library:

# let k = Secret.of_bytes (Bytes.of_string "sixteen byte key");;
val k : Secret.t = <abstr>
# let c = Secret.copy k;;
val c : Secret.t = <abstr>
# Secret.equal k c;;
- : bool = true
# Secret.destroy k; Secret.destroy c;;
- : unit = ()

of_bytes takes ownership of its argument and wipes it. You will not find an of_string, because a string cannot be wiped.

Reading is a different matter. Even a bounds-checked read would return a char or fill a bytes, so material would leave the library through a function whose name suggests it is safe. The borrow value would also need a pointer to the buffer, which puts the key one field away from something the caller holds. So writes go in through the ordinary functions, and every read goes through Unsafe, where the call site shows it.

Some C primitives want an OCaml bytes or string and an offset. Secret.Unsafe hands over that buffer, offset and length without copying:

# let k = Secret.of_bytes (Bytes.of_string "sixteen byte key");;
val k : Secret.t = <abstr>
# Secret.Unsafe.with_string k (fun v ~off ~len -> String.sub v off len);;
- : string = "sixteen byte key"
# Secret.destroy k;;
- : unit = ()

The buffer holds this secret and nothing else, and off is always 0. The buffer can be longer than len because a secret is padded up to the size at which the runtime stops moving it, and that padding is zero and stays zero. Code that calls into Unsafe should be either a primitive that takes a buffer and an index or a serialiser writing a key out of the program. Stock OCaml has no borrow lifetimes, so nothing stops the callback from keeping the buffer. Unsafe is an audit marker in the spirit of a Rust unsafe block, and the compiler does not check it.

When a primitive needs to cache the buffer on purpose, it calls Secret.Unsafe.borrow. The caller keeps the resulting Lease.t next to its alias, and the buffer is not wiped until Lease.release. The atomic acquisition then happens once at setup, instead of on every call into the primitive. A secret destroyed while such a lease is outstanding still counts in Secret.live (), so a test that checks for leaked secrets also catches leaked leases.

Security #

What this library limits is what stays behind after a secret is spent, in memory and in anything that later copies memory. It does nothing about:

  • Copies made before the secret existed. Once key material has been an immutable string it has escaped; keep it from ever becoming one.
  • An attacker who can read the process's memory while it runs.
  • Register and stack copies made by a cryptographic primitive. Go's experimental runtime/secret.Do handles that for a call tree on some architectures; stock OCaml has no such runtime mode.
  • Swap. Run without swap, or with encrypted swap. mlock is rationed per process and runs out quickly with one key per connection.
  • The copy in a forked child. The kernel copied the address space, bytes included. The handles are revoked (every accessor raises, and is_inherited tells you why) but the copy is still there. Exec promptly, or destroy what you inherited.
  • Bit flips in key material at rest. On a satellite payload computer with no ECC memory, a single-event upset (a bit flipped by a charged particle) can corrupt a key between two ground contacts, and nothing here will notice. What you want is an authenticated integrity check over the material, redone on each use, and a re-unseal from the TPM or OTP-backed state when it fails. That sits above this allocator, with whatever owns the sealing.

Core dumps are a per-process setting. Call Secret.Process.refuse_core_dumps () once at startup in any daemon that holds keys: it sets RLIMIT_CORE to zero and, on Linux, clears PR_SET_DUMPABLE, which also keeps other processes from attaching or reading /proc/pid/mem.

  • libsodium has sodium_malloc and sodium_mlock, the reference design for guarded, locked memory for secrets. This library zeroes the same way but skips the locking, the guard pages and the canaries. It stays inside OCaml's allocator, so the crypto API never has to take a bigarray or a foreign buffer type.
  • secrecy and zeroize are the Rust layers for explicit exposure and for wiping that the compiler cannot optimise away. In Rust a borrow lifetime stops a reference from outliving its owner. OCaml has no such lifetimes, so this library revokes borrows at run time.
  • memguard does the same job in Go with locked, guarded buffers, and its documentation warns that the slices it returns must not escape.
  • runtime/secret is Go's experimental mode that erases registers, stack and heap for a call tree soon after it returns.

Licence #

ISC. See LICENSE.md.