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.

Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite

Die generierte Referenz von std/cipher zeigt jede öffentliche Deklaration mit Signatur, Doc-Kommentar und Beispielen.

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):

nativethe VM
a 32-byte message sealedabout 30 usabout 1.07 ms
a 32-byte message openedabout 29 usabout 1.05 ms
1 MiB sealedabout 0.19 s (5.2 MiB per second)about 8.0 s (0.13 MiB per second)
1 MiB openedabout 0.19 sabout 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.

  • std/digest - the hashes, and constantTimeEqual that compares the tag.
  • std/signature - signatures, for when the receiver must know who sealed.
Esc