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
Context rules
Roles are separateA Sender.t only seals; a Receiver.t only opens. There is no type that does both.
Order is fixedMessages must be opened in the order they were sealed. HPKE contexts do not recover from loss or reordering.
One nonce per successA successful seal or open advances the sequence exactly once.
Failure holds stillA failed open does not advance the sequence, so a rejected message does not desynchronize the stream.
Aliases are not copiesBinding a context to a second name refers to the same mutable state.
Exhaustion is an errorWhen 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.

Error constructors
ConstructorReturned when
Unsupported_algorithmAn integer codepoint outside the closed registry reached of_int.
Invalid_public_keyWrong 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_keyWrong encoded length, or a scalar outside the valid range.
Invalid_encapsulationA malformed encapsulated key at context-level receiver setup.
Key_mismatchA recipient or sender key's KEM differs from the suite's KEM.
Derive_key_pair_failureRejection sampling did not find a valid scalar for a NIST curve.
Invalid_pskA secret shorter than 32 bytes, or an empty identifier.
Invalid_lengthDeterministic 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_reachedThe context's 96-bit sequence counter is exhausted.
Plaintext_too_longThe plaintext exceeds the AEAD's limit.
Export_length_out_of_rangeAn export length is negative or above 255 times the hash size.
Concurrent_useAnother domain or thread held the context. No cryptography was performed by this call.
Open_errorA single-shot open failed. Decapsulation and authentication failures are deliberately indistinguishable.
Internal_errorA 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.