# @mongez/encryption — full reference > Complete API reference for `@mongez/encryption` v2.0 — authenticated symmetric encryption (WebCrypto AES-256-GCM + PBKDF2-HMAC-SHA256) for JSON-encodable values, plus hex `md5`/`sha1`/`sha256`/`sha512` digests. This file is the concatenation of every skill, intended for single-fetch loading by AI agents. Load `llms.txt` for the structured index instead. Install: `yarn add @mongez/encryption` or `npm i @mongez/encryption`. **v2.0 is a breaking security release.** `encrypt`/`decrypt` are now async, `decrypt` throws instead of returning `null`, the pluggable cipher `driver` is removed, v1.x ciphertext is rejected unless explicitly enabled, and there is a Node 20+/secure-context runtime floor. See "Migration" below. ## Public exports ```ts import { // encryption encrypt, decrypt, tryDecrypt, // hashes md5, sha1, sha256, sha512, // configuration setEncryptionConfigurations, getEncryptionConfig, resetEncryptionConfigurations, assertIterations, // key cache deriveKey, clearKeyCache, // envelope inspection + constants isEncryptionEnvelope, parseEnvelope, buildHeader, encodeEnvelope, ENVELOPE_VERSION_1, SUITE_PBKDF2_SHA256_AES_256_GCM, SALT_LENGTH, IV_LENGTH, AUTH_TAG_LENGTH, AUTH_TAG_LENGTH_BITS, HEADER_LENGTH, DEFAULT_ITERATIONS, MIN_ITERATIONS, MAX_ITERATIONS, // legacy (v1.x) — decrypt only isLegacyCipher, isLegacyDriver, legacyDecrypt, LEGACY_CIPHER_PREFIX, // errors EncryptionError, MissingEncryptionKeyError, UnsupportedRuntimeError, DecryptionError, // types type EncryptionConfigurations, type EncryptOptions, type DecryptOptions, type LegacyCipherDriver, type EncryptionEnvelope, } from "@mongez/encryption"; ``` All exports ship from the package root — no subpath entry points. The WebCrypto plumbing (`getSubtle`, `randomBytes`, base64 helpers) is internal and not exported. --- # Overview > **Auto-trigger:** code first imports anything from `@mongez/encryption`; user asks "what does @mongez/encryption do", "is it secure for X", "can I store tokens / PII with this", "why is decrypt async now", or "should I use this or Node crypto"; a file evaluates whether to adopt the package or audits its threat model. > **Skip when:** deep API reference on a specific export — use the Encrypt/Decrypt, Hashes or Configuration sections; ready-made patterns — use Recipes; questions strictly about `crypto-js`, libsodium, or Node `crypto` themselves. `@mongez/encryption` gives the Mongez family one authenticated `encrypt(value, key)` / `decrypt(cipher, key)` pair, so no caller has to assemble WebCrypto by hand — key import, KDF choice, work factor, per-message nonce, tag handling, base64 both ways. Getting any one of those wrong yields a cipher that looks like it works. ## Runtime requirement `encrypt`/`decrypt` need `globalThis.crypto.subtle` and `crypto.getRandomValues`: - **Node.js 20+** — available as a global. - **Browser in a secure context** — HTTPS or `http://localhost`. Plain-HTTP origins do not expose `crypto.subtle`. - **Node.js ≤ 16, insecure browser contexts, bare React Native/Hermes** — throw `UnsupportedRuntimeError`. Upgrade, or install a WebCrypto polyfill providing both `subtle` and `getRandomValues`. - **Jest with the default jsdom environment** — older jsdom has no `crypto.subtle`; use the `node` environment or inject `require("node:crypto").webcrypto`. There is deliberately no fallback: silently degrading to a non-CSPRNG or a hand-rolled cipher would defeat the point of the AEAD migration. The hash exports have no such requirement. ## Quick peek ```ts import { encrypt, decrypt, sha256 } from "@mongez/encryption"; const cipher = await encrypt({ userId: 42 }, "a long passphrase"); // AES-256-GCM const value = await decrypt(cipher, "a long passphrase"); // { userId: 42 } const tag = sha256(JSON.stringify({ q: "phones" })); // stable cache key ``` ## Mental model | Concept | Type | Mental model | |---|---|---| | `encrypt(value, key?, options?)` | `(any, string?, EncryptOptions?) => Promise` | JSON-wrap as `{ data: value }`, derive a key with PBKDF2 over a fresh salt, seal with AES-256-GCM under a fresh nonce, return base64 `header ‖ ciphertext ‖ tag`. | | `decrypt(cipher, key?, options?)` | `(string, string?, DecryptOptions?) => Promise` | Parse and validate the header, re-derive the key from the envelope's own salt, verify the tag, JSON-parse, return `.data`. **Throws** `DecryptionError` on any failure. | | `tryDecrypt(...)` | same → `Promise` | `decrypt`, returning `null` for a `DecryptionError` and re-throwing everything else. | | Envelope | base64 string | Self-describing: version, suite, work factor, salt, nonce — all authenticated as AAD. | | Legacy path | opt-in | v1.x AES-CBC ciphertext, readable only with `legacyDecryption: true`. Unauthenticated. | | Hash function | `(string) => string` | Stateless, synchronous, no config, lowercase hex. | | Module config | `{ key?, iterations?, legacyDecryption?, legacyDriver? }` | Process-global defaults via `setEncryptionConfigurations`. | ## Threat model | Property | v2 | |---|---| | Confidentiality of the payload | **Yes** — AES-256-GCM under a PBKDF2-derived 256-bit key. | | Integrity / tamper detection | **Yes** — 128-bit GCM tag over ciphertext *and* header. Any edit is rejected. | | Nonce and salt hygiene | **Yes** — fresh CSPRNG values per message, never reused, never plaintext-derived. | | Work-factor downgrade on existing ciphertext | **Prevented** — the iteration count is authenticated as AAD. | | CPU exhaustion via a forged header | **Bounded** — a declared work factor above 5,000,000 is rejected before key derivation. | | Format confusion (v1 blob swapped for a v2 envelope) | **Prevented by default** — the legacy path is off unless enabled; the formats differ in their first byte. | | Weak passphrases | **No.** PBKDF2 raises the cost of an offline guess; it does not make `"password123"` safe. PBKDF2 is GPU-friendly relative to scrypt/Argon2 — it is the strongest KDF WebCrypto exposes natively. | | Key management / rotation | **No.** No key identifier in the envelope; rotation means re-encrypting or trying keys in order. | | Binding ciphertext to a context | **No.** Callers cannot supply their own AAD, so a ciphertext moved from user A's row to user B's row still decrypts. Put the context inside the value and check it. | | Replay / freshness | **No.** No timestamp or counter; add your own `exp` inside the payload. | | Length hiding | **No.** Ciphertext length reveals plaintext length plus a constant. | | Secrets in a browser | **No.** A passphrase shipped to a page is readable by extensions, devtools and injected scripts. Encrypted `localStorage` raises the bar against passive disk inspection only. | | Constant-time hash comparison | **No.** Digests are hex strings; `===` is not timing-safe. | | `md5` / `sha1` collision resistance | **Broken.** Fingerprinting only. | | FIPS / regulated compliance | **No.** Not a validated module. | | Side-channel resistance | Inherited from the platform WebCrypto implementation; unaudited here. | **Reach for it when:** encrypting values at rest in browser storage, opaquing query-string parameters, sealing payloads that pass through untrusted intermediaries, field-level encryption of a database column, and anywhere a wrong-key or tampered value must *fail* rather than degrade. **Do not reach for it when:** password storage (`bcrypt`/`scrypt`/**Argon2id**), session tokens (signed JWT/JWS or server-side sessions), key exchange or public-key work (libsodium, WebCrypto ECDH), streaming large files, or where a compliance regime demands a validated module or a managed KMS. --- # encrypt(value, key?, options?) > **Auto-trigger:** code imports `encrypt`, `decrypt` or `tryDecrypt`; user asks "how do I encrypt/decrypt a value", "why does decrypt throw now", "how do I handle a wrong key", "how do I round-trip a token through a URL", or "why are two encryptions of the same input different". > **Skip when:** hashing/fingerprinting — use the Hashes section; module defaults — use Configuration; recipe-style compositions — use Recipes; password storage (`bcrypt`/`argon2`); public-key crypto. ```ts encrypt(value: any, key?: string, options?: EncryptOptions): Promise type EncryptOptions = { iterations?: number }; ``` `key` falls back to `getEncryptionConfig("key")`. Behavior: 1. Validates the key: falsy → `MissingEncryptionKeyError`; non-string → `EncryptionError`. 2. Rejects a cipher-driver-shaped object in the options slot with `EncryptionError` (v1.x callers passed `AES` here). 3. Validates the work factor: an integer in `100_000 … 5_000_000`, else `EncryptionError`. 4. Wraps the value as `{ data: value }` and `JSON.stringify`s it — a circular value rejects here, before any randomness is drawn. 5. Draws a fresh 16-byte salt and 12-byte nonce from the platform CSPRNG. 6. Derives a 256-bit non-extractable AES-GCM key via PBKDF2-HMAC-SHA256. 7. Seals with AES-256-GCM, passing the 34-byte header as additional authenticated data. 8. Returns `base64(header ‖ ciphertext ‖ tag)`. Rejects with: - `MissingEncryptionKeyError` — no per-call key and no configured default. - `EncryptionError` — non-string key, a cipher driver in the options slot, or an out-of-range `iterations`. - `UnsupportedRuntimeError` — no `crypto.subtle` or no `crypto.getRandomValues`. - `TypeError` from `JSON.stringify` — circular references, BigInt, a throwing `toJSON`. Edge cases: - **`undefined` / functions** — dropped by JSON; round-trip to `undefined`. - **Empty string, `0`, `false`, `null`** — round-trip exactly (that is what the `{ data: … }` wrapper is for). - **Unicode** — round-trips via UTF-8. - **Non-deterministic** — a fresh salt and nonce per call, so the same value under the same key never produces the same string. Never compare, index, or cache-key on ciphertext. - **Size** — 50 bytes of overhead (34 header + 16 tag) before base64's ~33% expansion. - **Encoding** — standard base64 alphabet (`+`, `/`, `=`). URL-encode before putting it in a URL. # decrypt(cipher, key?, options?) ```ts decrypt(cipher: string, key?: string, options?: DecryptOptions | LegacyCipherDriver): Promise type DecryptOptions = { legacyDecryption?: boolean; legacyDriver?: LegacyCipherDriver; }; ``` Behavior: 1. Validates the key, then rejects a non-string or empty `cipher`. 2. Base64-decodes and inspects the leading byte. Not a version-1 envelope → the legacy path (below). 3. Rejects, **before deriving any key**: a truncated envelope, an unknown cipher suite, or a declared work factor outside `1 … 5,000,000` (a CPU-exhaustion guard, since that number comes from attacker-reachable bytes). 4. Re-derives the key from the passphrase and the envelope's own salt and iteration count. 5. Verifies the GCM tag over header + ciphertext and decrypts. 6. `JSON.parse`s the plaintext and returns `.data`. **`decrypt` throws — it does not return `null`.** In v1.x every failure was `null`, which could not be distinguished from a successfully decrypted `null`, and which hid wrong keys and tampering from the application. | Situation | Error | |---|---| | Wrong key **or** tampered ciphertext | `DecryptionError` — *"Authentication failed: the ciphertext was modified, or the key is wrong."* One message for both, deliberately: GCM cannot distinguish them, and exposing the difference would be a decryption oracle. | | Truncated / short envelope | `DecryptionError` — *"shorter than its own header and authentication tag."* | | Unknown cipher suite byte | `DecryptionError` — produced by a newer version of the package. | | Work factor outside `1…5,000,000` | `DecryptionError` — *"outside the accepted range"*, raised before key derivation. | | Unknown envelope version / not base64 / no legacy prefix | `DecryptionError` — *"Unrecognised ciphertext."* | | v1.x ciphertext while the legacy path is disabled | `DecryptionError` naming `legacyDecryption: true`. | | Authenticated but not our payload shape | `DecryptionError` — *"not a @mongez/encryption envelope body."* | | Empty string or non-string cipher | `DecryptionError`. | | No key anywhere | `MissingEncryptionKeyError`. | | No WebCrypto | `UnsupportedRuntimeError`. | Nothing is written to `console` on failure. v1.x `console.warn`'d on every one, which let a probing attacker flood logs. # tryDecrypt(cipher, key?, options?) ```ts tryDecrypt(cipher: string, key?: string, options?: DecryptOptions | LegacyCipherDriver): Promise ``` `decrypt`, returning `null` when it raises a `DecryptionError`. `MissingEncryptionKeyError` and `UnsupportedRuntimeError` still throw — those are deployment bugs, not bad ciphertext, and swallowing them would hide the bug. This is the v1.x failure shape, provided for callers that genuinely do not care why a value failed. It carries the old ambiguity: `encrypt(null)` round-trips to `null`, so `null` means "no usable value", not "failure". Prefer `decrypt` where you can act on the difference. ```ts const value = await tryDecrypt(cipher, key); if (value === null) return badRequest(); ``` # Error hierarchy ``` EncryptionError base class — catch for any failure from the package ├─ MissingEncryptionKeyError no key per call and none configured ├─ UnsupportedRuntimeError no crypto.subtle / no crypto.getRandomValues └─ DecryptionError wrong key, tampered, malformed, or gated legacy ciphertext ``` `instanceof` works after transpilation to ES5 (the constructors restore the prototype). ```ts import { decrypt, DecryptionError, UnsupportedRuntimeError } from "@mongez/encryption"; try { return await decrypt(cipher, key); } catch (error) { if (error instanceof DecryptionError) return null; // bad input if (error instanceof UnsupportedRuntimeError) throw error; // deployment fault throw error; } ``` # Envelope format ``` byte 0 envelope version (0x01 = ENVELOPE_VERSION_1) byte 1 cipher suite (0x01 = SUITE_PBKDF2_SHA256_AES_256_GCM) bytes 2–5 PBKDF2 iterations (uint32, big-endian) bytes 6–21 salt (SALT_LENGTH = 16) bytes 22–33 nonce / IV (IV_LENGTH = 12) bytes 34– ciphertext ‖ GCM tag (AUTH_TAG_LENGTH = 16, trailing) ``` `HEADER_LENGTH` is 34, and the **entire header is passed to AES-GCM as additional authenticated data**, so the declared work factor, salt and nonce are all tamper-evident. The leading version byte makes the format self-describing: a future algorithm change bumps the version or suite, and `decrypt` still recognises — and refuses or handles — every generation of ciphertext. Constants: `DEFAULT_ITERATIONS` (210,000), `MIN_ITERATIONS` (100,000, the encrypt-time floor), `MAX_ITERATIONS` (5,000,000, the decrypt-time ceiling). Inspection helpers, both pure string checks that never decrypt (so a migration script can classify a store without holding the key): ```ts isEncryptionEnvelope(cipher: string): boolean // a v2 AES-GCM envelope? isLegacyCipher(cipher: string): boolean // a v1.x "U2FsdGVkX1…" ciphertext? parseEnvelope(cipher: string): EncryptionEnvelope | null // null = not a v1 envelope; throws on a malformed one ``` # The derived-key cache PBKDF2 at 210,000 iterations costs on the order of 100 ms per call, so derived keys are memoised in-process, keyed by the exact `(passphrase, salt, iterations)` triple, capped at 64 entries with the oldest evicted. A failed derivation is never memoised. The cache cannot widen who can decrypt what — a hit requires the same passphrase *and* the same salt, and the cached `CryptoKey` is non-extractable. It does keep material resident for the life of the process. ```ts import { clearKeyCache } from "@mongez/encryption"; clearKeyCache(); // on logout, on key rotation, between tests ``` `deriveKey(password, salt, iterations)` is exported for advanced use; ordinary callers never need it. --- # Configuration > **Auto-trigger:** code imports `setEncryptionConfigurations`, `getEncryptionConfig`, `resetEncryptionConfigurations`, `clearKeyCache` or the type `EncryptionConfigurations`; user asks how to set a default key, tune the PBKDF2 work factor, enable legacy decryption, or configure at boot. > **Skip when:** per-call usage that doesn't touch defaults — use the Encrypt/Decrypt section; password-hashing config; multi-tenant per-request keys (pass the key explicitly instead). ```ts type EncryptionConfigurations = { key?: string; iterations?: number; legacyDecryption?: boolean; legacyDriver?: LegacyCipherDriver; /** @deprecated v1.x pluggable cipher — now only nominates the legacy decrypt driver. */ driver?: LegacyCipherDriver; }; setEncryptionConfigurations(opts: EncryptionConfigurations): void getEncryptionConfig(key: keyof EncryptionConfigurations): any resetEncryptionConfigurations(): void assertIterations(iterations: number): void // throws EncryptionError if out of range ``` Import-time defaults: ```ts { key: null, iterations: 210_000, legacyDecryption: false, legacyDriver: AES } ``` | Option | Effect | |---|---| | `key` | Default passphrase for `encrypt`/`decrypt`/`tryDecrypt`. Stretched with PBKDF2 — which does not rescue a short key. Use a long, high-entropy secret. | | `iterations` | PBKDF2 work factor used **when encrypting**. Integer in `100_000 … 5_000_000`; anything else throws `EncryptionError` at configuration time. | | `legacyDecryption` | Allow the v1.x AES-CBC fallback. Off by default — that format is unauthenticated, and leaving it silently readable would let an attacker who can write to your storage swap an authenticated envelope for a malleable legacy blob. | | `legacyDriver` | Cipher for the legacy fallback. Defaults to crypto-js `AES`, which is what v1.x defaulted to. | | `driver` | **Deprecated.** Cannot influence encryption; only nominates the legacy decrypt driver, and logs one deprecation warning per process. | ## Merge semantics Shallow merge over the current state — partial updates keep everything else. `undefined` values ARE written through, so `{ key: undefined }` erases a previously set key. ```ts setEncryptionConfigurations({ key: "k1" }); // iterations/legacy defaults kept setEncryptionConfigurations({ legacyDecryption: true }); // key preserved resetEncryptionConfigurations(); // back to import-time defaults ``` ## Work factor The count in force at encryption time is written into the envelope, so raising it later does not orphan older ciphertexts — they decrypt at the count they were sealed with, and the next `encrypt` uses the new value. Validation is asymmetric on purpose: - **Encrypting / configuring** — `100_000 … 5_000_000`, integer. Below the floor, PBKDF2 is decorative. - **Decrypting** — `1 … 5_000_000`. The count comes from attacker-reachable bytes: low values are accepted so old envelopes stay readable, and the ceiling stops a forged header claiming four billion iterations from pinning a core before the tag check runs. ```ts setEncryptionConfigurations({ iterations: 600_000 }); // globally await encrypt(value, key, { iterations: 100_000 }); // per call ``` ## Multi-tenant servers The configuration is process-global. Two concurrent requests with different tenant keys would race: ```ts // DON'T: setEncryptionConfigurations({ key: req.user.tenantKey }); return encrypt(payload); // DO: return encrypt(payload, req.user.tenantKey); ``` Treat `setEncryptionConfigurations` as boot-time setup, never request-time state. ## Boot-time setup ```ts // src/setup/encryption.ts import { setEncryptionConfigurations } from "@mongez/encryption"; const key = process.env.ENCRYPTION_KEY; if (!key || key.length < 32) { throw new Error("ENCRYPTION_KEY is required and must be at least 32 chars"); } setEncryptionConfigurations({ key }); ``` Crash loudly at boot rather than falling through to the throw inside `encrypt`, which surfaces at a random call site later. --- # Legacy (v1.x) ciphertext > **Auto-trigger:** user is upgrading from v1.x, sees *"legacy (v1.x) ciphertext"* in an error, or asks how to read old data / migrate a store. > **Skip when:** greenfield use with no v1 data. v2 never writes the v1.x format and refuses to read it unless told to. ```ts import { setEncryptionConfigurations, decrypt } from "@mongez/encryption"; setEncryptionConfigurations({ legacyDecryption: true }); // for the migration window await decrypt(oldCipher, key, { legacyDecryption: true }); // or per call import TripleDES from "crypto-js/tripledes"; await decrypt(oldCipher, key, { legacyDecryption: true, legacyDriver: TripleDES }); await decrypt(oldCipher, key, TripleDES); // v1-shaped third argument; implies legacyDecryption ``` Two properties to be explicit about: - **Values read through this path are not authenticated.** v1.x had no MAC, so if an attacker could write to that storage, what you just read may have been altered. Re-encrypt and stop trusting the old copy. - **The format is forward-only.** v2 envelopes are not readable by v1.x. Deploy v2 to every reader of a store *before* anything starts writing v2 into it. Helpers: `isLegacyCipher(cipher)`, `LEGACY_CIPHER_PREFIX` (`"U2FsdGVkX1"` — base64 of the OpenSSL `Salted__` header), `isLegacyDriver(value)`, `legacyDecrypt(cipher, key, driver?)`. --- # md5 / sha1 / sha256 / sha512 > **Auto-trigger:** code imports `md5`, `sha1`, `sha256` or `sha512`; user asks how to hash a string, build a stable cache key / ETag / idempotency key, or whether md5/sha1 is safe for a use case. > **Skip when:** symmetric encryption — use the Encrypt/Decrypt section; password storage (`bcrypt`/`scrypt`/`argon2`); message authentication (HMAC); constant-time comparison (`crypto.timingSafeEqual`). ```ts md5(text: string): string // @deprecated — legacy interop only sha1(text: string): string // @deprecated — legacy interop only sha256(text: string): string sha512(text: string): string ``` Unchanged in v2: stateless, synchronous, no configuration, no WebCrypto requirement. Direct passthroughs to `CryptoJS.MD5/SHA1/SHA256/SHA512` with `.toString()`, returning lowercase hex. Input is encoded as UTF-8 first, so outputs match the standard vectors for that scheme. `md5` and `sha1` carry `@deprecated` JSDoc so editors flag them at the call site. They still work — they exist for legacy interop (old cache keys, gravatar-style identifiers). Do not add new uses. Test vectors: ``` md5("") === "d41d8cd98f00b204e9800998ecf8427e" md5("123456") === "e10adc3949ba59abbe56e057f20f883e" sha1("") === "da39a3ee5e6b4b0d3255bfef95601890afd80709" sha1("123456") === "7c4a8d09ca3762af61e59520943dc26494f8941b" sha256("") === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" sha256("123456") === "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92" sha512("123456") === "ba3253876aed6bc22d4a6ff53d8406c6ad864195ed144ab5c87621b6c233b548baeae6956df346ec8c17f5ea10f35ee3cbc514797ed7ddd3145464e2a0bab413" ``` **Suitable uses:** content fingerprints, ETags, cache keys, idempotency keys, deduplication, probabilistic structures. **Unsuitable uses:** - Password storage → `bcrypt`, `scrypt`, **Argon2id**. Plain hashes are too fast and lack per-record salts. - Message authentication → HMAC (`crypto-js/hmac-sha256`, or `crypto.subtle.sign` with HMAC). A plain hash binds no secret. - Signatures over attacker-controlled input → `sha256` plus a signing primitive (RSA-PSS, Ed25519) or JWS. `md5` and `sha1` are collision-broken. - Equality checks on secret material → a constant-time compare (`crypto.timingSafeEqual`). - FIPS / regulatory validation → a vetted library or a KMS. Hash for keys; encrypt for secrecy. Never use a ciphertext as a cache key — it changes on every call. --- # Migration: v1.x → v2.0 > **Auto-trigger:** user upgrades the package and hits `[object Promise]` in storage, a `Promise` where a string was expected, a thrown `DecryptionError` where `null` used to be returned, *"no longer takes a cipher driver"*, *"legacy (v1.x) ciphertext"*, or `UnsupportedRuntimeError`. Five breaking changes. Full guide in `MIGRATION.md`. **1. `encrypt`/`decrypt` are async.** WebCrypto is promise-based and PBKDF2 is too slow to block on. ```diff - const cipher = encrypt({ userId: 42 }, KEY); - const value = decrypt(cipher, KEY); + const cipher = await encrypt({ userId: 42 }, KEY); + const value = await decrypt(cipher, KEY); ``` A missed `await` is not always a type error — it silently stores `[object Promise]`. Grep every call site. **2. `decrypt` throws instead of returning `null`.** ```diff - const value = decrypt(cipher, KEY); - if (value === null) return badRequest(); + const value = await tryDecrypt(cipher, KEY); + if (value === null) return badRequest(); ``` …or catch `DecryptionError` and act on it. Code that treated `null` as "no value yet" and wrote a default over it was silently destroying data on a wrong key. **3. No cipher `driver` on `encrypt`.** Passing one throws; the third argument is `{ iterations? }`. In configuration, `driver` is deprecated and only nominates the legacy decrypt driver. **4. v1 ciphertext is off by default.** Enable `legacyDecryption` for the migration window only; treat what you read as unauthenticated; re-encrypt; turn it off. **5. Node 20+ or a secure browser context**, else `UnsupportedRuntimeError`. ## Ordered rollout 1. Deploy v2 to everything that **reads** the store, with `legacyDecryption: true`. 2. Only then let writes switch to v2 (v1 readers cannot parse v2 envelopes). 3. Re-encrypt at rest — lazily on read, or with a batch walk. 4. Turn `legacyDecryption` off and delete the fallback config. ```ts import { decrypt, encrypt, isEncryptionEnvelope, isLegacyCipher } from "@mongez/encryption"; async function migrate(stored: string, key: string) { if (isEncryptionEnvelope(stored)) return stored; // already v2 if (!isLegacyCipher(stored)) throw new Error("unrecognised ciphertext"); const value = await decrypt(stored, key, { legacyDecryption: true }); return encrypt(value, key); } ``` Expect some rows to fail: v1 returned `null` for corrupt data, so stores can carry damage nobody ever saw. Log and count the failures before deleting anything — a spike may mean tampering, not corruption. ## Unchanged `md5`/`sha1`/`sha256`/`sha512`; the `{ data: value }` wrapper (`0`, `false`, `null` round-trip; `undefined`/functions become `undefined`); `setEncryptionConfigurations`/`getEncryptionConfig` names and shallow-merge semantics; the key fallback chain; non-deterministic ciphertext. ## Known incompatibility `@mongez/cache`'s `EncryptedLocalStorageDriver`/`EncryptedSessionStorageDriver` call `encrypt(...)` **synchronously** and write the result straight to storage — handed v2's promise they store `"[object Promise]"`. Pin `@mongez/encryption@^1` for that integration, or encrypt outside the cache and store the finished string through a plain driver. --- # Recipes > **Auto-trigger:** user wants a full pattern — boot-time setup, URL tokens, binding ciphertext to a user, field-level encryption, key rotation, migrating v1 data, or cache keys. > **Skip when:** single-function lookups — use the reference sections above. ## Boot-time setup, used everywhere ```ts // src/setup/encryption.ts import { setEncryptionConfigurations } from "@mongez/encryption"; const key = process.env.ENCRYPTION_KEY; if (!key || key.length < 32) throw new Error("ENCRYPTION_KEY missing or too short"); setEncryptionConfigurations({ key }); // src/anywhere.ts const cipher = await encrypt({ a: 1 }); const value = await decrypt(cipher); ``` ## Tamper-evident URL token ```ts import { encrypt, tryDecrypt } from "@mongez/encryption"; async function makeToken(payload: { orderId: number; exp: number }) { // Standard base64 contains + and / — always URL-encode. return encodeURIComponent(await encrypt(payload, KEY)); } async function readToken(raw: string) { const payload = await tryDecrypt(decodeURIComponent(raw), KEY); if (!payload) return null; // wrong key, garbage, or tampered if (payload.exp < Date.now()) return null; // freshness is still yours to enforce return payload; } ``` Unlike v1, an edited token now fails instead of decoding to something else. It is still a bearer token — anyone who copies it can replay it until `exp` — and it is not a substitute for a signed JWT when a third party must verify without your key. ## Bind a ciphertext to its context The package exposes no caller-supplied AAD, so a ciphertext lifted from one record and dropped into another still decrypts. Put the binding in the plaintext: ```ts async function sealFor(userId: string, value: unknown, key: string) { return encrypt({ userId, value }, key); } async function openFor(userId: string, cipher: string, key: string) { const payload = await decrypt(cipher, key); if (payload?.userId !== userId) throw new Error("ciphertext does not belong to this user"); return payload.value; } ``` ## Field-level encryption ```ts import { encrypt, decrypt, DecryptionError } from "@mongez/encryption"; await db.users.update(id, { ssn: await encrypt(ssn, process.env.FIELD_KEY!) }); try { const ssn = await decrypt(row.ssn, process.env.FIELD_KEY!); } catch (error) { if (error instanceof DecryptionError) { // Not "missing" — tampered, or the key rotated. Alert; do not fall back. throw new Error(`unreadable ssn for ${id}`); } throw error; } ``` Keeps the value out of backups, logs and read replicas in plaintext. Does not protect against an attacker holding both database and key. ## Key rotation ```ts import { encrypt, tryDecrypt, clearKeyCache } from "@mongez/encryption"; async function rotate(cipher: string, oldKey: string, newKey: string) { const value = await tryDecrypt(cipher, oldKey); if (value === null) return null; // not ours, or already rotated return encrypt(value, newKey); } clearKeyCache(); // once the old key is retired ``` The envelope carries no key identifier, so mid-rotation stores need a try-new-then-old ladder. ## Content-addressed cache key ```ts import { sha256 } from "@mongez/encryption"; function cacheKey(query: unknown) { // Property order can vary across engines — sort keys for a canonical form. return `q:${sha256(JSON.stringify(query))}`; } ``` ## Test setup ```ts import { beforeEach, afterEach } from "vitest"; import { resetEncryptionConfigurations, setEncryptionConfigurations, clearKeyCache, MIN_ITERATIONS, } from "@mongez/encryption"; beforeEach(() => { resetEncryptionConfigurations(); // The work factor is a number in the envelope, not a behavioural switch — // running at the floor keeps a suite fast without changing what's tested. setEncryptionConfigurations({ key: "a-long-test-passphrase", iterations: MIN_ITERATIONS }); }); afterEach(() => { resetEncryptionConfigurations(); clearKeyCache(); }); ``` Use the `node` environment (or a jsdom with WebCrypto), and never assert on an exact ciphertext — it changes every call. --- # What this package does NOT do - Password hashing → `bcrypt`, `scrypt`, **Argon2id**. - Key management, rotation metadata, or key IDs in the ciphertext → a KMS. - Caller-supplied AAD / ciphertext-to-record binding → put the context in the payload. - Replay protection or freshness → add `exp`/nonce inside the value. - Public-key crypto, key exchange, signatures → libsodium, WebCrypto ECDH/ECDSA, JWS. - Streaming or chunked encryption → Node `crypto` streams. - Constant-time comparison → `crypto.timingSafeEqual`. - Random IDs / UUIDs → `crypto.randomUUID`, `nanoid`, `@mongez/reinforcements`' `Random.token`. - FIPS-validated primitives → a vetted, validated module.