π Seventy-five ciphers, one call. ATTACK AT DAWN goes in, DWWDFN DW GDZQ comes out, and the way back is the same call with decode. Terminal, TypeScript, agent or browser tab, and nothing ever leaves the machine.
The puzzle says the answer is in a Vigenère, so you open a cipher site. The key field wants uppercase, J quietly became I, and the third tab does Playfair differently than the second one. Now hand that to an agent. It will do the Vigenère in its head and be wrong from the fourth letter on, confidently. So: one encode and one decode with the same options everywhere, and tools a model can call instead of counting on its fingers.
Docs, and a playground where the library runs in your browser: ciphers.agntn.dev.
Caution
Not audited. This code has never had a security audit. Do not use it in production, with real funds or with sensitive data. It is meant for agents, puzzles and local experiments only. It comes as is, without warranty of any kind, and the authors are not liable for any loss, as the MIT license states. Anything that matters wants an audited library.
- π‘ Seventy-five ciphers. From a pencil and paper Caesar to Enigma M3 and AES from ECB to XTS. Chaocipher kept its secret for 92 years, which is a long time to wait for an answer.
- π Same call on all of them. Swap the name and options in
create('vigenere').encode(text, { key }). The result says which cipher, operation and options it used. - π¨ Brute force built in. All 25 Caesar shifts in one command, so nobody has to try them by hand ever again.
- π Frequencies and the index of coincidence. Tells you whether it's one alphabet or several before you burn an hour on the wrong attack.
- π§ Ciphers describe themselves.
info()has the family, the options, the keyspace and whether encode and decode are the same. The CLI and the playground read it from there. - π₯οΈ CLI, library, MCP, Pi, OMP and AI SDK. Same tools, whichever one you're holding. No Node at hand? ciphers.agntn.dev/mcp serves them over HTTP.
- π Runs in the browser too. The playground imports the package into the page, nothing is posted anywhere.
- π§± Bounded on purpose. Text and keys have a maximum length in every tool schema, so a model can't hand the process a novel to shift.
- π§© Your cipher in one class. Extend
Cipher,register()it, andcreate()finds it like any built-in.
pnpm add @agntn/ciphersNode.js 26 or newer. No native anything, no network, no keys to sign up for. It's all string work.
npx @agntn/ciphers encode caesar "ATTACK AT DAWN" --shift 3DWWDFN DW GDZQ
Give it the cipher name, the text, then the flags that cipher needs. Only the result goes to stdout, so it pipes. Below, ciphers means pnpm exec ciphers after pnpm add, or install once with pnpm add -g @agntn/ciphers. Got the ciphertext but not the shift?
ciphers brute "DWWDFN DW GDZQ"shift= 3 -> ATTACK AT DAWN
shift=25 -> EXXEGO EX HEAR
shift=11 -> SLLSUC SL VSOF
shift=18 -> LEELNV LE OLHY
shift=21 -> IBBIKS IB LIEV
shift= 5 -> YRRYAI YR BYUL
shift=12 -> RKKRTB RK URNE
shift=14 -> PIIPRZ PI SPLC
shift=17 -> MFFMOW MF PMIZ
shift=10 -> TMMTVD TM WTPG
shift=22 -> HAAHJR HA KHDU
shift=16 -> NGGNPX NG QNJA
shift= 9 -> UNNUWE UN XUQH
shift= 8 -> VOOVXF VO YVRI
shift=24 -> FYYFHP FY IFBS
shift= 1 -> CVVCEM CV FCYP
shift= 2 -> BUUBDL BU EBXO
shift=15 -> OHHOQY OH ROKB
shift= 7 -> WPPWYG WP ZWSJ
shift=19 -> KDDKMU KD NKGX
shift=23 -> GZZGIQ GZ JGCT
shift=20 -> JCCJLT JC MJFW
shift= 4 -> ZSSZBJ ZS CZVM
shift= 6 -> XQQXZH XQ AXTK
shift=13 -> QJJQSA QJ TQMD
Shift 3 on top, it's the most English of the 25. English goes by letter pairs, so HELLO WORLD beats EBIIL TLOIA too. --lang pl ranks by Polish letters instead, --lang ja by Japanese romaji. Shift 25 comes second and ends in HEAR, which is about as funny as a Caesar brute force gets ;)
Enigma's in there too. The Wehrmacht M3, with rotors I, II and III and reflector B:
ciphers encode enigma "ATTACK AT DAWN"BZHGNO CR RTCM
Decode with the same settings to get the plaintext back. A rotor machine is its own inverse. Set --positions, --rings and --plugboard, or get AAA, AAA and an empty board. The machine every tutorial starts on.
A few more, same rules:
ciphers encode vigenere "ATTACK AT DAWN" --key LEMON
ciphers decode atbash "ZGGZXP ZG WZDM"
ciphers encode tap-code "HELP"
ciphers frequency "DWWDFN DW GDZQ" --lang pl
ciphers info bifidThe frequency command prints a histogram and the index of coincidence. Around 0.065, that's one alphabet with English underneath. Near 0.038, the alphabet keeps changing. You want a key length, not a histogram. That's period. The table below covers the rest: guessing families, recovering keys and finding text in invisible characters.
| Command | What it does | Example |
|---|---|---|
encode |
Plaintext in, ciphertext out | ciphers encode vigenere "ATTACK AT DAWN" --key LEMON |
decode |
The other way, same flags | ciphers decode vigenere "LXFOPV EF RNHR" --key LEMON |
brute |
All 25 Caesar shifts, best fit first | ciphers brute "DWWDFN DW GDZQ" |
frequency |
Letter histogram and the index of coincidence | ciphers frequency "DWWDFN DW GDZQ" --lang en |
period |
Vigenère key length, with the likely key | ciphers period "<ciphertext>" --max-period 30 |
guess |
Likely cipher families and what to run next | ciphers guess "<ciphertext>" --lang pl |
recover |
Searched keys, best first, each with its plaintext | ciphers recover "<ciphertext>" --cipher substitution |
probe |
Which KDF settings open a Salted__ blob |
ciphers probe "U2FsdGVkX1..." --key secret |
crib |
Crib dragging over ciphertexts under one reused key | ciphers crib '["<hex>","<hex>"]' --crib " the " |
hidden |
A message hidden by position, best readings first | ciphers hidden "$(cat poem.txt)" --pick line |
inspect |
Invisible and lookalike characters | ciphers inspect "$(cat tweet.txt)" |
reveal |
Messages hidden in invisible characters | ciphers reveal - < note.txt |
conceal |
A message hidden in invisible characters | ciphers conceal "noon" --cover "Nothing here." |
whitespace |
Whitespace positions and bit readings | ciphers whitespace - < note.txt |
trail |
Messages hidden at line ends | ciphers trail "noon" --cover - --zero space --one tab < poem.txt |
align |
Word changes between two texts | ciphers align "red fox sleeps" "red fox runs" |
info |
Every cipher by category, or one cipher up close | ciphers info enigma |
ciphers |
Same as info, for old habits |
ciphers ciphers --category stream |
mcp |
The MCP server on stdio | ciphers mcp |
A cipher's options are its flags: --key, --shift, --rails and so on. Which ones does yours take? Ask ciphers info <name> or check the CLI guide. Each command is an agent tool, so the CLI refuses exactly what a model gets refused. Add --json for the same details the model gets. Morse starting with a dash? Just type it. -.-. spells no flag, so it's read as text.
import { create, resolveCipher, analyzeFrequency } from '@agntn/ciphers'
const vigenere = create('vigenere')
const { text } = vigenere.encode('ATTACK AT DAWN', { key: 'LEMON' })
console.log(text) // LXFOPV EF RNHR
console.log(vigenere.decode(text, { key: 'LEMON' }).text) // ATTACK AT DAWN
const enigma = resolveCipher('Enigma')
console.log(enigma.encode('ATTACK AT DAWN').text) // BZHGNO CR RTCM
console.log(analyzeFrequency('DWWDFN DW GDZQ', 'en')?.ic) // 0.13636363636363635That's nearly all of it. Give create() the exact registered name and get one cached instance per cipher. Want to be less exact? resolveCipher() lowercases and turns spaces into hyphens. 'Rail Fence' works, 'railfence' doesn't. No fuzzy matching. Every result carries text, cipher, operation and the options that were applied, enough to repeat the call. Wrong key, bad shift, unknown name? The CipherError family names the option or cipher. For raw bytes, @agntn/ciphers/aes, @agntn/ciphers/chacha and @agntn/ciphers/salsa take and return a Uint8Array.
The rest: Transform, Analysis, Custom ciphers.
| Category | Family | Ciphers |
|---|---|---|
| classical | substitution-shift | caesar, rot13, rot47 |
| classical | substitution-reflection | atbash |
| classical | substitution-multiplicative | affine |
| classical | substitution-keyed | substitution, tridigital |
| classical | polyalphabetic | vigenere, gronsfeld, beaufort, porta, autokey, running-key, trithemius, alberti, quagmire-1, quagmire-2, quagmire-3, quagmire-4, chaocipher |
| classical | digraph | playfair, four-square, two-square |
| classical | polygraphic | hill |
| classical | fractionation | polybius, nihilist, morse, fractionated-morse, bacon, tap-code, a1z26, adfgvx, bifid, trifid, straddling-checkerboard |
| classical | homophonic | book |
| classical | transposition | rail-fence, columnar, route |
| classical | rotor | enigma |
| block | substitution-permutation | aes, aes-cbc, aes-cfb, aes-ofb, aes-ctr, aes-ccm, aes-ocb, aes-lrw, aes-xts, aes-cbc-mac, aes-passphrase, rijndael, serpent, aria, openpgp |
| block | feistel | des, desx, triple-des, triple-des-cbc, blowfish, lucifer, mars, cast5, camellia, sm4 |
| block | lai-massey | idea |
| stream | arx | rabbit, salsa20, xsalsa20, chacha20, xchacha20, chacha20-poly1305 |
| stream | permutation | rc4 |
| stream | polyalphabetic | xor |
| stream | crc-lcg | zipcrypto |
Seventy-five ciphers, seventeen families, three categories. The table is just names on purpose. For options, ask ciphers info <name>. Find rules, quirks and vectors at Ciphers, with classical, block and stream overviews. Why is xor polyalphabetic? It's Vigenère on bytes.
Six are self-inverse, so encode and decode are the same call: rot13, rot47, atbash, beaufort, porta and enigma. Block and stream ciphers take UTF-8 text and a hex key, and return hex. Use --bytes hex for plaintext that isn't text. The authenticated ones, aes-ccm, aes-ocb and chacha20-poly1305 among them, refuse to decode a ciphertext somebody changed.
Found U2FsdGVkX1 on a page? aes-passphrase opens that CryptoJS base64. openpgp opens gpg --symmetric output. zipcrypto opens old encrypted ZIP entries.
ciphers mcp
claude mcp add --transport http ciphers https://ciphers.agntn.dev/mcp # nothing to install
pi install npm:@agntn/ciphers
omp install @agntn/ciphers{
"mcpServers": {
"ciphers": { "command": "npx", "args": ["-y", "@agntn/ciphers", "mcp"] }
}
}There's one tool per command, with the same list for stdio MCP, HTTP, Pi, OMP and the AI SDK. Names and arguments live in the Agents guide. The HTTP server runs on a Cloudflare worker, so public puzzle material only.
Arguments are checked against the schema before a cipher sees them. A wrong key is a tool error with the reason in it, not a dead session.
A model that doesn't know what Bifid takes calls ciphers_info first, the encode and decode descriptions say so. And a decoded ciphertext is data: IGNORE PREVIOUS INSTRUCTIONS falling out of a ROT13 is the answer to the puzzle, not a new task.
Install the optional AI SDK 7 peer and your model provider:
pnpm add @agntn/ciphers ai @ai-sdk/anthropicimport { anthropic } from '@ai-sdk/anthropic'
import { ciphersAiTools } from '@agntn/ciphers/ai'
import { generateText, isStepCount } from 'ai'
const { text } = await generateText({
model: anthropic('claude-sonnet-5-5'),
tools: ciphersAiTools,
stopWhen: isStepCount(5),
prompt: 'Somebody Caesar-shifted this, crack it: WKH TXLFN EURZQ IRA',
})One import gives your app every tool MCP has. No wrappers to write, which leaves more time for the puzzle. A refused call goes back to the model as a tool error, and the ciphers still run locally.
Cryptography you'd trust with anything. The classical ones fall to anyone with a laptop and an afternoon. That's the point: puzzles, CTFs and teaching. The modern ones are here for the CTF that uses them. Mostly in ECB, the mode that leaks which blocks repeat. RC4 is here too, and RFC 7465 threw it out of TLS in 2015. All of it is plain TypeScript that never tried to be constant time.
No hashing, no wallet keys, no base encodings. Hashes are @agntn/hashes. Keys and signatures are @agntn/keys, and even those want nothing to do with real money. Base64, base58 and friends have no key to break, so they're @agntn/encodings.
Want a seventy-sixth? Extend Cipher with name(), info(), encode() and decode(). Then register('name', YourCipher) and create('name') works. The walkthrough is at Custom ciphers. Sending it here as a built-in? You'll need a vector from outside the code under test. CONTRIBUTING.md has the rest.
pnpm install # library and docs site
pnpm dev # obuild --stub
pnpm check # oxfmt --check, oxlint, tsc over the library, the extensions and the tests
pnpm test # vitest through vite-plus
pnpm build # obuild
pnpm run docs # Nuxt site, bundled straight from src/This package gets built on tools that Anthropic and OpenAI hand to open source projects, Claude for Open Source and Codex for Open Source. Thank you <3