Guide

Getting Started

Install, encode one line, decode it back. Same shape from every cipher.
Pre-1.0. API and tool list can still move. Pin exact versions if you build on it now.
Classical ciphers protect nothing. Lessons, riddles, CTFs - yes. Secrets - no. Anyone with a laptop breaks every cipher on this site in seconds. Use a real primitive for anything that matters.

Why this exists

A puzzle says "the key is LEMON" and I want the plaintext, not a lecture. An agent gets a ROT-47 string in a challenge and needs to flip it without leaving the process. Each cipher is small, but every one of them has conventions that decide whether your answer matches the author's: does Playfair fold J into I, does Bacon use 24 or 26 letters, which rotors is "Enigma" even. Every online tool picks something different and rarely says what.

So @agntn/ciphers picks once, writes it down, and gives every cipher the same two methods. No HTTP, no keys, no telemetry. It runs in your process and that's it.

Install

pnpm add @agntn/ciphers

First call

vigenere.ts
import { create } from "@agntn/ciphers";

const vigenere = create("vigenere");

const encoded = vigenere.encode("ATTACK AT DAWN", { key: "LEMON" });
console.log(encoded.text); // LXFOPV EF RNHR

const decoded = vigenere.decode(encoded.text, { key: "LEMON" });
console.log(decoded.text); // ATTACK AT DAWN

Importing the package registers all eighteen ciphers. create(name) gives you one cached instance and wants the exact registry name. resolveCipher("Rail Fence") lowercases, turns spaces into hyphens, and then also wants an exact match. No prefixes, no fuzzy matching, a typo is an UnknownCipherError. Guessing which cipher you meant is how you get the wrong plaintext with a straight face.

The shape

Every encode and decode returns this:

interface CipherResult {
  text: string; // the output
  cipher: string; // "vigenere"
  operation: "encode" | "decode";
  options: Record<string, unknown>; // what was actually applied, defaults included
  normalizedInput?: string; // optional, for a cipher that reshapes its input; the built-ins leave it out
}

Two options every cipher understands: preserveCase (default true) keeps lowercase letters lowercase, stripNonAlpha (default false) drops everything that is not a letter before the cipher runs. The rest is per cipher - shift, key, rails, a, b, period, positions, rings, plugboard. Each cipher's page lists its own, and so does create(name).info().

What ships

CipherFamilyOptionsSelf-inverse
caesarshiftshift (1 to 25, default 3)
rot13shift
rot47shift
atbashreflection
vigenerepolyalphabetickey
trithemiuspolyalphabetic
albertipolyalphabetickey, period
affinemultiplicativea (default 5), b (default 8)
playfairdigraphkey
polybiusfractionationkey optional
morsefractionation
baconfractionation
tap-codefractionation
adfgvxfractionationkey optional
bifidfractionationkey optional, period (default 5)
rail-fencetranspositionrails (default 3)
columnartranspositionkey
enigmarotorpositions, rings, plugboard

Errors

import { CipherError, InvalidOptionError, MissingOptionError, UnknownCipherError } from "@agntn/ciphers";

try {
  create("caesar").encode("HELLO", { shift: 26 });
} catch (error) {
  if (error instanceof InvalidOptionError) {
    error.option; // "shift"
    error.reason; // why 26 is not a shift
  }
}

No key where a key is required - MissingOptionError. Value out of range - InvalidOptionError with the option, the value and the reason. Anything else a cipher throws gets wrapped by normalizeError into a CipherError that names the cipher. The CLI prints the message and exits 1. No stack trace, you did nothing wrong, the input did.

Next

@agntn/ciphers·MIT license· Classical ciphers, for lessons and puzzles. Not for protecting anything, ever.