Small, real implementations of the crypto primitives you actually reach
for, built directly on Node's crypto module. No third-party dependency,
no home-grown cipher — just a sane API around what Node already ships.
Not audited. This is for general-purpose application use (encrypting config, signing tokens, protecting stored secrets), not for life-safety or regulatory-compliance systems. Get a real audit before using this for anything with legal or safety consequences.
npm install @ferrow/encryption-utilsimport { encrypt, decryptToString, deriveKey, generateSalt } from "encryption-utils";
const salt = generateSalt();
const key = await deriveKey("a user passphrase", { salt }); // 32-byte AES-256 key
const payload = encrypt("secret message", key);
// store `payload` and `salt` (salt is not secret, but is required to re-derive the key)
const plaintext = decryptToString(payload, key);encrypt(plaintext: string | Buffer, key: Buffer): stringEncrypts with a random 96-bit IV. Returns a single base64 string:base64(iv[12] || ciphertext || authTag[16])— one value to store or transmit, no separate IV/tag bookkeeping.decrypt(payload: string, key: Buffer): BufferThrows if the auth tag doesn't verify (wrong key or tampered ciphertext).decryptToString(payload: string, key: Buffer): stringConvenience wrapper returning utf8.
key must be exactly 32 bytes (AES-256). Use deriveKey to get one from
a passphrase, or randomBytes(32) for a machine-generated key.
deriveKey(passphrase: string, options: DeriveKeyOptions): Promise<Buffer>DeriveKeyOptions:{ salt: Buffer, keyLength?: 32, N?: 16384, r?: 8, p?: 1 }.maxmemis computed internally and raised automatically for largerN/rso Node's default 32MB scrypt ceiling doesn't surprise you.generateSalt(length = 16): Buffer
hmacSign(data: string | Buffer, key: Buffer | string): string— hex digest.hmacVerify(data, signature: string, key): boolean— timing-safe comparison viacrypto.timingSafeEqual; returnsfalse(never throws) for malformed input.
generateKeyPair(modulusLength = 2048): Promise<{ publicKey, privateKey }>PEM-encoded, SPKI/PKCS8.rsaEncrypt(plaintext: string | Buffer, publicKeyPem: string): string— base64.rsaDecrypt(payload: string, privateKeyPem: string): Buffer
Both use OAEP padding with SHA-256, matching modern defaults (RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"). RSA-OAEP payload size is bounded by the key size
(~190 bytes of plaintext for a 2048-bit key) — for larger payloads,
encrypt the data with AES-256-GCM and use RSA only to wrap the AES key.
Every function here is a thin, explicit wrapper over Node's built-in
crypto — no bundled cipher implementation, no dependency to audit
beyond Node itself. The AES payload format packs IV + ciphertext + auth
tag into one base64 string specifically so callers can't forget to store
the IV or tag separately (a common source of "it encrypted fine but I
can't decrypt it" bugs). hmacVerify uses timingSafeEqual rather than
=== because a naive string comparison leaks timing information an
attacker can use to forge signatures byte-by-byte.
Sponsored by Ferrow
Part of the ferrow-toolkit collection · Sponsored by Ferrow