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
| 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
- Whole bytes only. Arbitrary bit-length inputs are outside the API.
- Full 128-bit tags only. Tag truncation is deliberately deferred.
- No nonce masking.
- No incremental AEAD — see above for why.
- The raw permutation is not exported.
Four complete runnable programs live in
examples/,
one per construction. The exact signatures are on the
generated API reference.