A short answer up front
tene encrypts your secrets with a recipe called XChaCha20-Poly1305. This post explains why we picked that over the more famous AES-GCM, in plain language. If you only want the headline: ChaCha20 is fast on every laptop, the "X" version lets us pick nonces at random without worrying about clashes, and Poly1305 catches anyone who tries to edit the file.
You don't need a crypto background to follow along. There's a short glossary at the end for the words that look scary.
What a local vault actually has to do
Before picking an algorithm, write down the job:
- Store a value on disk so it can't be read without the master password.
- If anyone edits the file, the next read must fail loudly.
- Don't burn CPU. Don't add complicated code that might hide bugs.
- Ship as one static file (a CLI users can drop into their
PATH).
That's the four-bullet contract. Three modern recipes can meet it:
| Recipe | Strengths | Weakness |
|---|---|---|
| AES-256-GCM | Industry standard, fast on chips with the AES helper | Tricky to write safely without that helper |
| ChaCha20-Poly1305 | Fast in plain software, simple to write safely | 96-bit nonce — you have to pick nonces carefully |
| XChaCha20-Poly1305 | Same as above, but with a 192-bit nonce so collisions stop being a worry | Slightly newer (still well-audited) |
tene picked the third one. The next two sections explain why.
Why we skipped AES-GCM
AES is brilliant when the CPU has a tiny built-in helper called AES-NI. Every recent Mac, Intel, and AMD chip has one. With it, AES-GCM is fast and safe.
Without that helper — say, a small ARM device or a stripped-down container — software AES has a dangerous quirk. To stay safe against timing attacks (a sneaky way of guessing keys by measuring how long the code runs), the code has to avoid certain shortcuts. Those workarounds are easy to get wrong, and the bugs are subtle.
We didn't want a binary that's "fast and safe on most laptops, slow or risky on the rest". We wanted one answer that holds everywhere.
ChaCha20 is built from three operations only: add, rotate, and XOR. There are no shortcuts to avoid. Writing it safely is the obvious thing to do. That's the deciding factor for a portable CLI.
Why we use the "X" version
Plain ChaCha20-Poly1305 takes a 96-bit number called a nonce with each message. The nonce must never repeat for the same key — repeat once and the math falls apart.
Ninety-six bits sounds like a lot, but if you pick nonces at random, the math says clashes start to be a worry around 2⁴⁸ messages. That's a huge budget for most apps. But "safe if you keep careful count" is exactly what we wanted to avoid.
The "X" in XChaCha20 stretches the nonce to 192 bits. With 192 bits the collision risk is around 2⁹⁶, which is "the universe will end first" territory. We pick a brand-new random nonce every time and stop worrying.
For a vault that lives on a developer laptop for a decade — being re-encrypted who knows how many times — that peace of mind is worth a lot more than the few extra bytes per record.
The full chain, top to bottom
Here's everything that happens when tene saves a secret:
Master Password
↓ Argon2id (64 MiB memory, 3 iterations, your 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 vaultEvery box is a published standard:
- Argon2id is the password-stretcher. Standard: RFC 9106.
- HKDF turns the master key into per-environment keys. Standard: RFC 5869.
- XChaCha20-Poly1305 does the actual encryption. Standard: draft-irtf-cfrg-xchacha-03.
- SQLite is the storage format. Stable for two decades.
No homebrew steps, no clever tricks.

Why we mix the secret's name into the math
Here's a small but important trick. When tene encrypts STRIPE_KEY, it
doesn't only encrypt the value. It also feeds the name STRIPE_KEY into
the AEAD as so-called extra data.
Why bother? Imagine someone with write access to the vault file. They
copy the encrypted blob from the row labelled STRIPE_KEY into the row
labelled DATABASE_URL. Without the name-mixing trick, decryption would
succeed and your app would happily use the wrong secret.
With the name mixed in, that swap fails. Decrypting DATABASE_URL
expects a blob that was sealed under the name DATABASE_URL. Anything
else is rejected. The attacker would have to forge a valid Poly1305
tag — a problem cryptographers consider essentially impossible.
The code, in nine lines
This is the actual encrypt path in 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
}
// Glue the nonce on the front so decrypt can read it back.
return aead.Seal(nonce, nonce, plaintext, []byte(name)), nil
}Nine lines. No invented modes, no padding tricks, no branching for hardware variants. The library does the math; we just hand it the right inputs.
What we deliberately did NOT do
A lot of crypto bugs come from "improvements" that turn out to be weaknesses. The list of things we skipped is short on purpose:
- No homemade password-to-key step. We use the standard
argon2package. - No separate nonce file. The nonce sits at the front of each ciphertext — the boring, standard convention.
- No key reuse across environments. Dev, staging, and prod each get their own key from HKDF.
- No custom "tweaks" or "modes". Straight XChaCha20-Poly1305, exactly as written down in the spec.
If your secret manager's encryption page is exciting reading, that's a red flag, not a feature.
How fast is it?
Numbers from an M2 MacBook Air:
| Step | Time |
|---|---|
| Argon2id key derivation (one-time, 64 MiB / 3 iters) | ~120 ms |
| Encrypt one 64-byte secret | ~0.5 µs |
| Read 10 secrets from SQLite + decrypt | single-digit ms |
For a CLI this is invisible. Most of tene run's wall time goes to
starting the child process, not to crypto.
What could still go wrong
The real risks aren't in the math. They're in how people use it:
- A weak master password. Argon2id's 64 MB memory cost makes guessing painful, but a four-letter password is still a four-letter password.
- A compromised OS keychain. If your keychain is owned, the cached
master key is too. The fix is
--no-keychain(re-enter the password each time) for high-stakes runs. - Process memory dump. Any tool that hands secrets to a child as environment variables has this property. Memory wiping helps but isn't perfect.
- Operator error. Running
tene get KEYinside a recorded shell or an AI chat. The fix is tooling (CLAUDE.md rules), not crypto.
None of these are about XChaCha20 being weak. They're the boring, realistic threats every secret manager has to talk about honestly.
Summary
For a local-first vault written in Go and shipped as one static binary:
- ChaCha20's add-rotate-XOR design beats AES on portable software.
- The "X" version's 192-bit nonce removes the "never reuse a nonce" gotcha.
- Poly1305 catches anyone who edits the ciphertext.
- Mixing the secret's name into the math blocks ciphertext-swap attacks.
- Every step comes from a published standard and a reviewed library.
That's the whole answer. The actual code lives in pkg/crypto/ of the
tene repo if you want to read it.
Terms used in this post
XChaCha20-Poly1305 — An encryption recipe. The first half scrambles the data; the second half adds a tamper check. Works fast in plain software.
AES-GCM — The other popular recipe. Faster than ChaCha20 when the CPU has a built-in AES helper. Slower and trickier to write safely without one.
Nonce — A "number used once". A short random value that goes in with each message so the same secret never produces the same ciphertext twice.
AAD (Additional Authenticated Data) — Extra data — like the secret's name — that gets mixed into the tamper check but isn't itself encrypted. Lets the receiver detect swap attacks.
AEAD — "Authenticated Encryption with Associated Data". The umbrella term for any recipe (like ChaCha20-Poly1305) that does both encryption and tamper detection in one step.
Argon2id — A password-stretcher. Turns a short master password into a long key, while burning enough memory and CPU that guessing attacks become expensive.
HKDF — Key Derivation Function. Takes one master key and produces several child keys, each labelled for a specific use (e.g., one per environment).
Constant-time code — Code that runs in the same amount of time no matter what the secret inputs are. Stops attackers from guessing the key by stopwatching the program.
Birthday bound — A counting result. Roughly: with N possible nonces, you expect a random clash after about √N picks. For a 96-bit nonce that's 2⁴⁸, for a 192-bit nonce it's 2⁹⁶.
FAQ
Is XChaCha20-Poly1305 better than AES-GCM?
On a CPU without an AES hardware booster, ChaCha20 is faster and easier to write safely. For a local CLI vault on a developer laptop, XChaCha20-Poly1305 is the simpler, more portable pick.
Why not just use libsodium?
tene uses golang.org/x/crypto/chacha20poly1305, a reviewed Go library. libsodium would force a C dependency, which makes it harder to ship tene as a single static binary.
What does 'extended nonce' buy you?
XChaCha20 uses a 192-bit nonce instead of the regular 96. With 192 bits you can pick nonces at random and never have to think about clashes. With 96 bits you have to be careful.
Related reading: