- TypeScript 100%
Introduce a versioned ciphertext format with a per-message HKDF salt and key commitment that is checked before decryption. Add an optional aad input, and use WebCrypto for AES-256-GCM to avoid table-based AES. AES-256-GCM-SIV stays on @noble/ciphers because neither WebCrypto nor Node provides it. BREAKING CHANGE: decrypt rejects 0.1.x ciphertext unless legacy: true is set. AES-256-GCM now requires crypto.subtle. |
||
|---|---|---|
| src | ||
| tests | ||
| .gitignore | ||
| .npmrc | ||
| LICENSE | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| tsconfig.json | ||
| tsdown.config.ts | ||
minicrypt
Simple TypeScript library for symmetric encryption.
Provides four authenticated encryption ciphers:
- AES-256-GCM — Standard and widely interoperable
- AES-256-GCM-SIV — Nonce-misuse-resistant variant of AES-GCM
- ChaCha20-Poly1305 — Fast software cipher, used in TLS 1.3 and WireGuard
- XChaCha20-Poly1305 — Extended-nonce variant, safe with random nonces
Warning
This library is designed for encryption/decryption of data, not for hashing or storing passwords. Use a dedicated password-hashing function (Argon2id, bcrypt, scrypt) for password storage.
Installation
pnpm add minicrypt
Important
The
keyparameter is not an encryption key — it is a secret string that gets derived into a 256-bit key via HKDF-SHA256 (RFC 5869) with domain separation. Use a high-entropy secret (e.g.,crypto.randomBytes(32)hex-encoded, or a strong passphrase). Low-entropy secrets like"my-password"are trivially brute-forced — HKDF is a key-derivation function, not a password hasher. If you need to use a user-provided password, hash it through Argon2id / scrypt / bcrypt first, then use the result as the key.
Usage
AES-256-GCM
Standard and widely interoperable.
import { encrypt, decrypt } from "minicrypt/aes-256-gcm";
import crypto from "crypto";
// Generate a high-entropy secret
const key = crypto.randomBytes(32).toString("hex");
const encrypted = await encrypt({ key, data: "Hello, World!" });
const decrypted = await decrypt({ key, data: encrypted });
AES-256-GCM-SIV
Nonce-misuse-resistant: if a nonce is reused, only reveals whether plaintexts are identical.
import { encrypt, decrypt } from "minicrypt/aes-256-gcm-siv";
import crypto from "crypto";
const key = crypto.randomBytes(32).toString("hex");
const encrypted = await encrypt({ key, data: "Hello, World!" });
const decrypted = await decrypt({ key, data: encrypted });
ChaCha20-Poly1305
Standard 12-byte nonce variant used in TLS 1.3, WireGuard, and SSH.
import { encrypt, decrypt } from "minicrypt/chacha20-poly1305";
import crypto from "crypto";
const key = crypto.randomBytes(32).toString("hex");
const encrypted = await encrypt({ key, data: "Hello, World!" });
const decrypted = await decrypt({ key, data: encrypted });
XChaCha20-Poly1305
Extended 24-byte nonce — safe to use with randomly generated nonces.
import { encrypt, decrypt } from "minicrypt/xchacha20-poly1305";
import crypto from "crypto";
const key = crypto.randomBytes(32).toString("hex");
const encrypted = await encrypt({ key, data: "Hello, World!" });
const decrypted = await decrypt({ key, data: encrypted });
API
All modules export the same async interface:
encrypt(options: CryptoOptions): Promise<string>
- options.key — The secret string used as HKDF-SHA256 input key material
- options.data — The plaintext string to encrypt (must be non-empty, with no unpaired surrogates)
- options.info — Optional HKDF
infocontext; must match between encrypt and decrypt (see Security notes) - options.aad — Optional associated data, authenticated but not stored in the output
- Returns — Hex-encoded ciphertext:
0x02 || salt || commitment || nonce || ciphertext || tag
decrypt(options: CryptoOptions): Promise<string>
- options.key — The secret string (must match the one used for encryption)
- options.data — The hex-encoded ciphertext to decrypt
- options.info — Optional HKDF
infocontext; must match between encrypt and decrypt (see Security notes) - Returns — The decrypted plaintext string
- options.aad — Must match the value used for encryption
- options.legacy — Set to
trueto read minicrypt 0.1.x ciphertext (migration only) - Throws — If decryption fails (wrong key,
infooraad, tampered or malformed data)
Choosing a Cipher
| Cipher | Nonce Size | Best For |
|---|---|---|
| AES-256-GCM | 12 bytes | Interoperating with AES-GCM systems |
| AES-256-GCM-SIV | 12 bytes | When nonce uniqueness isn't guaranteed |
| ChaCha20-Poly1305 | 12 bytes | Software-only environments, mobile |
| XChaCha20-Poly1305 | 24 bytes | Random nonces, high-volume encryption |
XChaCha20-Poly1305 is the recommended default unless you need interoperability with a specific cipher.
Security Notes
- AES-256-GCM uses native AES through WebCrypto. It needs
crypto.subtle, which browsers only expose on secure origins. - AES-256-GCM-SIV is pure JavaScript. WebCrypto and Node have no GCM-SIV API, so it uses @noble/ciphers, whose AES T-tables can leak cache timings. If an attacker can observe timing on shared hardware, use another cipher.
- Per-message keys. Each message uses a key derived from a random 256-bit salt, so the 2²³ random-nonce limit on 12-byte-nonce ciphers does not apply.
infois combined with the cipher domain. The samekeyandinfoderive different keys for different ciphers.- Key-committing. Ciphertexts include an HKDF-SHA256 commitment, checked before decryption, so a ciphertext cannot be crafted to decrypt under two secrets.
legacy: trueskips this, so use it only to migrate. - Associated data. Pass
aad(such as a record ID) to bind a ciphertext to its context, so it cannot be moved to another record. - Text only. Input is UTF-8 encoded, and strings with unpaired surrogates are rejected.
Upgrading from 0.1.x
0.2 uses a new key-committing format, and decrypt rejects 0.1.x ciphertext by default. To migrate, read old data with legacy: true and encrypt it again:
const plaintext = await decrypt({ key, data: oldCiphertext, legacy: true });
const upgraded = await encrypt({ key, data: plaintext });
Pass the same info you used with 0.1.x. Remove legacy: true once your data is migrated, because accepting legacy ciphertext lets an attacker bypass key commitment.
Development
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build library
pnpm build
# Type check
pnpm typecheck
Credits
- Uses @noble/ciphers for AEAD cipher implementations
- Uses @noble/hashes for HKDF-SHA256 key derivation
License
MIT