Usage
Every entry point returns a result. The examples below use
the binding operator to keep that plumbing out of the way:
open Hpke
let ( let* ) = Result.bind
Install
opam install hpke
opam-repository has carried the package since 0.1.1. It takes a new release only after review, so it can trail the newest tag. To build against 0.2.0 before it lands there, pin the tagged source:
opam pin add hpke \
git+https://github.com/thevilledev/ocaml-hpke.git#v0.2.0
Then depend on the library from dune:
(executable
(name main)
(libraries hpke mirage-crypto-rng.unix))
OCaml 4.14+ and dune 3.12+ are required. The package pulls in Mirage
Crypto, curve448, Digestif, kdf, and
eqaf; it contains no primitive implementations of its own.
X448 comes from curve448, which implements its arithmetic
twice and lets the final executable choose. Depending on
hpke alone links the pure OCaml implementation; add
curve448.c to the executable's libraries for the C one,
which is two to three times faster. Both behave identically.
curve448 needs a 64-bit OCaml, so 32-bit architectures are
not supported.
Randomness
Randomness is an explicit argument. Seed a generator once at startup and pass it to every operation that needs one:
Mirage_crypto_rng_unix.use_default ();
let rng = Mirage_crypto_rng.default_generator () in
...
Only key generation and the sender side — context setup and the single-shot
seals — take ~rng. Receiving is deterministic, and so is derive_key_pair — which is the
point of it. A deterministic or test generator must never reach
production sealing: see
operational cautions.
Keys
Generate a pair, or derive one deterministically from input keying material:
let* private_key, public_key =
generate_key_pair ~rng Kem.X25519
in
let* fixed_private, fixed_public =
derive_key_pair Kem.P256 ~ikm
in
...
Parse a peer's key from the wire, and serialize your own:
let* peer = Public_key.of_bytes ~kem:Kem.X25519 encoded in
let bytes = Public_key.to_bytes peer in
...
Keys are abstract and tagged with their KEM, so a P-256 key cannot be
handed to an X25519 suite by accident; the mismatch is reported as
Key_mismatch rather than producing a wrong answer. Parsing
enforces the exact encoded length, uncompressed SEC1 form for the NIST
curves, and a scalar in range for private keys. X25519 private keys are
clamped on parse and on serialization; low-order X25519 public values
are rejected when the key is used.
One message
seal_base establishes a context, seals a single message,
and discards the context:
let suite =
Suite.create ~kem:Kem.X25519 ~kdf:Kdf.Hkdf_sha256
~aead:Aead.Chacha20_poly1305
let send ~rng ~recipient ~plaintext =
Rfc9180.seal_base ~rng suite ~recipient ~info:"application-v1"
~aad:"message-metadata" ~plaintext
let receive ~recipient ~ciphertext =
Rfc9180.open_base suite ~recipient ~info:"application-v1"
~aad:"message-metadata" ~ciphertext
info binds the context to an application role or version;
aad binds one message to its metadata. Both must match
exactly on both sides, and neither is transmitted by the library.
The success value is a record with two separate fields.
encapsulated_key is the KEM output the recipient needs to
decapsulate; ciphertext is the sealed message:
let* { Rfc9180.encapsulated_key; ciphertext } =
send ~rng ~recipient ~plaintext
in
...
That record is not a wire encoding. Applications frame the two strings
themselves — and should bind the framing to their protocol context
through info and aad rather than trusting
length prefixes alone.
Pre-shared keys
PSK mode mixes an out-of-band secret into the key schedule, so a recipient private key alone is not enough to open the message:
let* psk = Psk.create ~secret:tenant_secret ~id:"tenant-42" in
let* sealed =
Rfc9180.seal_psk ~rng suite ~recipient:recipient_public ~psk
~info:"application-v1" ~aad:"message-metadata"
~plaintext:"secret payload"
in
Rfc9180.open_psk suite ~recipient:recipient_private ~psk
~info:"application-v1" ~aad:"message-metadata"
~ciphertext:sealed
Psk.create rejects secrets shorter than 32 bytes and empty
identifiers. That is a length check only: it cannot establish that a
secret has adequate entropy, so derive PSKs from a real key-agreement or
key-derivation step rather than from a passphrase.
Authenticated senders
Auth mode also proves to the recipient that the message came from the
holder of a static private key. The sender passes its own private key
as ~sender, and the recipient passes the public half of
that key:
let* sealed =
Rfc9180.seal_auth ~rng suite ~recipient:recipient_public
~sender:sender_private ~info:"application-v1"
~aad:"message-metadata" ~plaintext:"secret payload"
in
Rfc9180.open_auth suite ~recipient:recipient_private
~sender:sender_public ~info:"application-v1"
~aad:"message-metadata" ~ciphertext:sealed
A message from any other sender fails to open, with the same
Open_error as a tampered one. AuthPSK mode adds a
~psk through seal_auth_psk and
open_auth_psk. For a stream of messages from one sender,
setup_auth_sender and setup_auth_receiver, or
their AuthPSK counterparts, set up contexts as in
many messages. Both keys must belong to the
suite's KEM; either of another KEM is a Key_mismatch.
This is not a signature. Whoever holds the recipient's private key, and
in AuthPSK mode the PSK as well, can seal a message that opens as coming
from any sender. For the same reason the recipient cannot prove to
anyone else who sent a message. Where either matters, also sign the
encapsulated key and ciphertext; the
security model has more. The
successor draft of HPKE removes both modes, so they exist only under
Hpke.Rfc9180.
Many messages
When one encapsulation should carry a stream of messages, set up the context yourself. The sender gets both the encapsulated key to send and the context to keep:
let send_all ~rng ~recipient messages =
let* { Rfc9180.encapsulated_key; context } =
Rfc9180.setup_base_sender ~rng suite ~recipient
~info:"stream-v1"
in
let rec seal_each sealed = function
| [] -> Ok (encapsulated_key, List.rev sealed)
| plaintext :: rest ->
let* ciphertext =
Rfc9180.Sender.seal context ~aad:"" ~plaintext
in
seal_each (ciphertext :: sealed) rest
in
seal_each [] messages
let receive_all ~recipient ~encapsulated_key ciphertexts =
let* context =
Rfc9180.setup_base_receiver suite ~recipient
~encapsulated_key ~info:"stream-v1"
in
let rec open_each opened = function
| [] -> Ok (List.rev opened)
| ciphertext :: rest ->
let* plaintext =
Rfc9180.Receiver.open_ context ~aad:"" ~ciphertext
in
open_each (plaintext :: opened) rest
in
open_each [] ciphertexts
| Roles are separate | A Sender.t only seals; a Receiver.t only opens. There is no type that does both. |
|---|---|
| Order is fixed | Messages must be opened in the order they were sealed. HPKE contexts do not recover from loss or reordering. |
| One nonce per success | A successful seal or open advances the sequence exactly once. |
| Failure holds still | A failed open does not advance the sequence, so a rejected message does not desynchronize the stream. |
| Aliases are not copies | Binding a context to a second name refers to the same mutable state. |
| Exhaustion is an error | When the 96-bit sequence is exhausted, further operations return Message_limit_reached instead of reusing a nonce. |
Contexts are single-threaded by design. If two domains or threads
attempt a state-changing operation on the same context, one of them
receives Concurrent_use before any cryptography is
performed. Do not blindly retry: the caller must decide which operation
owns the next sequence number, which is an application-level ordering
question the library cannot answer.
For protocol boundaries, prefer the single-shot functions. They
normalize peer-controlled decapsulation and authentication failures to a
single Open_error, while the context-level setup reports
malformed encapsulations structurally.
Secret export
Both context types export secrets for other protocols without touching message sequence state:
let* traffic_key =
Rfc9180.Sender.export context ~context:"traffic key" ~length:32
in
...
When a suite is only ever used this way, build it with
Suite.export_only. It carries no AEAD, and its capability
parameter means Sender.seal will not accept it — the
program does not compile rather than failing at run time:
let exporter =
Suite.export_only ~kem:Kem.X25519 ~kdf:Kdf.Hkdf_sha256
let derive ~rng ~recipient =
let* { Rfc9180.encapsulated_key; context } =
Rfc9180.setup_base_sender ~rng exporter ~recipient
~info:"exporter-v1"
in
let* key =
Rfc9180.Sender.export context ~context:"client write"
~length:32
in
Ok (encapsulated_key, key)
Export lengths run from 0 to 255 times the KDF hash size; outside that
range the call returns Export_length_out_of_range. Exact
limits per KDF are on the suites page.
Layered protocols
Protocols built on HPKE often derive further keys from an exported secret using the suite's own KDF and AEAD. Oblivious HTTP (RFC 9458) encrypts its response this way. The suite's unlabeled primitives and sizes are exposed for that purpose, so a consumer does not repeat the identifier-to-algorithm dispatch:
let* secret =
Rfc9180.Sender.export context ~context:"response"
~length:(max (Aead.nonce_size aead) (Aead.key_size aead))
in
let prk = Kdf.extract kdf ~salt secret in
let* key = Kdf.expand kdf ~prk ~info:"key" (Aead.key_size aead) in
let* nonce = Kdf.expand kdf ~prk ~info:"nonce" (Aead.nonce_size aead) in
let* key = Aead.key aead key in
Aead.seal key ~nonce ~aad:"" ~plaintext
Kdf.extract and Kdf.expand are plain RFC 5869
HKDF, without the labels HPKE's own key schedule adds.
Aead.key prepares a key, and Aead.seal and
Aead.open_ use it with an explicit nonce, so unlike a
context they cannot stop a nonce from being used twice: that is the
caller's responsibility. Preparing an AES-GCM key derives its GHASH
tables, which without hardware support costs more than sealing several
kilobytes, so a key that seals many messages should be prepared once.
A key, nonce, pseudorandom key, or output length of the wrong size is
reported as Invalid_length.
Errors
Error.t is a closed variant, and Error.pp
prints a class of failure. Error values never contain key material.
| Constructor | Returned when |
|---|---|
Unsupported_algorithm | An integer codepoint outside the closed registry reached of_int. |
Invalid_public_key | Wrong encoded length, a non-uncompressed SEC1 point, or a point off the curve. When a recipient key is used at sender setup, or a sender key at Auth or AuthPSK receiver setup, also an X25519 or X448 low-order value. |
Invalid_private_key | Wrong encoded length, or a scalar outside the valid range. |
Invalid_encapsulation | A malformed encapsulated key at context-level receiver setup. |
Key_mismatch | A recipient or sender key's KEM differs from the suite's KEM. |
Derive_key_pair_failure | Rejection sampling did not find a valid scalar for a NIST curve. |
Invalid_psk | A secret shorter than 32 bytes, or an empty identifier. |
Invalid_length | Deterministic derivation was given input a primitive refused by length, or an unlabeled KDF or single-shot AEAD call was given a key, nonce, or length of the wrong size. |
Message_limit_reached | The context's 96-bit sequence counter is exhausted. |
Plaintext_too_long | The plaintext exceeds the AEAD's limit. |
Export_length_out_of_range | An export length is negative or above 255 times the hash size. |
Concurrent_use | Another domain or thread held the context. No cryptography was performed by this call. |
Open_error | A single-shot open failed. Decapsulation and authentication failures are deliberately indistinguishable. |
Internal_error | A primitive failed unexpectedly. |
Do not translate these into distinguishable protocol responses. Doing so
rebuilds the oracle that Open_error exists to remove; the
security model covers the reasoning.