std/cipher
ChaCha20-Poly1305 of RFC 8439 in TorbScript - an authenticated cipher with a 32-byte key that seals a message with a 12-byte nonce and associated data, and opens it only if nothing was changed.
The generated reference of std/cipher has every public construct with its signature, its doc comment and its examples.
std/cipher keeps a secret from everybody who does not have the key, and notices when somebody changed the bytes. It
is one construction, ChaCha20-Poly1305 (RFC 8439 section 2.8): the stream cipher ChaCha20 hides the message, and the
one-time authenticator Poly1305 makes a 16-byte tag over the message and over associated data that is sent in the clear
but must not be changed. seal answers the ciphertext followed by the tag; open answers the message back, or
CipherError.Forged if any byte of the nonce, the associated data, the ciphertext or the tag is not the one that was
sealed. It is ordinary TorbScript with no native, over Int arithmetic and std/digest's
constantTimeEqual, so both back ends compute the same bits.
Only the AEAD is exported. ChaCha20 and Poly1305 stay inside the package (its lib.trb does not export them, and nothing
outside should reach for their modules by path), because a stream cipher without its tag lets
anybody flip bits of the message unseen, and a one-time authenticator under a key used twice lets a forger make tags.
Import#
use ChaCha20Poly1305, CipherError from "std/cipher"use ChaCha20Poly1305 from "std/cipher"
const bytes: Bytes = Buffer.from List.filled(32, 7)
const key = ChaCha20Poly1305.tryFrom(bytes).expect("32 bytes")
const nonce: Bytes = Buffer.from List.filled(12, 1)
const sealed = key.seal(nonce, "a TOTP seed".bytes(), "account 7".bytes()).expect("a 12-byte nonce")
print sealed.length()
const opened = key.open(nonce, sealed, "account 7".bytes()).expect("sealed just now")
print(opened == "a TOTP seed".bytes())
print key.open(nonce, sealed, "account 8".bytes()).isOk()Declarations#
ChaCha20Poly1305#
type ChaCha20Poly1305 with Show, Encode, TryFrom<Bytes, CipherError> {
static fn tryFrom(key: Bytes): Result<Self, CipherError>
fn seal(nonce: Bytes, plaintext: Bytes, associated: Bytes): Result<Bytes, CipherError>
fn open(nonce: Bytes, sealed: Bytes, associated: Bytes): Result<Bytes, CipherError>
fn show(): String
fn encode<Target: Encoder>(var target: Target)
}A key is exactly 32 bytes, which the caller takes from the system's source of randomness: the package makes no
randomness, and a key from anything else is not a key. tryFrom refuses any other length with
CipherError.WrongLength. Its show() writes ChaCha20Poly1305(key hidden) and never a byte of the key, so a key that
lands in a log line or in an encoded configuration does not leak.
seal(nonce, plaintext, associated) answers the ciphertext, which is as long as the plaintext, followed by the 16-byte
tag. The nonce is exactly 12 bytes, else CipherError.WrongLength. The associated data is authenticated and not
encrypted - a header, a record number, the name of the account - and is empty when there is none; whoever opens has to
give the same bytes.
open(nonce, sealed, associated) answers the plaintext. It computes the tag - under the one-time key that the key and the
nonce make, over the associated data and the ciphertext - first, compares it with the one that came in constant time, and decrypts only if they are equal: no byte of a
forged message is ever released. Everything that can be wrong with the sealed bytes is the same CipherError.Forged
with no detail - a changed ciphertext, tag, associated data or nonce, another key, fewer than 16 bytes - because
telling a caller which of them it was hands a forger an oracle.
use ChaCha20Poly1305 from "std/cipher"
const bytes: Bytes = Buffer.from List.filled(32, 3)
const key = ChaCha20Poly1305.tryFrom(bytes).expect("32 bytes")
const nonce: Bytes = Buffer.from List.filled(12, 9)
const sealed = key.seal(nonce, "hello".bytes(), []).expect("a 12-byte nonce")
print sealed.length()
const opened = key.open(nonce, sealed, []).expect("sealed just now")
print(opened == "hello".bytes())
print key.open(nonce, sealed[0..10], [])
const short: Bytes = Buffer.from List.filled(16, 0)
print ChaCha20Poly1305.tryFrom(short).isOk()CipherError#
type CipherError with Show, Error {
case WrongLength(what: String, expected: Int, found: Int)
case Forged
fn show(): String
}Why a key or a nonce is not one - the wrong number of bytes, with what was expected and what was found - or why sealed
bytes do not open. Forged carries nothing on purpose.
Correctness and speed#
Tested against every vector of RFC 8439 (std/cipher/tests, natively and in the VM): the quarter round (2.1.1 and
2.2.1), the block function (2.3.2 and A.1), the encryption (2.4.2 and A.2), the Poly1305 one-time key (2.6.2 and A.4),
the MAC (2.5.2 and A.3) and the AEAD (2.8.2 and A.5). The vectors were extracted from the text of the RFC by a program
and checked against Node's crypto (OpenSSL) and against a second, independent implementation before they were written
into the tests. Each AEAD vector is sealed to the printed bytes and opened once, and then refused with a changed byte in
the ciphertext, the tag, the associated data, the nonce or the key, and cut short of its tag. The empty message and the empty
and the non-empty associated data are tested in tests of their own. The block function and Poly1305 are also tested directly in the
package's own tests.
Measured with benchmarks/cipher.trb's two sizes - one key, a 32-byte message with associated data and one of
1 MiB - on the forge's Linux runner (x86-64, 2026-10-09; the native column is the suite built natively by torb test --native, the VM column the same suite run in the VM):
| native | the VM | |
|---|---|---|
| a 32-byte message sealed | about 30 us | about 1.07 ms |
| a 32-byte message opened | about 29 us | about 1.05 ms |
| 1 MiB sealed | about 0.19 s (5.2 MiB per second) | about 8.0 s (0.13 MiB per second) |
| 1 MiB opened | about 0.19 s | about 7.8 s |
Opening costs what sealing costs: the same keystream and the same authenticator. In the VM it is a cipher for a token, a seed or a record of a few kilobytes, not for a file.
A nonce must never repeat under one key. Two messages sealed under the same key and nonce give away the XOR of their plaintexts and let a forger make tags for other messages. The package cannot know, so the caller must: count the messages and use the count as the nonce, or take 12 random bytes for each. Random 96-bit nonces are safe for up to about 2^32 messages per key; beyond that the chance of two equal ones is no longer negligible, so change the key.
Constant time, and where it stops. The comparison of the tag is constant time (constantTimeEqual of
std/digest): it looks at all 16 bytes whether or not the first differs. ChaCha20 and Poly1305 are plain
Int arithmetic of the language - additions, rotations and exclusive ors on masked words, products of 26-bit limbs, a
final reduction by a mask rather than a branch - with no branch and no index that depends on the key or the message in
the source. Comparing two keys with == is not constant time either, and hash() is derived from the key: do not use
them on keys. What the compiler and the CPU make of that is not verified, and the package promises nothing more: it
does not defend against an attacker who can time the machine closely, nor against one who can read its memory. The key
stays in memory as long as the value does.
Related#
- std/digest - the hashes, and
constantTimeEqualthat compares the tag. - std/signature - signatures, for when the receiver must know who sealed.