Usage

Everything lives under a single Ascon module with one submodule per standardized construction. Inputs and outputs are bytes; nothing here accepts arbitrary bit lengths.

Install

opam install ascon

To build against the current checkout instead:

opam pin add ascon.dev .

Then add the library to the dune stanza that needs it:

(executable
 (name my_program)
 (libraries ascon))

OCaml 4.14 or newer and dune 3.10 or newer are required. There is nothing else to install: the package has no C stubs and does not link Unix, so the same build works under MirageOS.

Hash256

The one-shot form takes bytes or a string:

let digest = Ascon.Hash256.digest_string "message"
(* [digest] is exactly Ascon.Hash256.digest_size = 32 bytes. *)

Incremental contexts are immutable. feed returns a new context rather than mutating the old one, and get reads a digest without consuming the context it was called on:

let digest =
  let context = Ascon.Hash256.init () in
  let context = Ascon.Hash256.feed_string context "first chunk" in
  let context = Ascon.Hash256.feed context (Bytes.of_string "second chunk") in
  Ascon.Hash256.get context

Because contexts are persistent, a prefix can be computed once and reused for several suffixes. Chunk boundaries never affect the result: the context holds up to seven pending bytes and completes rate blocks across calls.

let prefix = Ascon.Hash256.feed_string (Ascon.Hash256.init ()) "shared header"
let first = Ascon.Hash256.get (Ascon.Hash256.feed_string prefix "body A")
let second = Ascon.Hash256.get (Ascon.Hash256.feed_string prefix "body B")
(* [prefix] is unchanged and still usable. *)

AEAD128

Keys and nonces are abstract values built by a checked constructor. Exactly 16 bytes are accepted, the input is copied defensively, and any other length is an error rather than a truncation:

match Ascon.Aead128.Key.of_bytes key_bytes with
| Ok key -> encrypt_with key
| Error `Invalid_length -> reject_configuration ()

Never reuse a nonce with the same key. Nonce reuse breaks Ascon-AEAD128's security requirements outright — it is not a degradation. key_bytes must come from a suitable secure key source, and nonce_bytes must be unique for every encryption under that key. The library cannot detect reuse for you.

encrypt returns the ciphertext and tag detached. decrypt returns the plaintext only after the tag verifies:

let () =
  let key = Result.get_ok (Ascon.Aead128.Key.of_bytes key_bytes) in
  let nonce = Result.get_ok (Ascon.Aead128.Nonce.of_bytes nonce_bytes) in
  let associated_data = Bytes.of_string "record header" in
  let plaintext = Bytes.of_string "secret payload" in
  let ciphertext, tag =
    Ascon.Aead128.encrypt ~key ~nonce ~associated_data ~plaintext
  in
  match Ascon.Aead128.decrypt ~key ~nonce ~associated_data ~ciphertext ~tag with
  | Ok authenticated_plaintext -> use authenticated_plaintext
  | Error `Authentication_failure -> reject_record ()
  | Error `Invalid_tag_length -> reject_malformed_record ()

Associated data is authenticated but not encrypted, and it is a required argument: pass Bytes.empty when a message has none. Both peers must agree on it exactly, or authentication fails.

Combined ciphertext and tag

When a wire format carries one blob, the combined helpers append the tag for you and split it back off on the way in:

let record = Ascon.Aead128.encrypt_combined ~key ~nonce ~associated_data ~plaintext in
(* Bytes.length record = Bytes.length plaintext + Ascon.Aead128.tag_size *)

match Ascon.Aead128.decrypt_combined ~key ~nonce ~associated_data record with
| Ok plaintext -> use plaintext
| Error `Authentication_failure -> reject_record ()
| Error `Invalid_tag_length -> reject_malformed_record ()

A blob shorter than 16 bytes cannot contain a tag at all, so decrypt_combined reports `Invalid_tag_length for it. That distinction is about the shape of the input, not about the key: treat both errors the same way in a protocol, and never report which one occurred to a remote peer.

What failure means

Decryption computes the whole candidate plaintext into a buffer, compares the full 128-bit tag with a comparison that scans every byte, and returns the buffer only on success. A rejected buffer is overwritten on a best-effort basis before it is dropped. Failure is a typed Error, not an exception, so an ordinary forgery cannot escape as a stray Failure up the call stack.

XOF128

The one-shot form asks for a length up front:

match Ascon.Xof128.digest (Bytes.of_string "message") ~length:64 with
| Ok output -> use output
| Error `Invalid_length -> assert false

The incremental form encodes the sponge's lifecycle in the types. absorbing and squeezing are distinct, so a state that has started producing output cannot be passed back to absorb — that is a compile error, not a runtime check:

let () =
  let absorbing = Ascon.Xof128.init () in
  let absorbing = Ascon.Xof128.absorb absorbing (Bytes.of_string "first ") in
  let absorbing = Ascon.Xof128.absorb absorbing (Bytes.of_string "second") in
  let squeezing = Ascon.Xof128.start_squeezing absorbing in
  let squeezing, first =
    Result.get_ok (Ascon.Xof128.squeeze squeezing ~length:17)
  in
  let _squeezing, next =
    Result.get_ok (Ascon.Xof128.squeeze squeezing ~length:15)
  in
  assert (Bytes.length first + Bytes.length next = 32)

Repeated squeezes produce consecutive output: concatenating them gives exactly the same bytes as one long squeeze from an equivalent state, whatever the split. Each call returns the next state alongside its output, so an unbounded keystream is a fold rather than a mutation.

CXOF128

A customization string is explicit domain separation: two calls that differ only in customization produce unrelated output. SP 800-232 caps it at 256 bytes, and the library enforces that cap.

let () =
  let output =
    Ascon.Cxof128.digest
      ~customization:(Bytes.of_string "com.example.protocol/transcript-v1")
      ~message:(Bytes.of_string "message")
      ~length:32
  in
  match output with
  | Ok bytes -> use bytes
  | Error `Customization_too_long -> reject_configuration ()
  | Error `Invalid_length -> assert false

The incremental form validates customization first, because that is the only part that can fail before any message byte is seen:

match Ascon.Cxof128.init ~customization with
| Error `Customization_too_long -> reject_configuration ()
| Ok absorbing ->
  let squeezing =
    Ascon.Cxof128.start_squeezing (Ascon.Cxof128.absorb absorbing message)
  in
  (match Ascon.Cxof128.squeeze squeezing ~length:32 with
   | Ok (_, output) -> use output
   | Error `Invalid_length -> assert false)

Customization strings are fully processed by init, so a long-lived protocol context pays for them once. Pick a value that cannot collide with another protocol's — a domain-qualified string with a version, as above.

Error reference

Every error the public API can return
Error Returned by Means
`Invalid_length Key.of_bytes, Nonce.of_bytes, and the of_string forms The input was not exactly 16 bytes. Nothing was truncated.
`Invalid_length Xof128.squeeze, Cxof128.squeeze, and both digest forms A one-shot length was not positive, or a requested length is unrepresentable. Checked before allocation.
`Authentication_failure Aead128.decrypt, Aead128.decrypt_combined The tag did not verify. The candidate plaintext is discarded.
`Invalid_tag_length Aead128.decrypt, Aead128.decrypt_combined The tag was not 16 bytes, or the combined blob was too short to hold one. This release supports full tags only.
`Customization_too_long Cxof128.init, Cxof128.digest The customization string exceeded the standard's 256-byte maximum.

A zero-length incremental squeeze is not an error: it returns an empty result and the same state, so a loop that asks for whatever is left needs no special case. Only the one-shot digest functions require a positive length, as SP 800-232 does.

What this release does not do

Four complete runnable programs live in examples/, one per construction. The exact signatures are on the generated API reference.