The problem a local vault has to solve
Store a value on disk such that:
- The value cannot be read without a secret (the master password).
- Nobody can tamper with the ciphertext without detection.
- The solution is cheap in CPU and code complexity.
- The binary is distributable as a single static file (important for a CLI).
That is essentially "authenticated encryption with a passphrase-derived key." There are three realistic modern choices:
- AES-256-GCM — wide industry adoption, fast with AES-NI, tricky constant-time without hardware.
- ChaCha20-Poly1305 — software-fast, simple to implement in constant time, standardized in RFC 8439.
- XChaCha20-Poly1305 — ChaCha20 with an extended 192-bit nonce so you can generate random nonces without worrying about collisions.
tene picked XChaCha20-Poly1305.
Why not AES-GCM
AES-GCM is excellent if you are running on a CPU with AES-NI hardware (every modern Intel, AMD, Apple Silicon). Without hardware acceleration, an AES software implementation is fiddly — constant-time AES in pure software requires table-lookup-free code or bit-slicing, and bugs here are subtle.
For a Go binary that has to run anywhere (Linux on a Raspberry Pi, Windows on an older machine, Docker in minimal environments), we want an implementation that is correct and fast regardless of the hardware.
ChaCha20's design is ARX (Add-Rotate-XOR) — the entire cipher runs on integer add, rotate, and XOR. There are no table lookups. Writing it in constant time is the straightforward thing to do.
Why X-ChaCha vs regular ChaCha
Regular ChaCha20-Poly1305 has a 96-bit nonce (RFC 8439). If you generate nonces randomly, the birthday bound says you can safely encrypt approximately 2^48 messages before collision probability gets uncomfortable. For a developer vault that is a huge budget — but "safe if you think about nonces carefully" is exactly what we want to avoid.
XChaCha20 extends the nonce to 192 bits by using the first 128 bits as input to a HChaCha20 key derivation. The result: generate a random 192-bit nonce per encryption and ignore collisions forever (the birthday bound is 2^96 messages, which is "the heat death of the universe" territory).
In tene's vault this matters because the vault is append-only in practice but the number of re-encryptions over a decade-long lifetime is not well-bounded.
The full crypto chain
Master Password
↓ Argon2id (64 MiB memory, 3 iterations, user-specific salt)
Master Key (256 bits) ──→ cached in OS keychain
↓ HKDF-SHA256
Encryption Key (256 bits)
↓ XChaCha20-Poly1305 (192-bit nonce, secret name as AAD)
Ciphertext stored in SQLite vaultEach step is mandated by a published standard:
- Argon2id: RFC 9106
- HKDF: RFC 5869
- XChaCha20-Poly1305: draft-irtf-cfrg-xchacha-03 (widely implemented)
- SQLite: stable file format

Why AAD matters
The secret name is passed as Additional Authenticated Data (AAD) to the Poly1305 tag. That means if an attacker swaps ciphertext between two records — taking the ciphertext for STRIPE_KEY and putting it in the row labeled DATABASE_URL — decryption fails.
Without AAD an attacker who can write to the vault file could confuse the application into using the wrong secret. With AAD this attack is a cryptographic forgery, which requires breaking Poly1305.
The code (simplified)
Here is the core encrypt function from tene:
import (
"crypto/rand"
"golang.org/x/crypto/chacha20poly1305"
)
func encrypt(key []byte, name string, plaintext []byte) (ciphertext []byte, err error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return nil, err
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
// Prepend nonce to ciphertext so decrypt can recover it.
return aead.Seal(nonce, nonce, plaintext, []byte(name)), nil
}That is it. Nine lines. No rolling-your-own crypto, no padding oracles,
no AES-NI branching. The golang.org/x/crypto/chacha20poly1305 package
is part of the standard-adjacent ecosystem and gets Go's regular crypto
review.
What we do not do
- We do not roll our own KDF. Argon2id is from
golang.org/x/crypto/argon2. - We do not store nonces separately. They are prepended to the ciphertext, which is the standard convention.
- We do not reuse keys. Each environment has its own key derivation via HKDF with a distinct info label.
- We do not invent "tweaks" or "modes". Straight XChaCha20-Poly1305 as specified.
Performance on commodity hardware
On an M2 MacBook Air:
- Argon2id(64 MiB, 3 iter) derivation: ~120 ms one-time on
tene initor keychain miss. - XChaCha20-Poly1305 encrypt of a typical 64-byte secret: ~0.5 microseconds.
- Vault read (SQLite + decrypt for 10 secrets): single-digit milliseconds.
For a CLI this is invisible. Most of the total latency in tene run
comes from fork+exec of the child process, not from crypto.
What could go wrong
The realistic risks are not crypto-algorithmic:
- Weak master password — mitigated by Argon2id's 64 MB memory cost.
- Keychain compromise — mitigated by the option to disable keychain
and re-enter the master password per invocation (
--no-keychain). - Process memory dump — acknowledged; any secret manager that exposes env vars at runtime has this property. Memory zeroing after use helps but does not eliminate.
- Operator error — running
tene get KEYinside a logged shell or AI session. Fixed by tooling (CLAUDE.md rules) not by crypto.
Summary
For a local-first vault written in Go and shipped as a single static binary:
- ChaCha20's ARX design beats AES in portable software.
- XChaCha's 192-bit nonce eliminates the "never reuse a nonce" caveat.
- Poly1305 provides authenticated encryption with a low-cost tag.
- AAD tied to the secret name prevents ciphertext-swap attacks.
- Every primitive comes from a published standard and an audited library.
That is the answer to "why XChaCha20-Poly1305" in one page. The
implementation lives in pkg/crypto/ of the tene repo if you want to
read the actual code.