Usage

Start with one message. Use contexts when your protocol sends several messages in order. Fallible operations return ('a, Hpke.Error.t) result.

Install · Example · Keys · PSK · Auth · Contexts · Export · Errors

Install

Requires an initialized opam switch with 64-bit OCaml 4.14+ and Dune 3.12+.

opam install hpke

These docs cover 0.4.0. The basic example also works with 0.3.0. opam-repository takes a release only after review, so it can trail the newest tag. To use the hybrid KEMs and Draft_hpke_04 before 0.4.0 lands there, pin the tagged source:

opam pin add hpke git+https://github.com/thevilledev/ocaml-hpke.git#v0.4.0

Encrypt and decrypt one message

Create an empty directory with these three files.

dune-project:

(lang dune 3.12)

dune:

(executable
 (name main)
 (libraries hpke mirage-crypto-rng.unix))

main.ml:

open Hpke

let ( let* ) = Result.bind

let round_trip ~rng =
  let suite =
    Suite.create ~kem:Kem.X25519 ~kdf:Kdf.Hkdf_sha256
      ~aead:Aead.Chacha20_poly1305
  in
  let* private_key, public_key =
    generate_key_pair ~rng Kem.X25519
  in
  let info = "example-v1" and aad = "message-1" in
  let* ciphertext =
    Rfc9180.seal_base ~rng suite ~recipient:public_key ~info ~aad
      ~plaintext:"Hello, HPKE."
  in
  Rfc9180.open_base suite ~recipient:private_key ~info ~aad
    ~ciphertext

let () =
  Mirage_crypto_rng_unix.use_default ();
  let rng = Mirage_crypto_rng.default_generator () in
  match round_trip ~rng with
  | Ok plaintext -> print_endline plaintext
  | Error error ->
      Format.eprintf "%a@." Error.pp error;
      exit 1

Run it:

opam exec -- dune exec ./main.exe

Output: Hello, HPKE.

The example keeps both keys in one process. In an application, give the sender a trusted copy of the recipient's public key and keep the private key with the recipient. Base mode does not authenticate the sender.

The snippets below assume open Hpke and let ( let* ) = Result.bind.

Randomness

Call Mirage_crypto_rng_unix.use_default () once at startup, as above. Pass ~rng to key generation and sender operations. Receivers need no RNG. Deterministic generators are for tests only.

Keys

let parse_peer encoded =
  Public_key.of_bytes ~kem:Kem.X25519 encoded

let encode_public public_key =
  Public_key.to_bytes public_key

Private_key.of_bytes and to_bytes handle private keys; keep those bytes secret. A key's KEM must match the suite or the operation returns Key_mismatch. Parsing checks the encoding; it does not establish who owns the key.

derive_key_pair kem ~ikm derives a pair deterministically from high-entropy keying material. It is not a password KDF. For encoding rules and sizes, see key validation.

Pre-shared keys

PSK mode requires both the recipient's private key and a shared secret to decrypt. Both peers use the same secret and identifier:

let psk_round_trip ~rng suite ~secret
    ~recipient_public ~recipient_private =
  let* psk = Psk.create ~secret ~id:"tenant-42" in
  let* ciphertext =
    Rfc9180.seal_psk ~rng suite ~recipient:recipient_public ~psk
      ~info:"example-v1" ~aad:"" ~plaintext:"Hello, HPKE."
  in
  Rfc9180.open_psk suite ~recipient:recipient_private ~psk
    ~info:"example-v1" ~aad:"" ~ciphertext

Psk.create requires at least 32 secret bytes and a non-empty identifier. Use a high-entropy secret; a length check cannot establish entropy.

Authenticated senders

Auth mode uses the sender's static key pair. The sender supplies its private key; the recipient supplies a trusted copy of the sender's public key:

let auth_round_trip ~rng suite ~recipient_public ~recipient_private
    ~sender_private ~sender_public =
  let* ciphertext =
    Rfc9180.seal_auth ~rng suite ~recipient:recipient_public
      ~sender:sender_private ~info:"example-v1" ~aad:""
      ~plaintext:"Hello, HPKE."
  in
  Rfc9180.open_auth suite ~recipient:recipient_private
    ~sender:sender_public ~info:"example-v1" ~aad:"" ~ciphertext

AuthPSK adds ~psk through seal_auth_psk and open_auth_psk. All keys must match the suite's KEM. Only DHKEMs support these modes; check Kem.supports_auth. ML-KEM and hybrids return Unsupported_mode.

Auth is not a signature: a holder of the recipient's private key can impersonate any sender. AuthPSK also requires the PSK for that impersonation. See the authentication limits.

Post-quantum and hybrid KEMs

Choose the KEM in both the suite and key generation:

let kem = Kem.Mlkem768_x25519  (* X-Wing; requires 0.4.0 *)
let suite =
  Suite.create ~kem ~kdf:Kdf.Hkdf_sha256 ~aead:Aead.Chacha20_poly1305
let make_keys ~rng = generate_key_pair ~rng kem

Use the same Base or PSK functions. Keys and encapsulations are larger; private keys serialize as seeds. Parse keys once and reuse them. See ML-KEM, hybrids, and their security limits.

Several ordered messages

Set up one context at each end. Send the encapsulated key once, then send each ciphertext in order. This example sends two messages:

let send_two ~rng suite ~recipient =
  let* { Rfc9180.encapsulated_key; context } =
    Rfc9180.setup_base_sender ~rng suite ~recipient ~info:"stream-v1"
  in
  let* first = Rfc9180.Sender.seal context ~aad:"" ~plaintext:"one" in
  let* second = Rfc9180.Sender.seal context ~aad:"" ~plaintext:"two" in
  Ok (encapsulated_key, first, second)

let receive_two suite ~recipient (encapsulated_key, first, second) =
  let* context =
    Rfc9180.setup_base_receiver suite ~recipient ~encapsulated_key
      ~info:"stream-v1"
  in
  let* first = Rfc9180.Receiver.open_ context ~aad:"" ~ciphertext:first in
  let* second = Rfc9180.Receiver.open_ context ~aad:"" ~ciphertext:second in
  Ok (first, second)

PSK, Auth, and AuthPSK have corresponding setup_* functions. For independent messages, use the single-shot API.

Export secrets

Both sender and receiver contexts can export the same secret using the same label and length. Exporting does not change the message sequence. Use Suite.export_only if you need no message encryption:

let exporter =
  Suite.export_only ~kem:Kem.X25519 ~kdf:Kdf.Hkdf_sha256

let export_sender ~rng ~recipient =
  let* { Rfc9180.encapsulated_key; context } =
    Rfc9180.setup_base_sender ~rng exporter ~recipient ~info:"export-v1"
  in
  let* key =
    Rfc9180.Sender.export context ~context:"traffic key" ~length:32
  in
  Ok (encapsulated_key, key)

let export_receiver ~recipient ~encapsulated_key =
  let* context =
    Rfc9180.setup_base_receiver exporter ~recipient ~encapsulated_key
      ~info:"export-v1"
  in
  Rfc9180.Receiver.export context ~context:"traffic key" ~length:32

Export-only contexts cannot be passed to seal or open; the type checker rejects them. HKDF export limits are 255 times the hash size; SHAKE exports allow up to 65,535 bytes. See KDF limits.

ML-KEM and hybrid decapsulation can succeed with a secret the sender does not share. An export-only protocol must confirm that secret with its peer before relying on it.

Primitives for layered protocols

Kdf.extract and Kdf.expand expose unlabeled HKDF. Aead.key prepares a reusable key; Aead.seal and Aead.open_ take an explicit nonce. These functions support protocols that derive keys from HPKE exports. Follow that protocol's derivation and framing, and never encrypt twice with the same key and nonce.

Handle errors

Use Error.pp to format an error. Error values contain no key material. The main cases are:

ErrorMeaning
Open_errorDecryption or authentication failed. Single-shot opens also map invalid encapsulations to this error.
Key_mismatchA key's KEM differs from the suite's.
Unsupported_modeAuth or AuthPSK was requested for an ML-KEM or hybrid KEM.
Concurrent_useAnother call held the context. This call did no cryptography.
Message_limit_reachedThe context's sequence is exhausted. Establish a new context.

Treat peer failures uniformly in protocol responses. Context setup exposes more specific errors than single-shot opens. For every constructor and its contract, see hpke.mli.