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
- A nonce is used once per key. It is not secret; store or send it beside the ciphertext. Reusing one under the same key breaks the cipher outright, revealing the XOR of two plaintexts and, for Poly1305, the ability to forge.
new_nonce!draws a fresh one; call it per message, never once per program. - AAD is authenticated and never encrypted. It travels in the clear and is bound to the ciphertext:
decryptfails unless it matches byte for byte. It is where a version tag, a record id, or a routing header goes, which leaves a valid ciphertext unable to be replayed into another context. Err(AuthFailed)is one answer to several questions. Wrong key, wrong nonce, wrong AAD, altered ciphertext: the tag check cannot tell them apart, and telling a caller which would leak how close a guess came. Treat it as "this did not come from someone holding the key" and nothing finer.- Deriving a key from a password needs a password KDF, which this module does not have, by design.
keytakes 32 bytes of key material, and a passphrase run through a hash is not that. Mint withnew_key!.
Arriving here for something this module does not have
- A digest and no ciphertext.
extra/sha2hashes; this encrypts.sha2.hmacis the one to reach for when you want to prove a message was not altered but do not want it hidden. - Putting a ciphertext somewhere textual.
encrypthands back rawbytes;extra/base64orextra/hexis what makes them a string a JSON field or a header can hold. - Storing a key or a nonce.
to_byteson either opaque type reads its material out andkey/noncebuild one back, and both are therefore persistable.new_key!andnew_nonce!say so again at the call, which is where the question tends to occur to people.
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"