Authenticated encryption, webhook signing, and secure random for Node.js — wrapped around
node:cryptowith safe defaults and byte-for-byte interop with the Go counterpart atgithub.com/ubgo/crypt.
@ubgo/crypt (also known as crypt-ts) is a batteries-included TypeScript / Node.js cryptography library: AES-256-GCM and ChaCha20-Poly1305 authenticated encryption (AEAD), HMAC and Ed25519 signing, HKDF key derivation, graceful key rotation, time-locked tokens, and X25519 sealed-box asymmetric encryption — with safe defaults, zero runtime dependencies, dual ESM + CJS output, strict types, and a versioned wire format that is byte-for-byte interoperable with the Go package github.com/ubgo/crypt.
import { seal, open, randomBytes, AEAD_KEY_SIZE } from "@ubgo/crypt"
const key = randomBytes(AEAD_KEY_SIZE)
const ct = seal(key, "hello, world")
const pt = open(key, ct).toString("utf8")
// pt === "hello, world"That's the whole API for the most common case.
- Is this for you?
- 30-second tour
- Why @ubgo/crypt?
- What's included
- API at a glance
- Binding & rotating keys — design notes
- FAQ
- Documentation
- Cross-language with the Go counterpart
- Install
- Status
- Reporting vulnerabilities
- License
@ubgo/crypt is built for Node.js applications that need a curated set of cryptography primitives done well, with safe defaults, no foot-guns, and (optionally) byte-for-byte interop with a Go service. Reach for it when you're about to write any of the following:
- Encrypt a value before storing it (database column, cookie, file) and decrypt it back later.
- Sign outgoing webhooks and verify incoming ones — HMAC-SHA256 or Ed25519 (public-key).
- Generate cryptographically-random API keys, magic-link tokens, CSRF tokens.
- Issue stateless time-locked tokens (password reset, email verify, magic login) with embedded expiry.
- Decrypt in Node what a Go service encrypted (or vice versa) — same wire format.
- Compare an API key in constant time without leaking timing.
- Interoperate with an existing AES-CBC system, or read ciphertext you already wrote in CBC.
- Derive per-tenant or per-purpose sub-keys from a single master with HKDF.
- Rotate keys gracefully —
KeyRingwith embedded kid; old data still readable, new writes use the active key. - Use ChaCha20-Poly1305 instead of AES-GCM (no AES-NI hardware, or defense-in-depth diversity).
- Encrypt to a recipient's public key — X25519 + ChaCha20-Poly1305 (sealed-box), age-style.
- Sign with Ed25519 — public-key signatures where verifiers don't share the signing key.
- Stop fighting Node's mutable Cipher API and use a clean wrapper.
If any of those are on your plate, this is the package.
Not for you if: you need browser/WebCrypto (this targets Node.js only), JWT/JOSE (use @panva/jose), TLS, PKI, password hashing in Node (do it server-side in Go via the Go counterpart's HashPassword), or KMS adapters / streaming AEAD (Go-only).
import { Sealer } from "@ubgo/crypt"
const sealer = new Sealer(loadAppKey()) // 32 bytes from env / secrets manager
const enc = sealer.seal("sk_live_4242deadbeef")
await db.query(`UPDATE partners SET secret = $1 WHERE id = $2`, [enc, id])
const plain = sealer.open(row.secret).toString("utf8")const ct = sealer.seal(payload, Buffer.from(`user:${userID}`))
const pt = sealer.open(ct, Buffer.from(`user:${userID}`))
// throws TamperedError if userID differs from issue timeimport { sign, verify } from "@ubgo/crypt"
const mac = sign(secret, body) // signer
const ok = verify(secret, body, mac) // verifier (constant-time)import { randomToken } from "@ubgo/crypt"
const apiKey = randomToken(32) // 43-char URL-safe string// Go side
ct, _ := crypt.Seal(sharedKey, payload, nil)
return ct// Node side, this package
import { open } from "@ubgo/crypt"
const plaintext = open(sharedKey, ct)Same wire format, byte-for-byte. Verified by shared test vectors in CI.
The previous implementation in our codebase, aitoolscrypt.ts, was hand-rolled around node:crypto and had two latent bugs that silently corrupted any plaintext longer than 16 bytes (one AES block):
// BUG 1: passes the full hex including IV to update()
const _decrypted = decipher.update(cipherText, "hex", "utf8")
// BUG 2: discards _decrypted, returns only final()
return decipher.final("utf8")The Cipher API in Node is mutable-builder — update() returns part of the output and final() returns the rest. Forgetting to concatenate is a quiet bug, with no error and no warning. The original author tested with 16-byte test data, which happened to land on the boundary where the bug doesn't manifest, and shipped.
The fix isn't more careful hand-rolling. It's one well-tested wrapper that removes the foot-gun:
import { open } from "@ubgo/crypt"
const plaintext = open(key, ciphertext)
// Internally handles update/final correctly. Caller cannot get this wrong.Plus a sibling in Go using the same wire format, with a shared test vector file enforcing parity in CI. That's @ubgo/crypt.
| @ubgo/crypt | node:crypto (DIY) |
Hand-rolled wrappers | |
|---|---|---|---|
| Authenticated encryption by default | ✅ AEAD out of the box | ||
No update()/final() footgun |
✅ handled internally | ❌ mutable Cipher, easy to drop bytes | |
| Versioned wire format (safe upgrades) | ✅ | ❌ | ❌ |
| Cross-language (Node ↔ Go) byte parity | ✅ shared vectors in CI | ❌ | |
| Key rotation with embedded kid | ✅ KeyRing |
❌ | ❌ |
| Constant-time compare wired in | ✅ | timingSafeEqual |
❌ |
| Strict types + dual ESM/CJS | ✅ | ||
| Runtime dependencies | ✅ none (node:crypto only) |
✅ |
If you want to wire the primitives yourself, node:crypto is right there. If you want the correct assembly — authenticated, versioned, rotatable, and readable from Go — that's @ubgo/crypt.
Authenticated encryption (AES-256-GCM) — seal, open, Sealer. Modern AEAD with a versioned wire format so future algorithms slot in without breaking decrypt of old data.
HMAC signing — sign, verify. Constant-time verification.
Secure random — randomBytes, randomToken (URL-safe base64), randomHex. Node CSPRNG.
Constant-time compare — constantTimeEqual. Wraps crypto.timingSafeEqual.
AES-CBC at @ubgo/crypt — encryptCbc, decryptCbc (16/24/32-byte keys for AES-128/192/256). First-class peer of AES-GCM; use it when interop with an existing AES-CBC system is required, or when reading ciphertext you already wrote in this format. CBC has no built-in authentication; pair with HMAC if you need tamper detection. An openAuto helper auto-detects AEAD vs CBC for migration scripts.
ChaCha20-Poly1305 AEAD — sealChaCha20, openChaCha20. Wire version 0x02. Use for hardware without AES-NI.
HKDF key derivation — deriveKey. Per-tenant or per-purpose sub-keys from a single master.
KeyRing for rotation — KeyRing class with embedded kid. Active key for new writes; retired keys remain readable until natural turnover.
Time-locked tokens — issueToken, verifyToken. Stateless one-time tokens with embedded expiry. Returns ExpiredError on expiry.
Ed25519 signatures — generateEd25519, signEd25519, verifyEd25519. Public-key signing where verifiers don't share the signing key.
Asymmetric encryption (sealed-box) — generateKeyPair, sealAsymmetric, openAsymmetric. X25519 + ChaCha20-Poly1305. Wire version 0x05.
Cross-language wire format — every AEAD and HMAC output is byte-identical to the Go counterpart at github.com/ubgo/crypt.
Strict TypeScript — full types, no any, dual ESM + CJS build.
Zero runtime dependencies — only node:crypto from the standard library.
Password hashing is intentionally not included; it's a server-side concern. Use the Go counterpart's HashPassword from your auth service, or pull argon2 directly if you must hash in Node.
// AEAD
function seal(key: Buffer | Uint8Array, plaintext: string | Buffer | Uint8Array, aad?: Buffer | Uint8Array): string
function open(key: Buffer | Uint8Array, ciphertext: string, aad?: Buffer | Uint8Array): Buffer
class Sealer {
constructor(key: Buffer | Uint8Array)
seal(plaintext: string | Buffer | Uint8Array, aad?: Buffer | Uint8Array): string
open(ciphertext: string, aad?: Buffer | Uint8Array): Buffer
}
// Random
function randomBytes(n: number): Buffer
function randomToken(n: number): string // URL-safe base64-no-pad
function randomHex(n: number): string
// Signing
function sign(key: Buffer | Uint8Array, data: Buffer | Uint8Array): Buffer
function verify(key: Buffer | Uint8Array, data: Buffer | Uint8Array, mac: Buffer | Uint8Array): boolean
function constantTimeEqual(a: Buffer | Uint8Array, b: Buffer | Uint8Array): boolean
// AES-CBC (16/24/32-byte keys; no built-in auth — pair with HMAC if needed)
import { encryptCbc, decryptCbc, openAuto } from "@ubgo/crypt"
// ChaCha20-Poly1305 (alternative AEAD; wire version 0x02)
function sealChaCha20(key: Buffer | Uint8Array, plaintext: string | Buffer | Uint8Array, aad?: Buffer | Uint8Array): string
function openChaCha20(key: Buffer | Uint8Array, ciphertext: string, aad?: Buffer | Uint8Array): Buffer
// HKDF key derivation
function deriveKey(masterKey: Buffer | Uint8Array, salt: Buffer | Uint8Array | undefined, info: Buffer | Uint8Array, length: number): Buffer
// KeyRing for rotation
class KeyRing {
constructor(activeKid: string, activeKey: Buffer | Uint8Array)
add(kid: string, key: Buffer | Uint8Array): void
remove(kid: string): void
setActive(kid: string): void
activeKid(): string
seal(plaintext: string | Buffer | Uint8Array, aad?: Buffer | Uint8Array): string
open(ciphertext: string, aad?: Buffer | Uint8Array): Buffer
}
// Time-locked tokens (ExpiredError on expiry)
function issueToken(key: Buffer | Uint8Array, payload: Buffer | string, ttlMs: number, aad?: Buffer | Uint8Array): string
function verifyToken(key: Buffer | Uint8Array, token: string, aad?: Buffer | Uint8Array): Buffer
// Ed25519 signatures
function generateEd25519(): { publicKey: Buffer; privateKey: Buffer }
function signEd25519(privateKey: Buffer | Uint8Array, data: Buffer | Uint8Array): Buffer
function verifyEd25519(publicKey: Buffer | Uint8Array, data: Buffer | Uint8Array, signature: Buffer | Uint8Array): boolean
// Asymmetric (X25519 + ChaCha20-Poly1305 sealed-box)
function generateKeyPair(): { publicKey: Buffer; privateKey: Buffer }
function sealAsymmetric(recipientPublicKey: Buffer | Uint8Array, plaintext: Buffer | string | Uint8Array): string
function openAsymmetric(recipientPrivateKey: Buffer | Uint8Array, ciphertext: string): BufferA few questions come up often enough to answer here, since the choices are deliberate (and match the Go counterpart).
"Can I set one global encrypt key instead of passing it everywhere?" Construct a Sealer once and reuse it. new Sealer(key) binds the key at construction; downstream code then calls sealer.seal(pt, aad) / sealer.open(ct, aad) with no key argument. There is intentionally no module-level default key and no setDefaultKey(): a global would be hidden mutable state — awkward to test (parallel test files share it), impossible to run two keys at once, and a silent zero-key footgun if used before it's set. Store the Sealer on your app/container/DI object instead of writing your own encryptToken(key, …) wrappers.
"Can a Sealer change its key on the fly?" No — a Sealer holds its key in a readonly #key private field with no setter, so it's effectively immutable. That's deliberate: an immutable sealer is trivially safe to share across your whole process, and a hard key swap would instantly make all previously-sealed ciphertext undecryptable.
"Then how do I rotate keys?" Use KeyRing: new writes use the active key, and reads dispatch by the kid embedded in each ciphertext, so previously-encrypted data stays readable while it migrates. This is the right tool for scheduled rotation and compromise response. If you only need a single-key swap at a safe boundary (e.g. a config reload), just build a fresh Sealer and replace your reference to it.
Does @ubgo/crypt work in the browser? No — it targets Node.js and builds on node:crypto. For browser code use the Web Crypto API, or a WebCrypto-based library. Anything encrypted here is still readable in the browser if you implement the same wire format, but this package itself is server-side.
Does it have any runtime dependencies? No. The only thing it imports at runtime is node:crypto from the standard library, so it adds nothing to your dependency tree or your supply-chain surface.
Can Node decrypt what a Go service encrypted? Yes. The Go counterpart github.com/ubgo/crypt produces and consumes the exact same bytes; both repos run against the same testdata/vectors.json, so any divergence fails CI on both sides.
Does it do password hashing? No, by design — password hashing is a server-side concern best done where argon2/bcrypt run natively. Use the Go counterpart's HashPassword, or pull argon2 directly if you must hash in Node.
ESM or CommonJS? Both. The package is dual-published with proper exports conditions, so import and require both resolve to the right build, with .d.ts types either way.
Should I use AES-GCM or ChaCha20-Poly1305? Default to AES-256-GCM (seal/open) — fastest wherever AES-NI exists (nearly all server CPUs). Reach for ChaCha20-Poly1305 (sealChaCha20) on hardware without AES-NI or for algorithm diversity. Both share the same versioned format.
How is this different from using node:crypto directly? node:crypto gives you a mutable, low-level Cipher API where forgetting to concatenate update() + final() silently corrupts data past the first block — the exact bug this package was written to kill. @ubgo/crypt wraps it with authentication, a versioned format, constant-time comparison, and Go interop so the parts that are easy to get wrong are already right.
Is AES-CBC deprecated? No — it's a first-class peer kept for interop with existing AES-CBC systems and for reading ciphertext you already wrote. It has no built-in authentication, so pair it with HMAC or use seal/open when you need tamper detection.
What Node.js version does it need? Node 18 or later.
- RECIPES.md — copy-pasteable patterns by task
- examples/ — 23 runnable end-to-end TypeScript programs
- BENCHMARKS.md — real numbers and what they mean
- FAQ.md — answers to questions you'll have
- Go counterpart —
USAGE.md,SECURITY.md,WIRE_FORMAT.md,MIGRATION.mdapply equally - CHANGELOG.md
github.com/ubgo/crypt is the Go sibling. Same API shape, same wire format, byte-identical output for the same input. Tested in CI by a shared testdata/vectors.json.
Three concrete patterns:
- Go signs, Node verifies. Go service emits a webhook; Node receiver validates with
verify. - Go encrypts, Node decrypts. Go API issues a session token; Node service reads it with
open. - Either side can do either side. No "primary" — both are first-class.
If you're shipping a polyglot stack, this is the difference between "Node and Go services that mostly agree" and "Node and Go services that have correctness as a CI invariant."
pnpm add @ubgo/crypt
# or
npm install @ubgo/crypt
# or
yarn add @ubgo/cryptRequires Node.js 18 or later.
ESM and CJS dual-published. Strict-mode TypeScript types. Zero runtime dependencies (node:crypto only).
- v0.x — pre-stable. The wire format is finalized and pinned by the shared cross-language vectors (ciphertext written today stays readable), but the surface API may receive small tweaks until v1.0.
- v1.0 — frozen API, with the same wire-format guarantees as the Go side.
Check the CHANGELOG before upgrading.
Open a private security advisory: https://github.com/ubgo/crypt-ts/security/advisories/new
We aim to acknowledge within 48 hours and patch P0 issues within 7 days.
@ubgo/crypt (crypt-ts) — a TypeScript / Node.js cryptography library for authenticated encryption (AES-256-GCM, ChaCha20-Poly1305 / AEAD), HMAC and Ed25519 signing, HKDF key derivation, key rotation, time-locked tokens, X25519 sealed-box asymmetric encryption, and secure random tokens. Apache-2.0, zero-dependency (node:crypto only), dual ESM + CJS, with a byte-for-byte Go counterpart (github.com/ubgo/crypt).