hanki

aead

stdlib/extra/aead.hk: authenticated encryption with XChaCha20-Poly1305.

Encryption that also proves the message was not altered. Every decrypt checks a tag before it hands back a single byte, and a ciphertext altered in transit is therefore an error and no plausible-looking plaintext. This module has no unauthenticated mode to reach for by mistake.

One algorithm, and no way to choose another. Algorithm agility is where these APIs go wrong: a caller who can pick has to know which to pick, and the wrong answer is silent. XChaCha20-Poly1305 is the pick because its nonce is 192 bits: wide enough that drawing one at random per message is safe with no counter and no bookkeeping, which is the failure mode that breaks AES-GCM deployments.

A pure-Hanki face over the sys native seam (HANKI.md §4, §17), like sqlite: nothing here bears an @intrinsic, and encrypt/decrypt delegate to sys.aead_encrypt/sys.aead_decrypt, the seam's only pure members, encryption being a deterministic function of its inputs with no authority to gate. Only minting a key or a nonce is effectful, and it charges the [random] it draws.

The seam is not an optimisation. sha2 is pure Hanki and measures ~30 KB/s on the bytecode VM, and bulk encryption at that rate is not encryption anyone can use.

Using it

use aead

def main!() -> int [io, random]
  key = aead.new_key!()
  nonce = aead.new_nonce!()
  sealed = aead.encrypt(key, nonce, "attack at dawn".to_bytes(), "v1".to_bytes())
  match aead.decrypt(key, nonce, sealed, "v1".to_bytes())
    Ok(plaintext) -> io.print!("#{plaintext.length} bytes back")
    Err(e) -> io.eprint!("#{e}")
  end
  0
end

What you have to get right

Arriving here for something this module does not have

key_length

key_length: int = 32

Bytes in a key.

nonce_length

nonce_length: int = 24

Bytes in a nonce: the 192 bits that make a random nonce safe.

tag_length

tag_length: int = 16

Bytes encrypt appends to the ciphertext for the authentication tag, which makes a sealed message this much longer than the plaintext it came from. Splitting a stored nonce || ciphertext blob needs this and nonce_length.

AeadError

type AeadError
  AuthFailed
  BadKeyLength(int)
  BadNonceLength(int)
end

What went wrong.

AuthFailed is the only one decrypt produces; the two length errors come from key and nonce, which is where a wrong length can still be caught.

impl Display<AeadError>

to_string

def to_string(self) -> string

Lowercase and without a trailing stop, which fits it inside a larger sentence as well as alone.

AuthFailed.to_string() => "authentication failed"
BadKeyLength(5).to_string() => "a key is 32 bytes, got 5"
BadNonceLength(0).to_string() => "a nonce is 24 bytes, got 0"

impl Eq<AeadError>

eq?

def eq?(self, other: Self) -> bool

Structural equality; the length errors compare the length they carry.

(AuthFailed == AuthFailed) => true
(BadKeyLength(5) == BadKeyLength(6)) => false

Key

opaque Key
  raw: bytes
end

A secret key: 32 bytes, and the only thing standing between a ciphertext and whoever has it.

Opaque, which checks its length once at construction and never again at every call, and leaves a Key and a Nonce unable to be passed in each other's place, which a pair of bare bytes parameters would permit with no word about it.

There is no Display, by design: a key that renders is a key that ends up in a log line. Read the material out with to_bytes when you mean to store or send it.

impl Eq<Key>

eq?

def eq?(self, other: Self) -> bool

Constant-time, which makes == on keys safe without anyone remembering to make it so. An ordinary byte compare stops at the first difference, and that timing recovers a key byte by byte. A caller comparing to_bytes() by hand would write that ordinary compare, and the operator does it right in place of leaving a trap where the obvious spelling is the wrong one.

k = new_key!()
(k == k) => true

impl Key

to_bytes

def to_bytes(self) -> bytes

The 32 bytes, for storing in a keychain or sending over an already-secure channel. Handle them as the secret they are.

new_key!().to_bytes().length => 32

Nonce

opaque Nonce
  raw: bytes
end

A number used once: 24 bytes, public, and never repeated under one key.

Opaque for the reasons Key is, minus the secrecy: a nonce travels in the clear beside the ciphertext it belongs to.

impl Nonce

to_bytes

def to_bytes(self) -> bytes

The 24 bytes, to store or send alongside the ciphertext. decrypt needs the nonce encrypt was given and no other.

new_nonce!().to_bytes().length => 24

impl Eq<Nonce>

eq?

def eq?(self, other: Self) -> bool

Two nonces are equal when all 24 bytes are. Constant-time for consistency with Key and not out of necessity: a nonce is public.

n = new_nonce!()
(n == n) => true

impl Display<Nonce>

to_string

def to_string(self) -> string

Lowercase hex, 48 digits. A nonce is public and rendering one is safe, which is what makes it loggable next to the ciphertext it belongs to.

new_nonce!().to_string().length => 48

key

def key(raw: bytes) -> Result<Key, AeadError>

Take 32 bytes of existing key material as a Key, reading one back from a keychain, a config file, or a pairing exchange.

The bytes must be uniformly random and secret. The length is the only checkable property: every 32-byte string is a valid key, including a terrible one. To make a new key use new_key!, which cannot get that wrong.

key("0123456789abcdef0123456789abcdef".to_bytes()).map(|k| k.to_bytes().length).unwrap_or(0) => 32
key("too short".to_bytes()).map(|k| 0).unwrap_or(-1) => -1

nonce

def nonce(raw: bytes) -> Result<Nonce, AeadError>

Take 24 bytes as a Nonce, reading back the one stored beside a ciphertext.

nonce("012345678901234567890123".to_bytes()).map(|n| n.to_bytes().length).unwrap_or(0) => 24
nonce("short".to_bytes()).map(|n| 0).unwrap_or(-1) => -1

new_key!

def new_key!() -> Key [random]

A fresh key: 32 bytes from the same source random.bytes! draws from.

Charges [random], and a program that mints keys therefore says so in its signature and a run can refuse it with --deny random. Under --deterministic the draw comes from the seeded stream and is not cryptographic, which is what makes a test reproducible and what makes that mode unfit for real keys.

The key it hands back is fully persistable, which is worth saying here and not on the type alone: to_bytes reads the 32 bytes out and key builds the same Key back from them. Minting material with random.bytes! and rebuilding at every use is a workaround for a limit that is not there.

new_key!().to_bytes().length => 32

new_nonce!

def new_nonce!() -> Nonce [random]

A fresh nonce: 24 bytes, drawn per message.

192 bits is the whole reason this cipher was chosen. Random nonces collide at around 2^96 messages under one key, which is not a number any program reaches, which leaves no counter to keep and no state to persist, the thing that makes narrower nonces hard to use safely.

Persistable the same way a key is: to_bytes reads the 24 bytes out and nonce builds one back. A nonce is public by design, and storing or sending it beside the ciphertext is the ordinary thing to do with it.

new_nonce!().to_bytes().length => 24

encrypt

def encrypt(key: Key, nonce: Nonce, plaintext: bytes, aad: bytes) -> bytes

Encrypt plaintext under key and nonce, binding aad to the result.

Returns the ciphertext with its authentication tag appended, tag_length bytes longer than the plaintext. Pass an empty bytes for aad when there is no surrounding context to bind.

Total: with a Key and a Nonce in hand there is nothing left to fail. That is what the opaque types are for: the length checks happened once, at construction, and this signature therefore has no error case to make a caller handle.

nonce must not have been used with this key before. Call new_nonce! per message.

k = new_key!()
n = new_nonce!()
encrypt(k, n, "attack at dawn".to_bytes(), "".to_bytes()).length => 30
encrypt(k, n, "".to_bytes(), "".to_bytes()).length => 16

decrypt

def decrypt(key: Key, nonce: Nonce, ciphertext: bytes, aad: bytes) -> Result<bytes, AeadError>

Check ciphertext's tag against key, nonce and aad, and decrypt it if it verifies.

aad must be byte-for-byte what encrypt was given. Anything else, a wrong key or a wrong nonce or altered ciphertext, is the same Err(AuthFailed), because one tag check answers all of it at once and reporting them apart would say how close a guess came.

Nothing partial comes back on failure: the tag is verified before any plaintext is returned, which leaves no half-decrypted buffer to mishandle.

k = new_key!()
n = new_nonce!()
sealed = encrypt(k, n, "attack at dawn".to_bytes(), "v1".to_bytes())
decrypt(k, n, sealed, "v1".to_bytes()).unwrap_or("".to_bytes()).to_string().unwrap_or("") => "attack at dawn"
decrypt(k, n, sealed, "v2".to_bytes()).map(|p| "opened").unwrap_or("refused") => "refused"