Table of Contents

Authenticated stream ciphers

A raw stream cipher gives confidentiality only. Bodu.Security.Cryptography pairs the extended-nonce stream ciphers with Poly1305 in three ready-made AEAD constructions, all built on the abstract Poly1305AeadTransform and all implementing IStreamAeadTransform. They share one shape: a 256-bit (32-byte) key, a 192-bit (24-byte) nonce that is large enough to draw at random, a 128-bit (16-byte) tag, and the wire format ciphertext ‖ tag.

Note

Like the rest of the library, these constructions are not independently audited and offer best-effort, not guaranteed, side-channel resistance.

The three constructions

Construction Type Keystream Framing Associated data Interoperates with
XChaCha20-Poly1305 XChaCha20Poly1305 XChaCha20 (HChaCha20 subkey + ChaCha20) RFC 8439 AEAD Yes draft-irtf-cfrg-xchacha; libsodium crypto_aead_xchacha20poly1305_ietf
XSalsa20-Poly1305 (secretbox) XSalsa20Poly1305 XSalsa20 (HSalsa20 subkey + Salsa20) NaCl secretbox - tag over the ciphertext only No - non-empty AAD throws ArgumentException NaCl / libsodium crypto_secretbox (same bytes, different order - see Pattern 3)
XSalsa20-Poly1305-AEAD XSalsa20Poly1305Aead XSalsa20 RFC 8439 AEAD Yes Nothing - a Bodu-defined hybrid; use only when both peers are Bodu

In every case the counter-0 keystream block supplies the one-time Poly1305 key and the message is encrypted from counter 1 (XChaCha20, XSalsa20-AEAD) or from byte 32 of the keystream (secretbox). The RFC 8439 framing authenticates AAD ‖ pad16(AAD) ‖ ciphertext ‖ pad16(ciphertext) ‖ le64(|AAD|) ‖ le64(|ciphertext|).

Which one? XChaCha20Poly1305 is the interoperable, random-nonce AEAD - the gap the BCL's 96-bit-nonce ChaCha20Poly1305 leaves (BCL interop). XSalsa20Poly1305 exists to talk to NaCl secretbox. XSalsa20Poly1305Aead exists for symmetry; prefer the other two unless a protocol names it.

Pattern 1 - XChaCha20-Poly1305 with associated data

This is the draft-irtf-cfrg-xchacha appendix A.3.1 vector; the tag reproduces the draft's c0875924c1c7987947deafd8780acf49.

using Bodu.Security.Cryptography;
using Bodu.Security.Cryptography.Extensions;

byte[] key   = Convert.FromHexString("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f");
byte[] nonce = Convert.FromHexString("404142434445464748494a4b4c4d4e4f5051525354555657");
byte[] aad   = Convert.FromHexString("50515253c0c1c2c3c4c5c6c7");
byte[] plaintext = "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."u8.ToArray();

using var enc = new XChaCha20Poly1305(key, nonce);
byte[] sealed_ = enc.Encrypt(plaintext, associatedData: aad);        // ciphertext ‖ tag: BD6D179D…  ‖ C0875924C1C7987947DEAFD8780ACF49

using var dec = new XChaCha20Poly1305(key, nonce);
byte[] recovered = dec.Decrypt(sealed_, associatedData: aad);        // throws CryptographicException on any tamper

Encrypt / Decrypt come from AeadTransformExtensions, which size and allocate the array; TagSize is 128 bits, and XChaCha20Poly1305.KeySize / NonceSize are 256 / 192.

The span overloads that write into your own buffer allocate nothing: the instance holds the key and nonce, and each message's keystream and Poly1305 state live on the stack, so the only allocation a message makes is the single-use instance itself. Whole 64-byte blocks of keystream are produced many at a time on processors with vector instructions. A message of up to 960 bytes (992 for XSalsa20Poly1305) can draw all of its keystream, including the block that keys Poly1305, in one or two such steps, which it does wherever the processor makes that cheaper than drawing a block at a time; and on x64 Poly1305 absorbs a message of 512 bytes or more several blocks at a time as well - see Hardware acceleration.

Warning

Name the associatedData: argument. Poly1305AeadTransform also has a public Encrypt(ReadOnlySpan<byte> plaintext, Span<byte> output, ReadOnlySpan<byte> associatedData = default); with two positional byte[] arguments that overload wins, your AAD array becomes the output buffer, and the call returns an int. Encrypt(plaintext) with a single argument is unambiguous.

Pattern 2 - single use, and what happens on tamper

Every instance is stateful and single-use. A second Encrypt or Decrypt on the same instance throws InvalidOperationException - including after a failed tag check, so a "retry on the same transform" is impossible by construction. A modified ciphertext, tag, or AAD throws CryptographicException and writes no plaintext.

using System.Security.Cryptography;
using Bodu.Security.Cryptography;
using Bodu.Security.Cryptography.Extensions;

byte[] key = new byte[32], nonce = new byte[24];
byte[] plaintext = "single use"u8.ToArray();

using var enc = new XChaCha20Poly1305(key, nonce);
byte[] sealed_ = enc.Encrypt(plaintext);
try { enc.Encrypt(plaintext); } catch (InvalidOperationException) { /* one message per instance */ }

sealed_[0] ^= 0x01;
using var dec = new XChaCha20Poly1305(key, nonce);
try { dec.Decrypt(sealed_); } catch (CryptographicException) { /* tag did not verify */ }
try { dec.Decrypt(sealed_); } catch (InvalidOperationException) { /* the instance is burned */ }
Warning

A (key, nonce) pair must encrypt at most one message. The 24-byte nonce is large enough to draw from RandomNumberGenerator (or Random(int)) per message without meaningful collision risk; that is the whole point of the extended nonce. Do not reuse a key across these constructions and the raw XChaCha20 / XSalsa20 ciphers without an HKDF-style key separation.

Pattern 3 - NaCl secretbox and the libsodium layout

XSalsa20Poly1305 reproduces the secretbox body byte for byte, but emits ciphertext ‖ tag like the rest of the library where libsodium's crypto_secretbox_easy emits tag ‖ ciphertext. Two static helpers swap the order at the boundary; both accept an aliased destination for in-place conversion.

using Bodu.Security.Cryptography;
using Bodu.Security.Cryptography.Extensions;

byte[] key   = Convert.FromHexString("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f");
byte[] nonce = Convert.FromHexString("404142434445464748494a4b4c4d4e4f5051525354555657");
byte[] plaintext = "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."u8.ToArray();

using var box = new XSalsa20Poly1305(key, nonce);
byte[] sealed_ = box.Encrypt(plaintext);                     // ciphertext ‖ tag; tag = 838EBCFC76D4B1D823B89BB6E0FC0520

byte[] libsodium = new byte[sealed_.Length];
XSalsa20Poly1305.ToLibsodiumCombined(sealed_, libsodium);   // tag ‖ ciphertext - what crypto_secretbox_easy produces

byte[] back = new byte[libsodium.Length];
XSalsa20Poly1305.FromLibsodiumCombined(libsodium, back);    // ciphertext ‖ tag again

using var open = new XSalsa20Poly1305(key, nonce);
byte[] recovered = open.Decrypt(back);

Secretbox authenticates no associated data: Encrypt(plaintext, associatedData: header) with a non-empty header throws ArgumentException (associatedData). When you need AAD with an XSalsa20 keystream use XSalsa20Poly1305Aead, whose call shape is identical to Pattern 1 but whose output no other library will open.

Pattern 4 - programming against the interface

All three types are IStreamAeadTransform, and through it IAeadTransform - the same interface the block-cipher AEADs and AsconAead128 implement - so one code path can serve any of them. The span-based members return the number of bytes written; the output must be at least plaintext.Length + TagSize / 8.

using Bodu.Security.Cryptography;

byte[] key = new byte[32], nonce = new byte[24], aad = "hdr"u8.ToArray();
byte[] plaintext = "polymorphic"u8.ToArray();

foreach (Func<IStreamAeadTransform> make in new Func<IStreamAeadTransform>[]
{
    () => new XChaCha20Poly1305(key, nonce),
    () => new XSalsa20Poly1305Aead(key, nonce),
})
{
    using IStreamAeadTransform enc = make();
    byte[] output = new byte[plaintext.Length + enc.TagSize / 8];
    int written = enc.Encrypt(plaintext, output, aad);

    using IStreamAeadTransform dec = make();
    byte[] plain = new byte[written - enc.TagSize / 8];
    int plainLength = dec.Decrypt(output.AsSpan(0, written), plain, aad);
}

Exact in-place operation is supported - the output span may start at the same address as the input - but any other overlap throws ArgumentException:

using Bodu.Security.Cryptography;

byte[] key = new byte[32], nonce = new byte[24];
byte[] plaintext = "in place"u8.ToArray();
byte[] buffer = new byte[plaintext.Length + 16];
plaintext.CopyTo(buffer, 0);

using var enc = new XChaCha20Poly1305(key, nonce);
int written = enc.Encrypt(buffer.AsSpan(0, plaintext.Length), buffer);          // same start: allowed

using var dec = new XChaCha20Poly1305(key, nonce);
int plainLength = dec.Decrypt(buffer.AsSpan(0, written), buffer);

Pattern 5 - detached tags

EncryptDetached / DecryptDetached - the overloads that return an AuthenticationTag separately from the ciphertext - are declared on AeadBlockCipherModeTransformExtensions for IAeadBlockCipherModeTransform only. The stream AEADs do not have them; because their layout is always ciphertext ‖ tag, detaching is a slice:

using Bodu.Security.Cryptography;
using Bodu.Security.Cryptography.Extensions;

byte[] key = new byte[32], nonce = new byte[24], aad = "hdr"u8.ToArray();
byte[] plaintext = "detached"u8.ToArray();

using var enc = new XChaCha20Poly1305(key, nonce);
byte[] combined = enc.Encrypt(plaintext, associatedData: aad);

int tagBytes = enc.TagSize / 8;
byte[] ciphertext = combined[..^tagBytes];
AuthenticationTag tag = AuthenticationTag.FromBytes(combined.AsSpan(^tagBytes));   // travels out of band

using var dec = new XChaCha20Poly1305(key, nonce);
byte[] recovered = dec.Decrypt([.. ciphertext, .. tag.AsSpan()], associatedData: aad);

See Nonces, salts, tags, and secrets for AuthenticationTag and the block-cipher EncryptDetached shape.

Building your own

Poly1305AeadTransform is abstract and public. A subclass supplies CreateEngine() - an IStreamCipher positioned at block counter 0 - and may override SealCore / OpenCore to substitute a framing (that is how the secretbox variant is built) or SupportsAssociatedData to reject AAD. The base handles length validation, the overlap rule, the single-use latch, and zeroizing the retained key and nonce on Dispose. The library's own keystream engines are internal, so a subclass brings its own IStreamCipher.

API summary

Member Where Notes
Encrypt(plaintext, output, associatedData = default) / Decrypt(…) → int Poly1305AeadTransform (IAeadTransform) span form; output ≥ input + 16 / input − 16
Encrypt(plaintext, associatedData = default) / Decrypt(…) → byte[] AeadTransformExtensions name associatedData:
TagSize IAeadTransform 128 bits
KeySize / NonceSize constants each concrete type 256 / 192 bits
ToLibsodiumCombined / FromLibsodiumCombined XSalsa20Poly1305 (static) swap ciphertext ‖ tag ↔ tag ‖ ciphertext
CreateEngine, SealCore, OpenCore, SupportsAssociatedData Poly1305AeadTransform (protected) extension points
Dispose all zeroes the retained key and nonce

Where to go next