🔐 High-security encryption/decryption library using AES-256-GCM and Argon2id, for Node.js and the browser, with full TypeScript support.
| Getting started | Features · Installation · Module system · Argon2 providers · Browser build · CommonJS interop · Quick Start |
| Reference | API Reference · Constructor options · Methods · Types and enums · Utility functions · Wire-format and codec exports · Full export surface |
| Runtimes | Isomorphic API & browser support · Cross-runtime interop · Browser Argon2id profile · CSP for WASM · Node-only methods in the browser |
| Formats | Ciphertext format (v1) · Container mode (v2) · Container format (v2) · Telling the formats apart |
| Configuration | Sync vs async · Progress callbacks · Security levels · Password requirements |
| Security | Security features · (key, IV) reuse boundary · Post-quantum security · Threat model · SECURITY.md |
| Errors | Error handling · Error types · AES-GCM size limit · Container error codes |
| Project | Testing · Benchmarks · Development · Contributing · Changelog · License |
- 🔐 AES-256-GCM authenticated encryption
- 🔑 Argon2id memory-hard key derivation
- 🌐 Isomorphic (Node + browser) — the same
encryptBytes/decryptBytes/encryptText/decryptTextrun in Node and the browser over one wire format, so a ciphertext produced in one runtime decrypts in the other (details) - 🔮 Post-quantum resistant by design — symmetric-only cryptography, nothing for Shor's algorithm to break (proofs)
- 📁 Streaming file encryption AND decryption (bounded memory regardless of file size) with atomic temp-file output
- 📦 Container mode — an optional authenticated v2 envelope with confidential metadata (filename/mime) and an embedded, re-verified plaintext hash (details)
- 🛡️ Memory-safe operations with secure clearing
- ✅ Strong password validation with detailed feedback
- 🔄 Cross-platform compatibility
- 📝 Full TypeScript support with strict typing
- 🧪 Comprehensive testing — 1,261 tests: 1,224 in the Node suite (28 files, Jest) plus 37 in a real headless Chromium (Vitest Browser Mode), behind a one-way coverage ratchet (96% statements / 88% branches / 98% functions / 96% lines)
- 🚀 Modern ES modules with tree-shaking support
- 🔒 Security-focused with constant-time comparisons
- 🔑 Default passphrase support for simplified usage
npm install @hiprax/cryptoThis package is ESM-only ("type": "module"). It is not shipped with a CommonJS build because Node.js ESM consumers (the package minimum is Node 22) can use it directly via import, and CJS consumers can still load it via dynamic import(). See the CommonJS interop section below.
The main entry (.) is an isomorphic conditional export: Node resolves the Node build (dist/index.js) and browser bundlers resolve a separate, node:-free browser build (dist/index.browser.js). The browser build ships the same in-memory async API over Web Crypto + WebAssembly Argon2id — see Browser build and Isomorphic API & Browser Support.
The async key-derivation paths (encryptText, decryptText, encryptFile, decryptFile, deriveKey) use Argon2id — the gold standard for password hashing. Three providers are supported and tried in order:
- Native
argon2— fastest, but requires a working C++ toolchain (Python + node-gyp) at install time on platforms without a prebuilt binary. Declared as an optional dependency. - Node's own
crypto.argon2— built into the runtime from Node 24.7.0 onward. Nothing to install: no compiler, no WebAssembly package, no dependency of any kind. Like the native addon, it runs the derivation off the event loop. - WASM
hash-wasm— pure WebAssembly, zero native deps, works everywhere Node.js runs, including Node 22, where there is no built-in. Declared as an optional dependency. It is also the provider the browser build uses unconditionally, because Web Crypto has no Argon2id. Unlike the other two it computes synchronously on the calling thread, so in Node it blocks the event loop for the whole derivation — see Which provider answered, and why it matters before using it to serve untrusted traffic.
Warning
The async API is non-blocking on two of the three providers, not all three. Measured on Node v24.19.0 at the default 128 MiB profile, with a 5 ms timer probing event-loop liveness: the native addon fired 76 of ~78 expected ticks and the built-in 85 of ~86, while hash-wasm fired 0 of ~138 during a 694 ms derivation. At this library's own Node decrypt-budget ceiling a single hash-wasm derivation blocked for 6,309 ms. Confirmed on Node v22.23.2 too (609 ms, 0 of ~121 ticks). Because both native packages are optional and a failed native build does not fail an npm install, a deployment can land on the blocking provider silently. Check with getArgon2Provider().
All three implement the same RFC 9106 Argon2id reference and derive bit-identical keys for the same (password, salt, memoryCost, timeCost, parallelism, hashLength) tuple — verified across nine parameter sets at development time, including the memoryCost === 8 * parallelism floor and memory values that are not a multiple of 4 * parallelism (all three apply the same rounding). Six (memoryCost, timeCost, parallelism) tuples are pinned as regression tests in src/__tests__/argon2-provider-parity.test.ts, each checked against every provider the host has — all three where the native addon built and the runtime is Node >= 24.7, and never fewer than two, since one implementation cannot evidence a parity claim: the known-answer vector (4096, 2, 1), plus (8, 2, 1), (9, 2, 1), (100, 2, 7), (8, 1, 1) and (4096, 1, 1). Exactly two of the six — the known-answer vector and (8, 1, 1) — are literally among the nine; the other four are neighbours chosen to reach the same corners, including a timeCost of 1, which @types/node declares out of range and which this library nonetheless permits and produces. (parallelism carries the identical wording and matters more, since p = 1 is the default in essentially every ciphertext this library has produced; the known-answer vector and four of the five other tuples pin it.) A seventh case re-runs the known-answer tuple with a multi-byte, non-ASCII password in both NFC and NFD, which is what pins that the three providers agree on how a JavaScript string becomes bytes; an ASCII vector cannot say that, because ASCII is byte-identical under every plausible encoding. The remaining seven development probes are one-off checks recorded in no committed test. A v1 ciphertext produced under any one provider therefore round-trips under any other, and which provider answers is a performance and packaging question rather than a compatibility one. There is deliberately no way to select one, because all three derive the same key and a switch would only pick a slower one. That framing was incomplete before v1.9.0 and is worth stating precisely: the providers are interchangeable for correctness, but not for threading. Two derive off the event loop and one does not, which is a behavioural difference rather than a performance one. The answer is not a selection flag, which would let a caller pick a provider their host does not actually have; it is getArgon2Provider(), which reports the one that answered so a service can refuse to start on it.
Measured cost per derivation at the library's default 128 MiB / t=3 / p=1 profile, five runs each on the maintainer's Linux machine (Node v24.19.0) on 2026-09-21: native 343 ms, Node built-in 403 ms, hash-wasm 597 ms. Treat those as an order of magnitude rather than a budget. A single Argon2id derivation is not a stable constant, and this repository records more than one figure for the same operation — an earlier session on the same machine measured ~357 ms native against ~631 ms WASM on 2026-09-10, and bench/README.md cites 395 ms — so budget for a wider spread on weaker hardware and run npm run bench for a number from your own host. To check the bit-identical claim yourself, hash the same tuple through each provider and compare the bytes — that is what src/__tests__/argon2-provider-parity.test.ts does. (bench/kdf.mjs will not tell you: it times whichever provider the chain resolves, it does not compare them.)
On Node >= 24.7.0 the async API needs no optional dependency at all. npm i @hiprax/crypto --omit=optional installs nothing but the package itself, and encryptText / decryptText / encryptFile / decryptFile / deriveKey still work, because the runtime supplies Argon2id. On Node 22 — the package minimum, in Maintenance LTS until 2027-04-30 — there is no built-in, so one of the two optional packages is required for any async path; the synchronous PBKDF2 methods need neither on any version.
The library tries native first (highest performance), then the runtime's built-in, then WASM. If all three are unavailable, async encryption throws CryptoError(MEMORY_ERROR, 'ARGON2_NOT_AVAILABLE') with the message:
argon2 native module unavailable. Install build tools (Python + node-gyp), or run on Node >= 24.7.0 (whose built-in `crypto.argon2` needs no install at all), or install the optional `hash-wasm` package for a pure-WASM Argon2id fallback (slower than native but works everywhere). Alternatively, use *Sync methods (PBKDF2). Native error: <msg>. Node built-in error: <msg>. WASM error: <msg>.
(the trailing Native error: … Node built-in error: … WASM error: … carries the concrete failure reason from each link of the chain, so "not installed" stays distinguishable from "installed but broken" for every one of them.)
import { getArgon2Provider } from '@hiprax/crypto/crypto-manager';
// 'native' | 'node' | 'wasm'. Forces the lazy load, so it is meaningful at boot.
if ((await getArgon2Provider()) === 'wasm') {
throw new Error(
'Argon2id resolved to the WASM provider, which blocks the event loop.'
);
}The three providers derive bit-identical keys, so this never affects whether a ciphertext round-trips. It affects availability: hash-wasm blocks the calling thread, so an endpoint that decrypts untrusted input on it can be stalled for seconds by a single small ciphertext, which is exactly what the async paths are supposed to prevent.
You will land on hash-wasm when the native addon is absent or unloadable and hash-wasm is not. Prebuilt binaries cover linux-x64/arm64/arm (glibc and musl, so Alpine is fine), darwin-arm64, freebsd-x64/arm64 and win32-x64. The gaps worth knowing: Intel macOS (darwin-x64) and win32-arm64 have no prebuild and need a working C++ toolchain; so do s390x/ppc64le. A glibc older than the prebuild's build host fails to load. Bundlers and serverless packagers that cannot ship a .node binary will also fall through. None of these fails your install, because the dependency is optional.
Three remedies, in the order worth trying:
- Install a C++ toolchain (Python + node-gyp) so the native addon builds.
- Run on Node >= 24.7.0, whose built-in
crypto.argon2needs no install at all and derives off the event loop. - Move decryption into a
worker_thread, which keeps the main loop free whichever provider answers. This is the same recipe the synchronous paths need, and it is the only one that works when you cannot change the install.
getArgon2Provider is exported from the @hiprax/crypto/crypto-manager subpath rather than the package root, because the root entry's surface is held to a deliberate shape (it exceeds the browser entry by exactly the Node-only file helpers) and this symbol is Node-only for a different reason.
To recover from an "all three unavailable" error, do any of the following — they are listed in the chain's own order, which is also fastest-first:
- Install build tools and reinstall so
argon2'snode-gypstep succeeds (best performance), or - Run on Node >= 24.7.0, where
crypto.argon2is part of the runtime — the only option on this list that installs nothing, or - Install
hash-wasmexplicitly:npm install hash-wasm(no build tools needed; transparent fallback once installed), or - Use the synchronous methods (
encryptTextSync,decryptTextSync,encryptFileSync,decryptFileSync,deriveKeySync) which use PBKDF2-HMAC-SHA256 and have no Argon2id provider dependency at all. Note that PBKDF2 is materially weaker than Argon2id against GPU/ASIC adversaries — prefer (1), (2) or (3) for new ciphertexts.
Note: a successful first load is cached for the lifetime of the process (subsequent async calls reuse the same provider). A failed first load is NOT cached — the next caller retries the whole chain from scratch, so transient failures (e.g. a temporary FS permission glitch on Windows during a build-tool install) can recover within the same process.
@hiprax/crypto is isomorphic: the . entry is a conditional export, so a browser bundler (webpack 5, Vite/Rollup, esbuild, Next.js) automatically resolves the separate browser build (dist/index.browser.js) while Node resolves the Node build (dist/index.js). The browser build's import graph contains zero node: builtins and references no Buffer/process global, so nothing like node:crypto/node:fs/node:stream is ever dragged into your bundle. Three complementary static gates enforce this continuously (in CI and at publish), and they cover three disjoint defect classes:
npm run check:browser— an esbuildplatform:'browser'bundle of the browser entry; fails on anynode:specifier reaching the browser graph.- The ESLint
no-restricted-globals(Buffer/process) +no-restricted-imports(node:*) override on the isomorphic source files — catches a bareBuffer/processvalue that esbuild cannot see, because a bundle succeeds and thenReferenceErrors at runtime. npm run check:types:browser— type-checks a throwaway browser-only consumer against the builtdist/, resolved through the package'sbrowserexport condition with"types": []and"skipLibCheck": false. This is the only gate that can see a type-position Node reference:no-restricted-globalsdoes not fire oninterface X { y: Buffer }(it inspects value references only), and esbuild erases types before it ever sees them. A Node type reachingdist/index.browser.d.ts— or anything it re-exports — now fails the build instead of reaching your project as aCannot find name 'Buffer'error.
# The browser build needs the WASM Argon2id provider (Web Crypto has no Argon2id):
npm install @hiprax/crypto hash-wasm// In a browser (or any bundler that sets the "browser" condition):
import { CryptoManager } from '@hiprax/crypto';
const cm = new CryptoManager(); // Web Crypto + hash-wasm Argon2id, 32 MiB default
const ct = await cm.encryptText('secret', 'MySecureP@ssw0rd123!');
const back = await cm.decryptText(ct, 'MySecureP@ssw0rd123!');Requirements and behavioral differences from Node — the 32 MiB browser Argon2id default, the secure-context requirement, the CSP 'wasm-unsafe-eval' one-liner, the browser memory-hygiene caveats, and the Node-only methods that throw UNSUPPORTED_IN_BROWSER — are all covered under Isomorphic API & Browser Support. The ./crypto-manager and ./utils subpath exports remain Node-only (they pull in node:fs/node:path); import from the package root (@hiprax/crypto) in browser code.
@hiprax/crypto is ESM-only. The package.json exports map has no require entry in any condition — the . entry resolves in JSON source order browser → node → default, and each of those branches nests its own types + default (so a browser bundler resolves dist/index.browser.js with the matching dist/index.browser.d.ts, while Node resolves dist/index.js with dist/index.d.ts); none of the branches is a require target. The supported and portable interop for CommonJS callers is a dynamic import() from an async function — it is the only pattern the test suite verifies:
// my-cjs-file.cjs
async function main() {
const { CryptoManager } = await import('@hiprax/crypto');
const cm = new CryptoManager();
const ciphertext = await cm.encryptText('hello', 'MySecureP@ssw0rd123!');
console.log(ciphertext);
}
main();On Node 22+ (the package minimum) a plain require('@hiprax/crypto') call may actually succeed via Node's built-in require(esm) path (enabled by default for ESM files with no top-level await). Do not rely on this: the behavior is undocumented as a stable API, the require path is untested by this library, and earlier minor releases of Node 22 may differ. await import() is the supported path.
If you need pure-CJS interop, the recommended path is to migrate the calling module to ESM ("type": "module" or .mjs).
import { CryptoManager } from '@hiprax/crypto';
const crypto = new CryptoManager();
// Encrypt text
const encrypted = await crypto.encryptText(
'Hello World',
'MySecureP@ssw0rd123!'
);
console.log('Encrypted:', encrypted);
// Decrypt text
const decrypted = await crypto.decryptText(encrypted, 'MySecureP@ssw0rd123!');
console.log('Decrypted:', decrypted);For scenarios where you need synchronous operations (note: uses PBKDF2 instead of Argon2id for key derivation):
import { CryptoManager } from '@hiprax/crypto';
const crypto = new CryptoManager();
// Encrypt text synchronously
const encrypted = crypto.encryptTextSync('Hello World', 'MySecureP@ssw0rd123!');
console.log('Encrypted:', encrypted);
// Decrypt text synchronously
const decrypted = crypto.decryptTextSync(encrypted, 'MySecureP@ssw0rd123!');
console.log('Decrypted:', decrypted);You can set a default passphrase when creating the CryptoManager instance, which allows you to encrypt and decrypt without specifying a password each time:
import { CryptoManager } from '@hiprax/crypto';
// Create instance with default passphrase
const crypto = new CryptoManager({
defaultPassphrase: 'MySecureP@ssw0rd123!',
});
// Encrypt text without specifying password
const encrypted = await crypto.encryptText('Hello World');
console.log('Encrypted:', encrypted);
// Decrypt text without specifying password
const decrypted = await crypto.decryptText(encrypted);
console.log('Decrypted:', decrypted);
// You can still override with a custom password
const encryptedWithCustom = await crypto.encryptText(
'Hello World',
'CustomP@ssw0rd456!'
);Memory-retention caveat. Configuring
defaultPassphrasekeeps the password resident as a regular V8 string for the full lifetime of theCryptoManagerinstance — and beyond, until V8's garbage collector reclaims any internal copies the engine made along the way (interning, deopt paths, etc.). The library cannot scrub V8 strings;secureClearonly zero-fillsBuffer-backed allocations. For long-lived processes that handle sensitive data, prefer passing the password explicitly to each encrypt/decrypt call: that bounds the password's V8-string lifetime to the call frame instead of the manager. The convenience ofdefaultPassphraseis appropriate for short-lived scripts, CLI tools, or scopes where the password's residency in process memory is not part of your threat model. See the Threat Model section below for the broader memory-hygiene picture.
import { CryptoManager } from '@hiprax/crypto';
const crypto = new CryptoManager();
// Encrypt file
await crypto.encryptFile('input.txt', 'output.enc', 'MySecureP@ssw0rd123!');
// Decrypt file
await crypto.decryptFile('output.enc', 'decrypted.txt', 'MySecureP@ssw0rd123!');For scenarios where you need synchronous file operations (note: uses PBKDF2 instead of Argon2id for key derivation):
import { CryptoManager } from '@hiprax/crypto';
const crypto = new CryptoManager();
// Encrypt file synchronously
crypto.encryptFileSync('input.txt', 'output.enc', 'MySecureP@ssw0rd123!');
// Decrypt file synchronously
crypto.decryptFileSync('output.enc', 'decrypted.txt', 'MySecureP@ssw0rd123!');import { CryptoManager } from '@hiprax/crypto';
// Create instance with default passphrase
const crypto = new CryptoManager({
defaultPassphrase: 'MySecureP@ssw0rd123!',
});
// Encrypt file without specifying password
await crypto.encryptFile('input.txt', 'output.enc');
// Decrypt file without specifying password
await crypto.decryptFile('output.enc', 'decrypted.txt');
// You can still override with a custom password
await crypto.encryptFile('input.txt', 'output.enc', 'CustomP@ssw0rd456!');import { CryptoManager } from '@hiprax/crypto';
// Create instance with default passphrase
const crypto = new CryptoManager({
defaultPassphrase: 'MySecureP@ssw0rd123!',
});
// Encrypt file synchronously without specifying password
crypto.encryptFileSync('input.txt', 'output.enc');
// Decrypt file synchronously without specifying password
crypto.decryptFileSync('output.enc', 'decrypted.txt');
// You can still override with a custom password
crypto.encryptFileSync('input.txt', 'output.enc', 'CustomP@ssw0rd456!');import { CryptoManager } from '@hiprax/crypto';
const crypto = new CryptoManager({
memoryCost: 2 ** 19, // 512 MiB — the ULTRA tier floor
timeCost: 4, // Higher time cost
parallelism: 2, // Use 2 threads
aad: 'my-app-v1', // Custom AAD
});
console.log('Security Level:', crypto.getSecurityLevel()); // 'ultra'Note: in pre-1.0 development (prior to the v0.15.0 dev iteration) the ULTRA tier was memoryCost: 2 ** 18 (256 MiB). It is 2 ** 19 (512 MiB) in v1.0.0 so the bar tracks OWASP 2026 guidance — the previous ULTRA configuration now classifies as HIGH.
The main class for encryption/decryption operations.
const crypto = new CryptoManager(options?: CryptoManagerOptions);Options:
memoryCost(number): Argon2 memory cost in KiB (default: 131072 —2 ** 17, 128 MiB, theHIGHtier; see Security Levels). This is deliberately above every configuration OWASP lists. OWASP's Password Storage Cheat Sheet does two separate things. It states a minimum — "Use Argon2id with a minimum configuration of 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism" — and it separately lists five configurations that "provide an equal level of defense, and the only difference is a trade off between CPU and RAM usage":m=47104(46 MiB)t=1,m=19456(19 MiB)t=2,m=12288(12 MiB)t=3,m=9216(9 MiB)t=4,m=7168(7 MiB)t=5, all atp=1. It designates none of them a "first choice" — the ordering is a CPU/RAM trade-off, not a ranking — and the lower-memory rows are not below the minimum, because each raisestasmfalls. At 128 MiB the library's default carries roughly 2.8x the memory of the highest-memory entry on that list and clears the stated minimum on both axes, so it is a conservative choice rather than a quotation from it. Resource-constrained callers (mobile, embedded, low-memory containers) can opt back into the previous 64 MiB profile by passingmemoryCost: 65536(2 ** 16).timeCost(number): Argon2 time cost (default: 3)parallelism(number): Argon2 parallelism (default: 1)aad(string): Custom Additional Authenticated Data (default: 'secure-crypto-tool-v2')defaultPassphrase(string): Default passphrase to use when no password is provided to encryption/decryption methodslegacyMode('auto' | 'strict' | 'reject'): How to handle legacy (pre-v1) ciphertexts during decryption —'auto'(default) accepts them,'strict'rejects withLEGACY_FORMAT_REJECTED,'reject'rejects withUNSUPPORTED_FORMAT. New ciphertexts are always produced in v1 format. See Ciphertext Format.pbkdf2Iterations(number): PBKDF2 iteration count for sync key derivation (default: 600000 — matches OWASP 2023+ recommendation for PBKDF2-HMAC-SHA256). The chosen value is embedded in every v1 ciphertext header produced by sync paths so it travels with the ciphertext and decryption remains correct even if you change the default later. Must be a positive integer.legacyPbkdf2Iterations(number): PBKDF2 iteration count assumed when decrypting legacy v0 sync ciphertexts (those produced before the versioned ciphertext format and which carry no embedded iteration count). Default: 100000 — the value baked into every v0 sync ciphertext produced by versions of this library prior to 0.11.0. Override only if you have legacy data that was produced with a non-default iteration count. Has no effect on v1 ciphertexts.skipPasswordValidation(boolean): Whentrue, the constructor skips strength validation ofdefaultPassphraseonly (default:false). This does not disable encryption-time password validation, and does not disable Unicode NFC normalisation — use it solely to construct a manager for decrypting legacy data whose password predates the current strength rules. See Password Requirements.decryptKdfLimits(object): Ceilings — and optional floors — on the KDF cost this manager will honour from a ciphertext's own header when decrypting. A ciphertext carries the Argon2id/PBKDF2 parameters that produced it, and those bytes are not authenticated until after a key has been derived, so they control how much work an attacker can make you do before you can reject their input. Defaults to a generous but bounded budget (Node:maxMemoryCost: 2 ** 19= 512 MiB,maxTimeCost: 10,maxParallelism: 16,maxWork: 2 ** 22KiB-passes,maxPbkdf2Iterations: 2_000_000; the browser build is tighter still at2 ** 18/2 ** 20). Each omitted ceiling widens to this instance's own cost, so a manager can always decrypt its own output. See Decrypting untrusted ciphertext.legacyHeaderAad(boolean): Backward-compat shim for v1 ciphertexts produced by v1.0.0 (default:false). Whentrue, v1 ciphertext AAD reverts to the v1.0.0 format (justaad, header bytes not bound) so v1.0.0-produced ciphertexts still decrypt; the defaultfalsebinds the header bytes into the AAD. Affects v1 ciphertexts only (v0 always usesaadalone). Leavefalsefor new code; use only as a temporary migration aid. See Migration: v1.0.0 → v1.1.0.
Every option is validated in the constructor, so a misconfiguration fails at construction rather than at first use. Each rejection is a CryptoError with a specific code:
| Rejected value | code |
type |
|---|---|---|
memoryCost not a positive integer / above 2 ** 22 / below 8 * parallelism |
INVALID_MEMORY_COST / MEMORY_COST_TOO_LARGE / MEMORY_COST_TOO_SMALL |
INVALID_INPUT |
timeCost not a positive integer / above 100 |
INVALID_TIME_COST / TIME_COST_TOO_LARGE |
INVALID_INPUT |
parallelism not a positive integer / above 64 |
INVALID_PARALLELISM / PARALLELISM_TOO_LARGE |
INVALID_INPUT |
pbkdf2Iterations not a positive integer / above 10_000_000 |
INVALID_PBKDF2_ITERATIONS / PBKDF2_ITERATIONS_TOO_LARGE |
INVALID_INPUT |
legacyPbkdf2Iterations not a positive integer / above 10_000_000 |
INVALID_LEGACY_PBKDF2_ITERATIONS / LEGACY_PBKDF2_ITERATIONS_TOO_LARGE |
INVALID_INPUT |
legacyMode not one of 'auto'/'strict'/'reject' |
INVALID_LEGACY_MODE |
INVALID_INPUT |
aad not a string |
INVALID_AAD |
INVALID_INPUT |
defaultPassphrase too weak (unless skipPasswordValidation: true) |
WEAK_PASSWORD |
INVALID_PASSWORD |
decryptKdfLimits not a plain object / a ceiling that is not a positive integer / a floor that is negative / a floor above the largest value its ceilings can actually reach (min(maxWork, maxMemoryCost × maxTimeCost), so an unsatisfiable policy is caught at construction rather than on first decrypt) |
INVALID_DECRYPT_KDF_LIMITS |
INVALID_INPUT |
any decryptKdfLimits field — ceiling or floor — above the corresponding wire-format cap (no header could carry such a value, so the limit could never bind) |
DECRYPT_KDF_LIMIT_TOO_LARGE |
INVALID_INPUT |
The memoryCost >= 8 * parallelism floor is RFC 9106 §3.1 and is checked on the resolved values, after defaults are applied — so new CryptoManager({ memoryCost: 256, parallelism: 64 }) is rejected with MEMORY_COST_TOO_SMALL (256 < 512), while raising parallelism alone is fine because the 128 MiB default clears the floor for every legal parallelism. The three upper bounds mirror the wire-format DoS caps enforced by parseHeader: a value above them would produce a ciphertext this library then refuses to decrypt.
Encrypts text with a password using Argon2id key derivation. If no password is provided and a default passphrase is set, the default passphrase will be used.
const encrypted = await crypto.encryptText(
'Hello World',
'MySecureP@ssw0rd123!'
);
// Returns: base64url encoded string
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
const encrypted = await crypto.encryptText('Hello World');Decrypts text with a password. If no password is provided and a default passphrase is set, the default passphrase will be used.
const decrypted = await crypto.decryptText(encrypted, 'MySecureP@ssw0rd123!');
// Returns: original text
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
const decrypted = await crypto.decryptText(encrypted);Isomorphic in-memory encryption — the same method (and the same output bytes) runs in Node and the browser. Encrypts raw bytes with Argon2id key derivation, producing a v1 ciphertext in the text byte layout [header:22][salt:32][iv:12][tag:16][ciphertext]. encryptText is a thin base64url wrapper over this method, so there is exactly one wire format. The empty Uint8Array is accepted and produces a valid authenticated ciphertext; the caller's data buffer is never mutated or scrubbed. See Isomorphic API & Browser Support.
const bytes = new TextEncoder().encode('Hello World');
const ct = await crypto.encryptBytes(bytes, 'MySecureP@ssw0rd123!');
// Returns: Uint8Array (v1 ciphertext, HPCR magic)Isomorphic counterpart of encryptBytes. Decrypts v1 (Argon2id) or legacy v0 ciphertext bytes, mirroring decryptText's byte path exactly (header parse, embedded-parameter override, header-bound AAD, and the legacyMode v0 fallback). Every confidentiality-relevant failure (wrong password, tampering) surfaces as the generic DECRYPTION_FAILED.
const back = await crypto.decryptBytes(ct, 'MySecureP@ssw0rd123!');
console.log(new TextDecoder().decode(back)); // 'Hello World'Synchronous version of text encryption. Uses PBKDF2 for key derivation instead of Argon2id for synchronous operation.
const encrypted = crypto.encryptTextSync('Hello World', 'MySecureP@ssw0rd123!');
// Returns: base64url encoded string
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
const encrypted = crypto.encryptTextSync('Hello World');Synchronous version of text decryption. Uses PBKDF2 for key derivation instead of Argon2id for synchronous operation.
const decrypted = crypto.decryptTextSync(encrypted, 'MySecureP@ssw0rd123!');
// Returns: original text
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
const decrypted = crypto.decryptTextSync(encrypted);encryptFile(inputPath: string, outputPath: string, password?: string, progress?: ProgressCallback): Promise<void>
Encrypts a file with a password. Uses streaming, so peak memory is bounded by the stream's high-water mark regardless of input size. Output is written to a sibling temp file (${outputPath}.<random>.tmp) and atomically renamed to outputPath only on full success — readers of outputPath therefore never observe a half-written ciphertext, and any pre-existing file at outputPath is preserved if encryption errors out. If no password is provided and a default passphrase is set, the default passphrase will be used. Automatically creates the output directory if it doesn't exist. The optional progress callback receives (bytesProcessed, totalBytes) events — see Progress callbacks for file ops for the contract.
await crypto.encryptFile('input.txt', 'output.enc', 'MySecureP@ssw0rd123!');
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
await crypto.encryptFile('input.txt', 'output.enc');
// With progress callback
await crypto.encryptFile(
'input.txt',
'output.enc',
'MySecureP@ssw0rd123!',
(processed, total) => {
const pct = total === 0 ? 100 : Math.round((processed / total) * 100);
console.log(`encrypt ${pct}% (${processed}/${total} bytes)`);
}
);decryptFile(inputPath: string, outputPath: string, password?: string, progress?: ProgressCallback): Promise<void>
Decrypts a file with a password. Streams the ciphertext through crypto.createDecipheriv() so the full ciphertext never sits in memory at once — multi-GiB ciphertexts decrypt with bounded memory. Output is written to a sibling temp file and atomically renamed to outputPath only after decipher.final() validates the GCM auth tag. Both v0 (legacy, no header) and v1 (preferred, 22-byte header) ciphertext layouts are supported (subject to the constructor's legacyMode for v0). If no password is provided and a default passphrase is set, the default passphrase will be used. Automatically creates the output directory if it doesn't exist. The optional progress callback receives (bytesProcessed, totalBytes) events where both values are denominated in input ciphertext bytes — see Progress callbacks for file ops.
await crypto.decryptFile('output.enc', 'decrypted.txt', 'MySecureP@ssw0rd123!');
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
await crypto.decryptFile('output.enc', 'decrypted.txt');
// With progress callback
await crypto.decryptFile(
'output.enc',
'decrypted.txt',
'MySecureP@ssw0rd123!',
(processed, total) => console.log(`decrypt ${processed}/${total} bytes`)
);encryptFileSync(inputPath: string, outputPath: string, password?: string, progress?: ProgressCallback): void
Synchronous version of file encryption. Uses PBKDF2 for key derivation instead of Argon2id for synchronous operation. Like the async path, output is staged to a sibling temp file and atomically renamed to outputPath only on success; pre-existing files at outputPath are preserved on error. The optional progress callback fires twice — once before encryption (0/totalBytes) and once after the rename succeeds (totalBytes/totalBytes); the input is streamed internally in fixed 64 KiB chunks, but the synchronous encrypt path emits no per-chunk progress events between the two. See Progress callbacks for file ops.
crypto.encryptFileSync('input.txt', 'output.enc', 'MySecureP@ssw0rd123!');
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
crypto.encryptFileSync('input.txt', 'output.enc');
// With progress callback
crypto.encryptFileSync(
'input.txt',
'output.enc',
'MySecureP@ssw0rd123!',
(processed, total) => console.log(`encrypt ${processed}/${total} bytes`)
);decryptFileSync(inputPath: string, outputPath: string, password?: string, progress?: ProgressCallback): void
Synchronous version of file decryption. Streams the ciphertext through crypto.createDecipheriv() in fixed 64 KiB chunks, read with fs.readSync and written with fs.writeFileSync(fd, chunk), so peak memory is bounded regardless of input size. Uses PBKDF2 for key derivation instead of Argon2id for synchronous operation. Both v0 (legacy) and v1 (preferred) ciphertext layouts are supported (subject to legacyMode). Output is staged to a sibling temp file and atomically renamed only after decipher.final() validates the GCM auth tag. The optional progress callback fires once before the body loop, once per body chunk, and once after the rename succeeds — see Progress callbacks for file ops.
crypto.decryptFileSync('output.enc', 'decrypted.txt', 'MySecureP@ssw0rd123!');
// With default passphrase
const crypto = new CryptoManager({ defaultPassphrase: 'MySecureP@ssw0rd123!' });
crypto.decryptFileSync('output.enc', 'decrypted.txt');
// With progress callback
crypto.decryptFileSync(
'output.enc',
'decrypted.txt',
'MySecureP@ssw0rd123!',
(processed, total) => console.log(`decrypt ${processed}/${total} bytes`)
);encryptContainer(data: Uint8Array, password?: string, meta?: ContainerMetadataInput): Promise<Uint8Array>
Isomorphic (Node + browser). Encrypts data into a self-describing, authenticated v2 container — an additive envelope format (magic HPCR, version 0x02) that is separate from the v1 text/file ciphertext. It wraps a random per-message data-encryption key under an Argon2id key-encryption key, stores optional filename/mime metadata confidentially (encrypted, never in cleartext), and embeds the plaintext SHA-256 for an end-to-end integrity check. See Container Mode.
const data = new TextEncoder().encode('report contents');
const container = await crypto.encryptContainer(data, 'MySecureP@ssw0rd123!', {
filename: 'report.txt',
mime: 'text/plain',
});
// Returns: Uint8Array (v2 container). In Node: writeFileSync('report.hpcr', container).Isomorphic counterpart of encryptContainer. Returns { data, meta } where meta is the authenticated { filename?, mime?, size }. The embedded SHA-256 is re-verified before returning; a mismatch throws CryptoError with code CONTAINER_INTEGRITY_FAILED. A v0/v1 blob (no HPCR magic, or a version byte other than 0x02) is rejected before any key derivation runs, so decryptContainer never accepts a non-container.
const { data, meta } = await crypto.decryptContainer(
container,
'MySecureP@ssw0rd123!'
);
console.log(meta); // { size: 15, filename: 'report.txt', mime: 'text/plain' }
console.log(new TextDecoder().decode(data)); // 'report contents'Validates password strength.
const isValid = crypto.validatePassword('MySecureP@ssw0rd123!');
// Returns: booleanGenerates cryptographically secure random bytes.
const random = crypto.generateSecureRandom(32);
// Returns: BufferderiveKey(password: string, salt: Buffer, overrides?: { memoryCost: number; timeCost: number; parallelism: number }): Promise<Buffer>
Derives an encryption key from a password using Argon2id. The salt must be a 32-byte Buffer (INVALID_SALT otherwise). overrides replaces this instance's Argon2id parameters for a single call — it is how the decrypt paths honour the parameters embedded in a v1 header rather than the constructor's defaults; omit it for normal use.
Warning
overrides is not bounded by decryptKdfLimits. This is a low-level primitive, so its cost parameters are yours rather than a ciphertext's and are deliberately unpoliced. Passing header-derived parameters straight through, as the second example below does, re-creates the pre-1.8.0 amplification in your own code. That example is safe only because the ciphertext is your own; on untrusted input call assertKdfWithinDecryptLimits(header.params, cm.getDecryptKdfLimits()) first, or use the high-level decrypt methods, which do it for you. See The low-level primitives are outside this policy.
const salt = crypto.generateSecureRandom(32);
const key = await crypto.deriveKey('MySecureP@ssw0rd123!', salt);
// Returns: 32-byte Buffer
// Reproduce the key of an existing ciphertext: read its embedded parameters,
// and pair them with THAT ciphertext's own salt (bytes 22..54 of a v1 record).
const header = crypto.inspectHeader(ciphertext);
if (header !== null && header.params.kind === 'argon2id') {
const embeddedSalt = Buffer.from(ciphertextBytes.subarray(22, 54));
const sameKey = await crypto.deriveKey(
'MySecureP@ssw0rd123!',
embeddedSalt,
header.params
);
}Synchronous version of key derivation using PBKDF2 (default: 600,000 iterations, SHA-256) instead of Argon2id. Pass an explicit iterations argument to override the per-instance default (used internally to apply the iteration count embedded in v1 ciphertext headers, but you can also pass it directly when calling deriveKeySync yourself).
Warning
iterations is not bounded by decryptKdfLimits, and this call blocks the event loop. The same caller-obligation boundary as deriveKey, but sharper: crypto.pbkdf2Sync runs on the calling thread, so an attacker-chosen count stalls the whole process (measured 1.00 s at 2,000,000 iterations, 5,094 ms at the 10,000,000 wire cap) and nothing downstream will refuse it. Apply assertKdfWithinDecryptLimits yourself before passing a header-derived count.
const salt = crypto.generateSecureRandom(32);
// Default iterations (600,000 — current OWASP recommendation)
const key = crypto.deriveKeySync('MySecureP@ssw0rd123!', salt);
// Returns: 32-byte Buffer
// Override iterations explicitly
const customKey = crypto.deriveKeySync('MySecureP@ssw0rd123!', salt, 250000);Low-level AES-256-GCM encryption. Returns { encrypted: Buffer, tag: Buffer }. Every argument is type- and length-checked: INVALID_DATA, INVALID_KEY (32 bytes), INVALID_IV (12 bytes), INVALID_AAD (must be a Buffer when supplied).
aadOverride replaces the instance's configured aad for this one call. It is how the high-level v1 paths bind the on-disk header bytes into the auth tag. If you pass it, you must pass the byte-identical value to decryptData, or authentication fails.
Security: the caller is responsible for ensuring each
(key, iv)pair is used at most once. See AES-GCM (key, IV) reuse for the full explanation and recommended pattern. PreferencryptText/encryptFilefor any code that does not have a specific reason to manage IVs by hand.
Throws CryptoError(INVALID_INPUT, 'DATA_TOO_LARGE_FOR_GCM') if data exceeds the AES-GCM per-invocation bound — see The AES-GCM per-invocation size limit.
const key = crypto.generateSecureRandom(32);
const iv = crypto.generateSecureRandom(12); // fresh random IV per message
const { encrypted, tag } = crypto.encryptData(Buffer.from('data'), key, iv);decryptData(encryptedData: Buffer, key: Buffer, iv: Buffer, tag: Buffer, aadOverride?: Buffer): Buffer
Low-level AES-256-GCM decryption. Returns the decrypted data as a Buffer.
The caller must supply the exact (key, iv, tag) that were produced by encryptData, plus the matching AAD — either the instance's configured aad (the default) or, if encryptData was given an aadOverride, that same override. Tag-check failure surfaces as CryptoError with code DECRYPTION_FAILED regardless of which condition failed (wrong key, wrong IV, wrong AAD, or tampered ciphertext) — the generic message is intentional, to avoid leaking which case applied. A ciphertext past the AES-GCM per-invocation bound is rejected up front with DATA_TOO_LARGE_FOR_GCM, since no such input could have been produced correctly.
const decrypted = crypto.decryptData(encrypted, key, iv, tag);Securely zeroes a buffer to remove sensitive data from memory. The parameter is Uint8Array (every Node Buffer is a Uint8Array, so existing Buffer call sites are unaffected); a plain Uint8Array — e.g. browser key material — is actually scrubbed rather than silently skipped. Best-effort only: it cannot reach immutable V8 strings or GC-managed copies (see Threat Model).
crypto.secureClear(key);Gets current encryption parameters.
const params = crypto.getParameters();
// Returns: object with algorithm detailsGets security level based on configuration.
const level = crypto.getSecurityLevel();
// Returns: 'low' | 'medium' | 'high' | 'ultra'Checks if a default passphrase is configured.
const hasDefault = crypto.hasDefaultPassphrase();
// Returns: boolean indicating if default passphrase is setReturns the configured legacy-format handling mode — one of 'auto', 'strict', or 'reject' (see the legacyMode constructor option).
const mode = crypto.getLegacyMode();
// Returns: 'auto' | 'strict' | 'reject'Parses the v1 ciphertext header without decrypting. Returns a ParsedHeader ({ version, kdfId, params, headerLen }) for a v1 ciphertext, or null when the input lacks the v1 magic bytes (i.e. a legacy v0 ciphertext). Accepts either a base64url string (text-format output) or a Uint8Array (file contents — a Node Buffer is a Uint8Array, so Buffer inputs keep working). String inputs are validated as well-formed base64url before decoding and throw CryptoError with code INVALID_BASE64URL on malformed input — so an invalid string fails fast instead of being mistaken for a v0 ciphertext; byte inputs are read as-is. An empty string, or an argument that is neither a string nor a Uint8Array, throws code INVALID_INPUT instead. A buffer that begins with the v1 magic but is otherwise malformed throws a specific parser CryptoError (e.g. TRUNCATED_HEADER, UNSUPPORTED_KDF, INVALID_HEADER_PARAM, KDF_PARAMS_OUT_OF_BOUNDS). See the worked example under Ciphertext Format (v1).
Inspecting a large ciphertext is cheap: since v1.6.0 only the first 32 base64url characters of a string input are decoded — enough for the 22-byte header with two bytes to spare — so the call allocates 24 bytes rather than a copy of the whole payload, whatever its size. The well-formedness check still scans the entire string (narrowing it would stop rejecting a malformed tail), but it does so in one allocation-free pass.
This is a v1 inspector, not a format sniffer. A v2 container reuses the same
HPCRmagic and the same 22-byte header shape, so it passes the magic check and is then rejected on its version byte:inspectHeaderon a container throwsCryptoErrorwith typeDECRYPTION_FAILEDand codeUNSUPPORTED_VERSION. It never returnsnullfor a container, and there is no input for which it reports "v2". If the input may be either format, classify it first — see Telling the formats apart.
The library exports all types, interfaces, and enums for TypeScript consumers:
import {
// Error handling
CryptoError,
CryptoErrorType,
// Enums
SecurityLevel,
EncryptionAlgorithm,
// Interfaces
type CryptoManagerOptions,
type EncryptionResult, // Node only — names `Buffer`; see the note below
type EncryptionParameters,
type ValidationResult,
type FileInfo,
type RetryConfig,
type ProgressCallback,
// Type alias for the `legacyMode` option and `getLegacyMode()`
type LegacyMode,
// Container mode (v2 envelope)
type ContainerMetadataInput,
type ContainerMetadata,
type DecryptedContainer,
// Wire-format header types (returned by `inspectHeader` / `parseHeader`)
type ParsedHeader,
type KdfId,
type KdfHeaderParams,
type Argon2idHeaderParams,
type Pbkdf2HeaderParams,
// Options bag for `validatePath` (Node only — declared in the Node-only utils)
type ValidatePathOptions,
} from '@hiprax/crypto';ParsedHeader is { version, kdfId, params, headerLen }, and params is the discriminated union KdfHeaderParams = Argon2idHeaderParams | Pbkdf2HeaderParams — narrow on params.kind ('argon2id' or 'pbkdf2-sha256') before reading memoryCost / iterations.
EncryptionResultis Node-only. It describes the{ encrypted: Buffer, tag: Buffer }return of the low-levelencryptData, so it names the NodeBufferglobal. As of v1.6.0 it lives incrypto-manager.tsrather thantypes.tsand is exported from@hiprax/crypto(unchanged) and, additionally, from@hiprax/crypto/crypto-manager. It is not part of the browser type surface — nothing on the browser build can produce one (encryptDatathere throwsUNSUPPORTED_IN_BROWSER), and a browser-only TypeScript project would previously have failed to compile on the twoBufferreferences it carried. Every other name in the list above is available in both runtimes.
Additional utility functions are also exported:
import {
validateFile,
validatePath,
generateRandomString,
validatePasswordStrength,
generateUUID,
sha256,
generateRandomHex,
secureStringCompare,
formatFileSize,
getFileExtension,
isTextFile,
sanitizeFilename,
createBackupPath,
isValidBase64,
isValidBase64Url,
createProgressBar,
sleep,
retryWithBackoff,
getFileInfo,
} from '@hiprax/crypto';
// Validate if file exists and is accessible
const fileValidation = await validateFile('path/to/file.txt');
// Validate if path is valid for writing.
// `validatePath` rejects empty input, null bytes, ASCII control characters
// (codepoints `< 0x20` or `0x7F`), Windows-illegal characters
// (`<`, `>`, `:`, `"`, `|`, `?`, `*` — drive-letter prefix excluded),
// and literal `..` traversal segments after `path.normalize`.
const pathValidation = validatePath('path/to/output.txt');
// Optional: enforce that the input path resolves inside an allowed root.
// Useful for catching within-drive cross-traversal that the literal-`..`
// segment check on its own cannot detect (because `path.normalize`
// collapses internal `..` cancel-outs to a clean path). The check is
// segment-aware (no `/etc/sec` ↔ `/etc/secret` collision) and on Windows
// is case-insensitive and forward-slash-tolerant. NOTE: this is a
// syntactic / resolved-string check; it does NOT defend against
// symlink-based escapes.
const inProject = validatePath('/home/user/project/data/file.txt', {
allowedRoot: '/home/user/project',
});
// inProject.isValid === true
// NOTE: this example is Windows-specific. Both drive-letter accommodations
// (stripping `C:` before the invalid-character scan, and before the `..`
// segment scan) are gated on `process.platform === 'win32'`.
const escape = validatePath('C:\\Users\\..\\Windows', {
allowedRoot: 'C:\\Users',
});
// On Windows: isValid === false, error === 'Path is outside the allowed root'
// On POSIX: isValid === false, error === 'File path contains invalid characters'
// (the `:` is never stripped there, so the earlier scan rejects it)
// Generate secure random string
// (default 32 chars ≈ 190 bits of entropy; request >= 44 chars for a full
// 128-bit post-quantum margin — see the sizing note below this block)
const randomString = generateRandomString(32);
// Validate password strength with detailed feedback
const passwordCheck = validatePasswordStrength('MyPassword123!');
console.log('Score:', passwordCheck.score); // 0-5
console.log('Feedback:', passwordCheck.feedback); // Array of suggestions
// Generate UUID (v4 — 122 random bits; an identifier, NOT a bearer secret)
const uuid = generateUUID();
// Hash string with SHA-256
const hash = sha256('hello world');
// Generate random hex string (each hex char = 4 bits; use >= 64 chars for
// 256-bit bearer secrets — see the sizing note below this block)
const hex = generateRandomHex(16);
// Secure string comparison (constant time)
const isEqual = secureStringCompare('secret', 'secret');
// Format file size. Throws rather than guessing: NEGATIVE_FILE_SIZE for a negative
// count, INVALID_FILE_SIZE for NaN/Infinity/non-number, FILE_SIZE_TOO_LARGE above
// Number.MAX_SAFE_INTEGER. The unit ladder caps at TB.
const size = formatFileSize(1024 * 1024); // "1 MB"
// Get file extension (lowercase)
const ext = getFileExtension('photo.JPG'); // ".jpg"
// Check if file is text file
const isText = isTextFile('document.txt');
// Sanitize filename
const safeName = sanitizeFilename('file<name>.txt'); // "file_name_.txt"
// Create backup path
const backupPath = createBackupPath('file.txt'); // "file_2026-06-30T12-00-00_a1b2c3.backup.txt"
// Validate base64
const isValid = isValidBase64('SGVsbG8gV29ybGQ=');
// Validate base64url (the format used by this library's encrypted output).
// Careful: `isValidBase64Url` (capital U, Node-only, from utils) is a thin
// delegate to `isValidBase64url` (lowercase u, isomorphic, from the codec).
// Identical behaviour, but only the lowercase spelling exists in the browser build.
const isValidUrl = isValidBase64Url('SGVsbG8gV29ybGQ');
// Create progress bar (width defaults to 30; out-of-range inputs are clamped,
// a non-positive/non-integer width falls back to 30, and total <= 0 yields 0%)
const progress = createProgressBar(50, 100); // "[███████████████░░░░░░░░░░░░░░░] 50%"
// Sleep for specified time
await sleep(1000); // Sleep for 1 second
// Retry with exponential backoff
const result = await retryWithBackoff(
async () => {
// Some async operation that might fail
return await someOperation();
},
{ maxRetries: 3, baseDelay: 1000 }
);
// Get file information
const fileInfo = await getFileInfo('path/to/file.txt');
console.log('Size:', fileInfo.size);
console.log('Extension:', fileInfo.extension);
console.log('Is Text:', fileInfo.isTextFile);Sizing random secrets for a post-quantum margin.
generateRandomStringandgenerateRandomHexdraw from the OS CSPRNG, so their strength is purely a function of length. Grover's algorithm halves the effective entropy of a random secret against a quantum adversary, so to preserve a 128-bit post-quantum margin, size bearer secrets (API keys, session tokens, capability URLs) at 256 bits:generateRandomHex(64)(64 hex chars) orgenerateRandomString(44)(≈262 bits). The defaults (32 chars) are ample for identifiers and classical threat models.generateUUIDoutput carries 122 random bits and is designed as a collision-resistant identifier — do not use it as an unguessable bearer token where post-quantum unpredictability matters. See Post-Quantum Security.
Beyond CryptoManager and the utility helpers, both entry points re-export the wire-format
constants and the pure byte codecs. They exist so that tooling can classify, inspect and
transcode this library's output without decrypting it, and they are covered by the same
compatibility promise as the rest of the public API.
import {
// Password policy, shared with the manager
isValidPassword,
SECURITY_THRESHOLDS,
// Format identity
MAGIC_BYTES,
MAGIC_LENGTH,
VERSION_LENGTH,
KDF_ID_LENGTH,
KDF_PARAMS_LENGTH,
HEADER_LENGTH, // 22
FORMAT_VERSION, // 0x01 — v1 text/file ciphertext
CONTAINER_VERSION, // 0x02 — v2 container
KDF_ID_ARGON2ID, // 0x00
KDF_ID_PBKDF2_SHA256, // 0x01
// Header helpers
hasMagic,
packHeader,
parseHeader,
// Parser DoS caps (see Threat Model)
MAX_ARGON2_MEMORY_COST, // 2 ** 22
MAX_ARGON2_TIME_COST, // 100
MAX_ARGON2_PARALLELISM, // 64
MAX_PBKDF2_ITERATIONS, // 10_000_000
// AES-GCM per-invocation bound
MAX_GCM_PLAINTEXT_BYTES, // 2 ** 36 - 32
assertGcmPlaintextLimit,
// Pure, isomorphic byte codecs
bytesToBase64url,
base64urlToBytes,
isValidBase64url,
bytesToHex,
utf8Encode,
utf8Decode,
concatBytes,
} from '@hiprax/crypto';Three notes that decide whether code written against these behaves the same in both runtimes:
-
MAGIC_BYTESand the header helpers are typed per runtime, and on the Node entry two of them areBuffer-only by contract.hasMagicreturnsfalsefor a plainUint8Array, andparseHeaderthrowsCryptoError(INVALID_INPUT, 'INVALID_HEADER_INPUT')for one. That is a trap, becauseencryptBytesandencryptContainerreturn a plainUint8Arrayin both runtimes even though those bytes genuinely begin withHPCR:const bytes = await cm.encryptBytes(plaintext, password); // Uint8Array, starts "HPCR" hasMagic(bytes); // Node entry: false (!) Browser entry: true parseHeader(bytes); // Node entry: THROWS INVALID_HEADER_INPUT hasMagic(Buffer.from(bytes)); // Node entry: true parseHeader(Buffer.from(bytes)); // Node entry: the parsed header cm.inspectHeader(bytes); // works on BOTH entries — prefer this
The contract is preserved on purpose (these two were
Buffer-typed long before the browser build existed). For code that must behave identically in both runtimes, use theinspectHeadermethod, or compare againstMAGIC_BYTESelement-wise; see Telling the formats apart. -
The codecs are byte-for-byte compatible with the Node
Bufferequivalents, leniency included:base64urlToBytesterminates at=, skips non-alphabet code units (so line-wrapped input decodes), accepts the standard+//alphabet as aliases, and discards an incomplete trailing sextet.isValidBase64url, by contrast, is a strict canonical check: it returnstruefor exactly the stringsbytesToBase64urlemits. -
packHeader/parseHeaderrun the same logic the library runs on itself, DoS caps and RFC 9106 floor included, so a headerparseHeaderrejects is one no decrypt path would have honoured.
| Node entry | Browser entry | |
|---|---|---|
| Runtime bindings | 56 (55 named + default) |
37 (36 named + default) |
CryptoManager, SECURITY_THRESHOLDS, isValidPassword, CONTAINER_VERSION |
✅ | ✅ |
CryptoError, CryptoErrorType, SecurityLevel, EncryptionAlgorithm |
✅ | ✅ |
Wire-format constants + hasMagic/packHeader/parseHeader |
✅ (Buffer-typed) |
✅ (Uint8Array-typed) |
Decrypt KDF policy (DEFAULT_DECRYPT_KDF_LIMITS, resolveDecryptKdfLimits, assertKdfWithinDecryptLimits) |
✅ | ✅ |
Codecs (bytesToBase64url, …) |
✅ | ✅ |
The 19 utils helpers (validatePath, sha256, sleep, …) |
✅ | ❌ Node-only (node:fs/node:path) |
EncryptionResult, ValidatePathOptions (types) |
✅ | ❌ Node-only |
Count them yourself against a build:
node -e "import('@hiprax/crypto').then(m => console.log(Object.keys(m).sort().join('\n')))"@hiprax/crypto runs the same code, over the same wire format, in Node and the browser. The in-memory async API — encryptBytes / decryptBytes / encryptText / decryptText / encryptContainer / decryptContainer / inspectHeader / validatePassword / getParameters / getSecurityLevel — lives in a runtime-agnostic core and is available in both builds. The runtime-specific primitives (CSPRNG, Argon2id, AES-256-GCM, SHA-256) are the only thing that differs: Node uses node:crypto plus one of its three Argon2id providers, the browser uses Web Crypto (SubtleCrypto) + WebAssembly Argon2id (hash-wasm). There is exactly one ciphertext format — a blob produced in one runtime decrypts in the other.
encryptBytes(data, password?) / decryptBytes(data, password?) are the foundation: they take and return Uint8Array, and encryptText / decryptText are thin base64url wrappers over them (one wire format, one code path).
import { CryptoManager } from '@hiprax/crypto';
const cm = new CryptoManager();
const bytes = new TextEncoder().encode('Hello World'); // any Uint8Array, incl. binary
const ct = await cm.encryptBytes(bytes, 'MySecureP@ssw0rd123!'); // Uint8Array
const back = await cm.decryptBytes(ct, 'MySecureP@ssw0rd123!'); // byte-identicalThe empty Uint8Array is accepted and produces a valid authenticated ciphertext. The caller's data buffer is never mutated or scrubbed (it belongs to the caller).
Because both runtimes share one format and one KDF (Argon2id, whose native, Node-built-in and WASM outputs are all bit-identical for the same parameters), a ciphertext crosses the boundary transparently:
// In the browser (32 MiB Argon2id default):
const ct = await cm.encryptText('secret', 'MySecureP@ssw0rd123!');
// ...transmit `ct` (a base64url string) to a Node service...
// In Node:
const plaintext = await new CryptoManager().decryptText(
ct,
'MySecureP@ssw0rd123!'
);
// -> 'secret' (Node reads the KDF params embedded in the ciphertext header)The reverse direction (Node → browser) works identically, subject to the memory caveat below.
Install the WASM Argon2id provider alongside the package (Web Crypto has no Argon2id), import from the package root, and — for large payloads — hand the ciphertext bytes to a Blob:
npm install @hiprax/crypto hash-wasmimport { CryptoManager } from '@hiprax/crypto';
const cm = new CryptoManager();
// Encrypt a File/Blob the user selected, entirely client-side:
async function encryptFileInBrowser(
file: File,
password: string
): Promise<Blob> {
const plaintext = new Uint8Array(await file.arrayBuffer());
const ct = await cm.encryptBytes(plaintext, password); // Uint8Array
return new Blob([ct], { type: 'application/octet-stream' });
}
// Decrypt back to a Blob for download:
async function decryptToBlob(
ciphertext: Uint8Array,
password: string
): Promise<Blob> {
const plaintext = await cm.decryptBytes(ciphertext, password);
return new Blob([plaintext]);
}Browser large-file handling is in-memory (read the file, encryptBytes/decryptBytes, hand back a Blob): there is no browser streaming, because Web Crypto AES-GCM is one-shot. Peak memory is proportional to the payload size — a documented limit, not a bug.
The Node default is 128 MiB Argon2id (memoryCost = 2 ** 17, classified HIGH). The browser default is a lighter 32 MiB profile (memoryCost = 2 ** 15, timeCost = 3, parallelism = 1) — which still strictly exceeds OWASP's stated minimum configuration on both axes: 32 MiB against its 19 MiB, at t=3 against its t=2. That is ≈1.68x the minimum's memory with an extra iteration on top. It is nevertheless classified MEDIUM by getSecurityLevel(), because 32 MiB is below this library's own HIGH threshold. This is a runtime-specific default, not a format change; you can pass an explicit memoryCost, and every ciphertext carries its own KDF parameters on the wire.
⚠️ Decrypt-side memory caveat. Because each ciphertext header embeds the exactmemoryCostused to derive its key, decrypting a ciphertext produced at 128 MiB requires allocating 128 MiB — which can OOM a memory-constrained mobile browser tab (iOS Safari WASM ceilings are as low as ~64–120 MB). Data intended to be decrypted in browsers should be encrypted at ≤ the browser memory profile (e.g. the 32 MiB browser default). The wire format is identical across runtimes; only the affordable KDF cost differs. Node → browser interop is only reliable when the Node side encrypts within the browser's memory budget.
The browser Argon2id path compiles WebAssembly (hash-wasm). Under a strict CSP that sets script-src (or default-src) without 'unsafe-eval', WebAssembly compilation is blocked and throws a CompileError. Allow it with the strictly-narrower 'wasm-unsafe-eval' (WASM only — not JS eval):
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'hash-wasm instantiates from inline bytes (the WASM is embedded as base64), so no connect-src entry and no network fetch are needed. Pages with no CSP run WASM fine. Supported: Chrome/Edge 97+, Firefox 102+, Safari 16+.
The browser build is honest about weaker memory hygiene than Node:
- Opaque
CryptoKey. Web CryptoimportKeycopies the raw key bytes into aCryptoKeyobject the library can no longer reach — sosecureClearcannot scrub it. The engine zeroes the transient raw-key copy it owns immediately after import, but theCryptoKeyitself lives until GC. - Immutable V8 strings. As in Node, passwords and decrypted text are JavaScript strings; they are GC-managed and cannot be zeroed (see Threat Model).
- Secure context required.
crypto.subtleis only available in a secure context (HTTPS orlocalhost). On an insecure origin,globalThis.crypto.subtleisundefinedand the key import raises aTypeError. What you catch differs by direction: on the encrypt path thatTypeErrorpropagates unchanged, while on the decrypt path it is converted toCryptoErrorwith codeDECRYPTION_FAILED, because the decrypt engine wraps the whole operation.
The synchronous (PBKDF2) paths, the streaming file paths, and the Buffer-typed low-level primitives cannot be expressed with one-shot, async Web Crypto, so in the browser build all eleven are present as stubs that raise CryptoError(INVALID_INPUT, 'UNSUPPORTED_IN_BROWSER'): encryptTextSync, decryptTextSync, encryptFile, decryptFile, encryptFileSync, decryptFileSync, encryptData, decryptData, deriveKey, deriveKeySync, and generateSecureRandom.
Each stub fails the way its Node counterpart's signature promises, which matters for error handling: the eight synchronous ones throw synchronously, while the three declared async (deriveKey, encryptFile, decryptFile) reject rather than throw, so a .catch() or a try/await/catch written against the Node build keeps working unchanged against the browser build. Use the in-memory async API (encryptBytes / decryptBytes / encryptText / decryptText / encryptContainer / decryptContainer) instead. The ./utils file helpers are Node-only and are not exported from the browser build.
Container mode is an optional, additive wire format (magic HPCR, version 0x02) for sealing a payload together with confidential metadata and an end-to-end integrity check. It is separate from the v1 text/file ciphertext — it does not touch encryptBytes/decryptBytes — and, like the rest of the in-memory API, it is isomorphic (available in Node and the browser). It differs from encryptBytes in three ways:
- Two-layer keying (KEK/DEK). An Argon2id key-encryption key (KEK), derived from the password, AES-256-GCM-wraps a fresh random 32-byte data-encryption key (DEK); the DEK encrypts the metadata and the payload.
- Confidential metadata. The optional
filename/mime(plus the derivedsizeand the plaintext SHA-256) are packed into a metadata block that is itself encrypted under the DEK — none of it appears in cleartext anywhere in the output. - End-to-end integrity. The SHA-256 of the plaintext is embedded and re-verified on decrypt (
CONTAINER_INTEGRITY_FAILEDon mismatch), on top of the per-segment GCM authentication.
The AES-GCM AAD of all three segments (DEK-wrap, metadata, payload) is the configured context string (aad) bound to the verbatim 22-byte header, so tampering with the version, KDF parameters, or any reserved byte flips every tag — and, exactly like the v1 path, a custom aad provides cross-application domain separation: a container sealed by a manager configured with aad: 'app-A' cannot be opened by one configured with aad: 'app-B', even with the same password and parameters.
import { CryptoManager } from '@hiprax/crypto';
const cm = new CryptoManager();
const data = new TextEncoder().encode('report contents');
const container = await cm.encryptContainer(data, 'MySecureP@ssw0rd123!', {
filename: 'report.txt',
mime: 'text/plain',
});
const { data: back, meta } = await cm.decryptContainer(
container,
'MySecureP@ssw0rd123!'
);
console.log(meta); // { size: 15, filename: 'report.txt', mime: 'text/plain' }Container mode is an in-memory format (no streaming). Persist or transmit the bytes with the idioms natural to each runtime:
// Node — a file container:
import { readFileSync, writeFileSync } from 'node:fs';
writeFileSync(
'report.hpcr',
await cm.encryptContainer(readFileSync('report.txt'), pw, {
filename: 'report.txt',
mime: 'text/plain',
})
);
// Browser — download as a Blob:
const blob = new Blob([await cm.encryptContainer(bytes, pw, { filename })]);The container is memory-bounded (the whole payload is held in memory to hash and encrypt it); it is intended for small-to-medium payloads. For arbitrarily large files in Node, prefer the streaming
encryptFile/decryptFile(v1) methods.
Container mode and the v1 ciphertext path reject each other's blobs:
- Feeding a v2 container to
decryptBytes/decryptTextthrowsCryptoError(in the defaultlegacyMode: 'auto'the v1 header parser rejects the0x02version and the v0 fallback cannot rescue it, surfacing asDECRYPTION_FAILED;strict/rejectsurfaceUNSUPPORTED_VERSION). - Feeding a v0/v1 blob to
decryptContaineris rejected by a pure, DoS-bounded header parse before any key derivation runs (noHPCRmagic →CONTAINER_INVALID_MAGIC; wrong version →CONTAINER_UNSUPPORTED_VERSION).
The complete set, grouped by when it can fire:
- Argument type: a
containerthat is not aUint8ArraythrowsINVALID_ENCRYPTED_DATA, not a container-specific code —decryptContainertype-checks its own argument before it reaches the parser. (The parser has its ownINVALID_CONTAINER_INPUTfor the same condition, but the internal parser is not exported, so that code is unreachable through the public API.) - Pre-authentication structural parse (before any key derivation):
TRUNCATED_CONTAINER(shorter than the 174-byte fixed overhead, or ametaLenthat overruns the buffer),CONTAINER_INVALID_MAGIC,CONTAINER_UNSUPPORTED_VERSION(version byte ≠0x02),CONTAINER_UNSUPPORTED_KDF,CONTAINER_INVALID_HEADER_PARAM(a non-positive parameter, ormemoryCost < 8 * parallelism),CONTAINER_KDF_PARAMS_OUT_OF_BOUNDS,CONTAINER_KDF_COST_EXCEEDS_DECRYPT_LIMITSandCONTAINER_KDF_COST_BELOW_DECRYPT_MINIMUM(within the wire-format caps but outside this manager'sdecryptKdfLimitsbudget). - Input validation on the seal side:
INVALID_CONTAINER_META(non-stringfilename/mime),CONTAINER_METADATA_TOO_LARGE(a field over 65535 UTF-8 bytes),CONTAINER_DATA_TOO_LARGE(payload over0xffffffff). - Post-authentication:
CONTAINER_METADATA_MALFORMED(the decrypted metadata block has an unknown flag bit, a length prefix that overruns, or trailing bytes),CONTAINER_INTEGRITY_FAILED(the decrypted payload does not match its embedded SHA-256, or its length does not match the sealedsize). - Operation wrappers, for a non-
CryptoErrorthat escapes the engine:CONTAINER_ENCRYPTION_FAILED(typeENCRYPTION_FAILED) andCONTAINER_DECRYPTION_FAILED(typeDECRYPTION_FAILED).
Every code above carries type DECRYPTION_FAILED except INVALID_ENCRYPTED_DATA, TRUNCATED_CONTAINER, CONTAINER_KDF_PARAMS_OUT_OF_BOUNDS, CONTAINER_KDF_COST_EXCEEDS_DECRYPT_LIMITS, CONTAINER_KDF_COST_BELOW_DECRYPT_MINIMUM, INVALID_CONTAINER_META, CONTAINER_METADATA_TOO_LARGE, CONTAINER_DATA_TOO_LARGE and the unreachable INVALID_CONTAINER_INPUT, which are INVALID_INPUT, and CONTAINER_ENCRYPTION_FAILED, which is ENCRYPTION_FAILED. All of the above were confirmed by probe against this build. A wrong password, a mismatched aad, or any single-bit tamper surfaces as the generic DECRYPTION_FAILED — never as one of the specific codes — so there is no oracle. The byte layout is documented under Container Format (v2).
The library provides both asynchronous and synchronous versions of encryption/decryption operations:
- Use Argon2id for key derivation (more secure)
- Better for performance and scalability
- Non-blocking operations on the native and built-in Argon2id providers; on the WASM provider the derivation runs on the calling thread and blocks the event loop, so check which provider answered before serving untrusted traffic
- Methods:
encryptText(),decryptText(),encryptFile(),decryptFile()
- Use PBKDF2 for key derivation (less secure but synchronous)
- Blocking operations
- Useful for simple scripts or when async/await is not available
- Methods:
encryptTextSync(),decryptTextSync(),encryptFileSync(),decryptFileSync()
Note: Synchronous operations use PBKDF2 with 600,000 iterations (SHA-256) by default — matching the OWASP 2023+ recommendation for PBKDF2-HMAC-SHA256 (still current in 2026). The iteration count is configurable via the constructor option pbkdf2Iterations and is embedded in every v1 ciphertext header so changing it later does not break old data. Argon2id (used by the async paths) remains the stronger choice for production use because it is memory-hard.
Backward compatibility: Versions of this library prior to 0.11.0 used 100,000 PBKDF2 iterations and did not embed the iteration count in the ciphertext. Such legacy v0 ciphertexts continue to decrypt successfully under the default legacyMode: 'auto'; the decoder uses legacyPbkdf2Iterations (default 100,000) as the assumed iteration count. Override legacyPbkdf2Iterations only if you have legacy data that was produced with a non-default value.
Security upgrade rationale: Bumping the default from 100,000 → 600,000 reflects the 6× increase in baseline GPU brute-force resistance recommended by OWASP since 2023. Existing v1 ciphertexts produced with the old default would still decrypt correctly because their iteration count is embedded in the header — only legacy v0 sync data is affected, and it remains decryptable under legacyMode: 'auto'.
Important: Synchronous and asynchronous functions are not compatible with each other due to different key derivation methods. Always use the same type (sync or async) for both encryption and decryption.
All four file methods accept an optional fourth argument: a progress callback of type ProgressCallback = (bytesProcessed: number, totalBytes: number) => void. When supplied, the callback is invoked periodically during encryption/decryption so callers can drive UI updates or back-pressure-aware pipelines.
| Method | Initial event | Per-chunk events | Final event | Total denomination |
|---|---|---|---|---|
encryptFile |
(0, totalBytes) |
per readable data event |
(totalBytes, totalBytes) |
input file size (plaintext bytes) |
decryptFile |
(0, totalBytes) |
per readable data event |
(totalBytes, totalBytes) |
input file size (ciphertext) |
encryptFileSync |
(0, totalBytes) |
none — no per-chunk events | (totalBytes, totalBytes) |
input file size (plaintext bytes) |
decryptFileSync |
(0, totalBytes) |
per 64 KiB chunk | (totalBytes, totalBytes) |
input file size (ciphertext) |
Universal invariants: processed is monotonically non-decreasing across events for a single call, every event reports the same total, and the final invocation always has processed === total (so callers can rely on a single "100% done" signal).
If the supplied callback throws, the throw propagates out of the file method and aborts the encryption/decryption — the temp file is cleaned up, no partial output is written to outputPath, and the original error reaches the caller. The library preserves the caller's error identity (e.g. instanceof MyError continues to work) rather than wrapping the throw in CryptoError(FILE_ENCRYPTION_FAILED). This is the intentional design: a callback that throws is a caller-side bug, and silently swallowing it would hand back a "successful" encryption to a caller who thought they had aborted.
If you want best-effort progress reporting that never aborts the underlying op, wrap your callback in a try/catch yourself:
await crypto.encryptFile(
'input.txt',
'output.enc',
password,
(processed, total) => {
try {
myUI.updateProgress(processed, total);
} catch {
// swallowed — encryption keeps going
}
}
);// `ProgressCallback` is an interface, so it needs a type-only import — a plain
// named import fails under `verbatimModuleSyntax` / `isolatedModules`.
import { CryptoManager, type ProgressCallback } from '@hiprax/crypto';
const crypto = new CryptoManager();
// Async encrypt with a console progress bar
const printProgress: ProgressCallback = (processed, total) => {
const pct = total === 0 ? 100 : Math.round((processed / total) * 100);
process.stdout.write(`\rencrypt ${pct}% (${processed}/${total} bytes)`);
};
await crypto.encryptFile('input.bin', 'output.enc', password, printProgress);
// Async decrypt with the same callback shape
await crypto.decryptFile(
'output.enc',
'decrypted.bin',
password,
(processed, total) => console.log(`decrypt ${processed}/${total} bytes`)
);
// Sync encrypt — fires once at start and once after the rename
crypto.encryptFileSync(
'input.bin',
'output.enc',
password,
(processed, total) => console.log(`sync encrypt ${processed}/${total}`)
);
// Sync decrypt — fires per 64 KiB chunk
crypto.decryptFileSync(
'output.enc',
'decrypted.bin',
password,
(processed, total) => console.log(`sync decrypt ${processed}/${total}`)
);The progress argument is fully optional — every call shape that worked before this argument was added continues to work unchanged.
The library supports different security levels based on Argon2 parameters. The current threshold table is the one that ships with the v1.0.0 stable release; it was last tightened during pre-1.0 development (in the v0.15.0 dev iteration, Task 18) to track OWASP 2026 guidance for Argon2id — the HIGH tier moved from memoryCost: 2^16 (64 MiB) up to memoryCost: 2^17 (128 MiB), and ULTRA moved from 2^18 up to 2^19 (512 MiB):
- Low:
memoryCost < 2^14ORtimeCost < 2(Fast, less secure — fallback tier) - Medium:
memoryCost: 2^14(16 MiB),timeCost: 2— this library's own floor, and it sits below OWASP's stated minimum: the samet=2, but 16 MiB against the 19 MiB OWASP asks for. TreatMEDIUMas a deliberate trade for resource-constrained devices, not as a recommended configuration. The 12/9/7 MiB rows in OWASP's list are not cover for it, because each pairs its lower memory with more iterations, whichMEDIUMdoes not. (The browser default is also classifiedMEDIUMby this table, but at 32 MiB /t=3it exceeds OWASP's minimum on both axes — the tier label is coarser than the comparison.) - High:
memoryCost: 2^17(128 MiB),timeCost: 3(the Node default — above every configuration OWASP lists, which top out atm=47104/ 46 MiB, and far above the stated minimum) - Ultra:
memoryCost: 2^19(512 MiB),timeCost: 4(Maximum — paranoid tier for offline / async-only workloads)
A configuration is reported at a tier only when both memoryCost AND timeCost clear that tier's minimum; if either parameter falls short, classification falls through to the next-lower tier.
The default memoryCost was bumped from 2^16 (64 MiB) to 2^17 (128 MiB) during pre-1.0 development and is the v1.0.0 stable default. This is a deliberate performance regression that doubles the memory footprint and roughly doubles the latency of every async key-derivation call. The trade-off buys roughly 2× the GPU brute-force resistance in line with current OWASP guidance.
- Existing v1 ciphertexts continue to decrypt unchanged. Each ciphertext header embeds the exact
memoryCost/timeCost/parallelismthat were used to derive its key, so the decoder applies the embedded values rather than the constructor default. Data encrypted under the old 64 MiB default round-trips under the new default with no migration step. - To opt back into the previous 64 MiB profile, pass
memoryCost: 2 ** 16to theCryptoManagerconstructor. This is the recommended escape hatch for resource-constrained environments (mobile, embedded, low-memory containers, shared free-tier hosts). Note: aCryptoManagerconfigured this way will reportgetSecurityLevel() === 'medium'rather than'high', which accurately reflects the post-bump threshold table. - To be classified ULTRA, you now need
memoryCost: 2 ** 19(512 MiB) ANDtimeCost: 4. The settings that used to earn ULTRA (2 ** 18= 256 MiB withtimeCost: 4) now classify as HIGH, because 256 MiB clears the new HIGH floor of2 ** 17but not the new ULTRA floor of2 ** 19.
The threshold table is exported as SECURITY_THRESHOLDS so downstream tooling can assert configurations meet a baseline at startup:
import { CryptoManager, SECURITY_THRESHOLDS } from '@hiprax/crypto';
const cm = new CryptoManager({
memoryCost: SECURITY_THRESHOLDS.HIGH.memoryCost,
timeCost: SECURITY_THRESHOLDS.HIGH.timeCost,
});
// Or assert that whatever was configured meets your minimum:
const params = cm.getParameters();
if (
params.argon2Options.memoryCost < SECURITY_THRESHOLDS.HIGH.memoryCost ||
params.argon2Options.timeCost < SECURITY_THRESHOLDS.HIGH.timeCost
) {
throw new Error('crypto policy below HIGH');
}SECURITY_THRESHOLDS is Object.freezed (recursively) and typed as const, so consumers cannot mutate it to weaken the bar at runtime.
v1.1.0 ships two security fixes that change the on-disk wire format for v1 ciphertexts (a security-fix patch release; v0 ciphertexts are unaffected):
- The 22-byte v1 header is now bound to the AES-GCM auth tag (via the AAD). Pre-fix, an attacker could flip bits in the header's reserved-byte regions (offsets 16–21 for Argon2id, 10–21 for PBKDF2-SHA256) without invalidating the auth tag — a categorical break of the integrity contract. Post-fix, ANY mutation of the header bytes (including reserved-byte regions) flips the GCM tag and decryption fails with
DECRYPTION_FAILED. parseHeaderrejects out-of-range KDF parameters withKDF_PARAMS_OUT_OF_BOUNDSBEFORE invoking the KDF. Caps: Argon2idmemoryCost <= 2^22(4 GiB),timeCost <= 100,parallelism <= 64; PBKDF2iterations <= 10_000_000. Note these bounds are inclusive, so they do not by themselves stop a ~100-byte ciphertext from requestingmemoryCost = 4 GiB: that is what the per-instancedecryptKdfLimitsbudget added in 1.8.0 is for — see Decrypting untrusted ciphertext. Additionally,parseHeaderenforces the Argon2id RFC 9106 §3.1 cross-field floormemoryCost >= 8 * parallelism(codeINVALID_HEADER_PARAM); no legitimately-produced ciphertext can violate this floor since the constructor enforces it at construction time.
Impact on existing v1 ciphertexts: v1 ciphertexts produced by v1.0.0 specifically were encrypted with the unbound AAD and therefore will NOT decrypt under v1.1.0's default. Two migration paths:
// Option A: re-encrypt under v1.1.0 (recommended).
const cmLegacy = new CryptoManager({ legacyHeaderAad: true });
const cmNew = new CryptoManager(); // v1.1.0 default — header-bound AAD
const plaintext = await cmLegacy.decryptText(v100Ciphertext, password);
const v101Ciphertext = await cmNew.encryptText(plaintext, password);
// Option B: keep using legacyHeaderAad: true at decrypt time.
// Note this opts back in to the v1.0.0 vulnerability where reserved
// bytes are silently mutable. Recommend Option A.
const cmCompat = new CryptoManager({ legacyHeaderAad: true });
const plaintext = await cmCompat.decryptText(v100Ciphertext, password);v0 (legacy unversioned) ciphertexts are unaffected by either change — they always used (and continue to use) just the AAD context string for AAD, and they have no header to subject to the parameter caps.
A password is accepted if either of the following holds:
- Passphrase rule (NIST SP 800-63B style) — at least 20 characters, regardless of character composition. This accepts XKCD-style multi-word passphrases like
correct horse battery staple longerwhose entropy comes from word choice rather than category mixing. - Composition rule — at least 8 characters, AND contains:
- at least one uppercase letter (
[A-Z]) - at least one lowercase letter (
[a-z]) - at least one digit (
\d) - at least one non-alphanumeric character (any character outside
[A-Za-z0-9]— so_,-,+,[,], non-ASCII punctuation, etc. all count as "special").
- at least one uppercase letter (
Validation runs on encryptText / encryptTextSync / encryptFile / encryptFileSync so that newly produced ciphertexts always use a strong key. It does not run on decryption — once encrypted, data stays decryptable with whatever password was accepted at encryption time.
defaultPassphrase (constructor option) is also validated at construction time so misconfiguration fails fast with WEAK_PASSWORD rather than at first use. If you need to decrypt legacy data encrypted under a weaker password, pass skipPasswordValidation: true to bypass the constructor check (this does not disable encryption-time validation, and does not disable Unicode NFC normalisation in deriveKey/deriveKeySync).
Memory hygiene of defaultPassphrase. The library stores the configured defaultPassphrase on the instance as a plain V8 string. V8 strings are immutable and GC-managed, so secureClear (which only zero-fills Buffer-backed allocations) cannot scrub them — the password stays resident for the full lifetime of the CryptoManager instance plus an unbounded GC tail for any internal V8 string copies. For sensitive workloads, prefer passing the password explicitly to each encrypt* / decrypt* call so the password's V8-string lifetime is bounded by the call frame rather than the manager instance. defaultPassphrase is a convenience for short-lived scripts and CLI tools where the additional retention is not part of your threat model. See Threat Model for the broader memory-hygiene picture.
Passwords are NFC-normalised (String.prototype.normalize('NFC')) before key derivation. This means 'café' typed as a precomposed é (U+00E9) and the same character typed as e + U+0301 (combining acute accent) derive the same key — visually identical input always produces identical ciphertexts regardless of how the input method composed it.
Every ciphertext produced by this library — text or file, async or sync — begins with a 22-byte versioned header. This makes the format self-describing: the KDF used and its exact parameters travel with the ciphertext, so a CryptoManager configured with different defaults can still decrypt data produced by another instance.
Header layout (22 bytes total)
| Offset | Length | Field | Meaning |
|---|---|---|---|
| 0 | 4 | magic |
ASCII "HPCR" — identifies a v1 ciphertext |
| 4 | 1 | version |
0x01 — current format version |
| 5 | 1 | kdf-id |
0x00 = Argon2id (async paths), 0x01 = PBKDF2-SHA256 (sync) |
| 6 | 16 | kdf-params |
KDF-specific parameter block (see below) |
KDF parameter block (16 bytes, big-endian)
For Argon2id (kdf-id = 0x00):
[memoryCost: 4 bytes BE u32][timeCost: 4 bytes BE u32][parallelism: 2 bytes BE u16][reserved: 6 bytes zero]
For PBKDF2-SHA256 (kdf-id = 0x01):
[iterations: 4 bytes BE u32][reserved: 12 bytes zero]
Full ciphertext layouts
Text (base64url-encoded):
[v1 header: 22][salt: 32][iv: 12][tag: 16][ciphertext: variable]
File (binary):
[v1 header: 22][salt: 32][iv: 12][ciphertext: variable][tag: 16]
The tag-position difference (text vs file) is unchanged from the legacy format: file encryption streams the ciphertext and only knows the auth tag once the stream completes, so it must append the tag at the end.
Backward compatibility (v0)
Ciphertexts produced before this format was introduced (no magic bytes) are still accepted by default. The constructor option legacyMode controls this behaviour:
'auto'(default): legacy v0 ciphertexts are decrypted using the parameters configured on thisCryptoManager.'strict': legacy v0 ciphertexts are rejected withCryptoErrorcodeLEGACY_FORMAT_REJECTED.'reject': legacy v0 ciphertexts are rejected withCryptoErrorcodeUNSUPPORTED_FORMAT.
Encryption always produces v1 — there is no option to fall back to v0.
import { CryptoManager } from '@hiprax/crypto';
// Default: accept legacy v0 ciphertexts.
const auto = new CryptoManager();
// Refuse to decrypt anything that lacks the v1 header.
const strict = new CryptoManager({ legacyMode: 'strict' });
// Inspect the format of an existing ciphertext without decrypting:
const encrypted = await auto.encryptText('hello', 'MyP@ssw0rd123!');
const header = auto.inspectHeader(encrypted);
console.log(header); // { version: 1, kdfId: 0, params: { kind: 'argon2id', ... }, headerLen: 22 }Decryption error codes specific to the format header
UNSUPPORTED_VERSION: header magic matches but the version byte is not0x01— most often a v2 container (version0x02) fed to a v1 path.inspectHeaderreports it unconditionally; the decrypt methods do not always (see the note below).UNSUPPORTED_KDF: header KDF identifier is unknown.KDF_MISMATCH: ciphertext was produced by the sync path but is being decrypted by the async path (or vice-versa).INVALID_MAGIC: only emitted by the low-levelparseHeaderhelper when the magic check fails (the high-level decrypt methods treat that case as v0 and applylegacyMode).TRUNCATED_HEADER,INVALID_HEADER_PARAM: defensive parser errors for malformed v1 input.KDF_PARAMS_OUT_OF_BOUNDS: a header asking for KDF work beyond the wire-format caps.KDF_COST_EXCEEDS_DECRYPT_LIMITS/KDF_COST_BELOW_DECRYPT_MINIMUM: a header within the wire-format caps but outside this manager's owndecryptKdfLimitsbudget. Raised before any key derivation; the message names which ceiling fired.LEGACY_FORMAT_REJECTED/UNSUPPORTED_FORMAT: emitted in'strict'/'reject'modes when a v0 ciphertext is presented.
In the default legacyMode: 'auto', most of the codes above are not what a decrypt call will actually report. auto treats any header-parse failure as "this might be a v0 ciphertext whose 32-byte random salt happened to start with HPCR", so it swallows the specific code and retries the blob as v0. Concretely:
| Behaviour | |
|---|---|
Masked in auto, visible in strict/reject |
UNSUPPORTED_VERSION, UNSUPPORTED_KDF, KDF_MISMATCH, INVALID_HEADER_PARAM, TRUNCATED_HEADER |
| Never masked, in any mode | KDF_PARAMS_OUT_OF_BOUNDS, KDF_COST_EXCEEDS_DECRYPT_LIMITS, KDF_COST_BELOW_DECRYPT_MINIMUM, and (since v1.9.0, only when a floor is configured) FALLBACK_KDF_COST_BELOW_DECRYPT_MINIMUM — DoS rejection is never traded away for legacy recovery. All four are enforced outside the header-parse try/catch, so auto structurally cannot swallow them |
What auto reports instead |
INVALID_ENCRYPTED_DATA_SIZE if the blob is under 60 bytes, otherwise the generic DECRYPTION_FAILED — unless a minWork/minPbkdf2Iterations floor is configured and this manager's fallback cost does not meet it, in which case FALLBACK_KDF_COST_BELOW_DECRYPT_MINIMUM is reported ahead of both, because that check precedes the minimum-size check |
That 60 is salt + iv + tag (32 + 12 + 16): the v0 retry treats the whole blob as a body with no header, so anything shorter fails the minimum-size check before any key derivation, and anything longer reaches GCM and fails authentication. Measured on this build: decryptText of an encryptTextSync ciphertext reports DECRYPTION_FAILED under auto and KDF_MISMATCH under strict; a 10-byte blob beginning HPCR reports INVALID_ENCRYPTED_DATA_SIZE under auto and TRUNCATED_HEADER under strict. If you are debugging a format problem, construct a strict manager to see the real reason.
Container mode is an additive, separate wire format — it reuses the 22-byte header shape but stamps version 0x02 and does not touch the v0/v1 text/file paths. It seals a payload under a two-layer key hierarchy (an Argon2id key-encryption key wraps a random data-encryption key), encrypts a confidential metadata block, and embeds the plaintext SHA-256 for an end-to-end integrity check. All three GCM segments are bound to the verbatim header AAD.
[magic "HPCR": 4][version 0x02: 1][kdf-id 0x00 (Argon2id): 1][kdf-params: 16] # 22-byte header (AAD)
[salt: 32]
[kekIv: 12][wrappedDek: 32][kekTag: 16] # random 32-byte DEK, AES-256-GCM-wrapped under the Argon2id KEK
[metaIv: 12][metaLen: 4 BE u32][encMeta: metaLen][metaTag: 16] # confidential metadata, encrypted under the DEK
[dataIv: 12][encData: variable][dataTag: 16] # payload, encrypted under the DEK
The kdf-params block is the same Argon2id layout as v1 ([memoryCost u32BE][timeCost u32BE][parallelism u16BE][reserved 6×0x00]); containers are always Argon2id. The encMeta segment decrypts (under the DEK) to a canonical serialization of { flags, size, sha256, filename?, mime? } — the filename/mime bytes are present only inside this encrypted block and never in cleartext. After the payload decrypts, its SHA-256 (and length) are re-checked against the sealed-in values; a mismatch throws CONTAINER_INTEGRITY_FAILED. See Container Mode for the API and error codes.
The version byte sits at offset 4 in both formats, and both version constants are exported from the Node and the browser entry point — FORMAT_VERSION (0x01, v1 text/file ciphertext) and CONTAINER_VERSION (0x02, v2 container). So a consumer holding raw bytes can route them without decrypting and without hard-coding a magic number:
import { CONTAINER_VERSION, FORMAT_VERSION, MAGIC_BYTES } from '@hiprax/crypto';
const hasHpcrMagic = (bytes: Uint8Array): boolean =>
bytes.length > MAGIC_BYTES.length &&
MAGIC_BYTES.every((b, i) => bytes[i] === b);
const isHpcr = hasHpcrMagic(bytes);
if (isHpcr && bytes[4] === CONTAINER_VERSION) {
// v2 container: metadata and the integrity check come back with the payload.
const { data, meta } = await cm.decryptContainer(bytes, password);
} else if (isHpcr && bytes[4] === FORMAT_VERSION) {
// v1: the only branch on which `inspectHeader` is safe to call.
const header = cm.inspectHeader(bytes); // { version: 1, kdfId, params, headerLen }
const plaintext = await cm.decryptBytes(bytes, password);
} else {
// Legacy v0, or not this library's output at all. `decryptBytes` applies the
// configured `legacyMode` here; `'strict'`/`'reject'` refuse it outright.
const plaintext = await cm.decryptBytes(bytes, password);
}Two details make this correct rather than merely plausible:
- Check the magic before reading byte 4. A legacy v0 ciphertext opens with a 32-byte random salt, so roughly one in
2 ** 32of them begins with the fourHPCRbytes by chance (the library'slegacyMode: 'auto'recovery exists for exactly that case). Readingbytes[4]on its own would misclassify any v0 blob whose fifth byte happens to be0x02. - Use
MAGIC_BYTES, not the exportedhasMagic(), on aUint8Array. The Node entry'shasMagickeeps its original contract of accepting only a realBufferand returnsfalsefor anything else, whileencryptBytes/encryptContainerreturn a plainUint8Arrayin both runtimes. The browser entry'shasMagicaccepts anyUint8Array. The byte comparison above behaves identically on both entries;hasMagicdoes not. Wrap the value (hasMagic(Buffer.from(bytes))) if you want the Node helper specifically. The sameBuffer-only contract applies to the exportedparseHeader, which throwsINVALID_HEADER_INPUTon a plainUint8Arrayfrom the Node entry — theinspectHeadermethod used above takesUint8Arrayand works on both entries, which is why the example calls that instead.
Note that inspectHeader is deliberately not part of this decision: it parses v1 headers only and throws UNSUPPORTED_VERSION on a container. Classify first, then call it on the v1 branch.
Every ciphertext this library produces carries, in cleartext, the KDF parameters that produced it — that is what lets a ciphertext outlive a change to your defaults. The cost is that those parameters are unauthenticated at the moment they are read: the AES-GCM tag covering the header cannot be verified until a key has been derived, and deriving that key is precisely the expensive step the header controls. An attacker who can hand you a ~100-byte blob therefore chooses how much work you do before you can reject it.
Two layers bound that. The wire-format caps in parseHeader bound what the
format can express (memoryCost <= 2 ** 22, timeCost <= 100,
parallelism <= 64, iterations <= 10_000_000) and are the same for everyone.
They are inclusive, so on their own they still admit 4 GiB and 100 passes. The
per-instance decrypt budget, decryptKdfLimits, is what bounds the work
your process is willing to do:
const cm = new CryptoManager({
decryptKdfLimits: {
maxMemoryCost: 2 ** 18, // KiB — peak RAM per derivation (256 MiB)
maxTimeCost: 4,
maxParallelism: 4,
maxWork: 2 ** 20, // memoryCost × timeCost, the real CPU bound
maxPbkdf2Iterations: 600_000,
},
});Anything over budget is refused with
CryptoError(INVALID_INPUT, 'KDF_COST_EXCEEDS_DECRYPT_LIMITS') — or the
CONTAINER_-prefixed form for a v2 container — before any key is derived,
and the message names which ceiling fired so you know which one to raise. You do
not have to set the option: the defaults below are already applied.
| Field | Node default | Browser default | Bounds |
|---|---|---|---|
maxMemoryCost |
2 ** 19 (512 MiB) |
2 ** 18 (256 MiB) |
peak resident memory |
maxTimeCost |
10 |
10 |
passes, and with maxParallelism, thread churn. Not simultaneously reachable with maxMemoryCost: maxWork binds first, so at 512 MiB only t <= 8 is accepted |
maxParallelism |
16 |
16 |
lanes, i.e. OS threads |
maxWork |
2 ** 22 KiB-passes |
2 ** 20 |
CPU time (memoryCost × timeCost) |
maxPbkdf2Iterations |
2_000_000 |
2_000_000 |
sync-path CPU |
minWork, minPbkdf2Iterations |
0 (off) |
0 (off) |
see "the opposite direction" below |
An omitted ceiling widens to this instance's own cost. A manager configured
with memoryCost: 2 ** 21 carries a maxMemoryCost of 2 ** 21, so a manager
can always decrypt its own output and upgrading cannot make stored data
unreadable. A ceiling you set explicitly is used exactly as given and never
widened. Read the effective budget with getDecryptKdfLimits(); the exported
DEFAULT_DECRYPT_KDF_LIMITS is the default table, not your instance's budget.
Why maxWork exists alongside the per-axis caps. Argon2id's cost is
approximately memoryCost × timeCost, so capping only each axis bounds the
product at their multiple. maxWork bounds it directly, which is what lets the
per-axis ceilings stay generous: a cheap-but-many-passes header
(m = 2 ** 17, t = 10) is accepted while an expensive combination whose axes are
each individually legal (m = 2 ** 19, t = 9) is not. Conversely maxTimeCost
and maxParallelism are not redundant with maxWork: libargon2 is compiled
with threading and sets threads = parallelism, so a header of
m = 512, t = 100, p = 64 is only 51,200 KiB-passes of hashing — invisible to a
product budget — yet asks for roughly 25,600 OS thread create/joins.
-
The synchronous paths are still unsuitable for untrusted input.
decryptTextSyncanddecryptFileSynccallcrypto.pbkdf2Sync, which runs on the calling thread. At the default ceiling of 2,000,000 iterations a single request blocks the Node event loop for 1.00 s measured (299 ms at this library's own 600,000 default; 5,094 ms at the 10,000,000 wire cap), so on the order of one request per second saturates a process. There is no in-library non-blocking path for PBKDF2-format ciphertext. The async methods are Argon2id-only: internallyassertKdfMatchesrefuses a PBKDF2 header, though in the defaultlegacyMode: 'auto'you observe the genericDECRYPTION_FAILEDrather thanKDF_MISMATCH, because the blob is retried as v0 first (see thelegacyModetable under Ciphertext Format (v1)). Either way "use the async paths" is not actionable advice if what you hold is sync-format data. The supported workaround is aworker_thread, measured below; the long-term answer is to re-encrypt with the async API. -
The async paths block the event loop on the WASM provider. Only the native addon and Node's built-in
crypto.argon2derive off the loop.hash-wasmcomputes on the calling thread: 0 of ~138 expected timer ticks during a 694 ms derivation at the 128 MiB default, and 6,309 ms at this budget's own Node ceiling. Both Argon2id packages are optional and a failed native build does not fail an install, so this can be true of your deployment without anything saying so. Assert it at boot withgetArgon2Provider(). -
On the other two providers, the async paths share a small pool. The native addon and the built-in both run on the libuv threadpool, which defaults to four slots, so four concurrent derivations starve every other async
fs,dns,zlibandcryptooperation in the process. Measured: an unrelatedfs.readFilecompleted in 3.5 ms at concurrency 2 and 527 ms at concurrency 4. Bounding per-request cost is necessary but not sufficient; rate limiting and concurrency control remain yours. RaisingUV_THREADPOOL_SIZEis a trade, not a fix. It does restore responsiveness (7.9 ms at concurrency 4 with a pool of 8), but it raises peak memory and per-request latency in the same breath: at concurrency 8 the derivations took 873 ms wall at pool 4 against 2,038 ms at pool 16, and the process worst case at this budget'smaxMemoryCostgoes from 2 GiB at pool 4 to 8 GiB at pool 16. Under attack a wider pool amplifies memory rather than protecting you. Bound concurrency in the application instead:// Cap in-flight decrypts so an attacker cannot occupy the whole pool. let inFlight = 0; const waiting: Array<() => void> = []; async function boundedDecrypt(blob: Uint8Array, limit = 2) { if (inFlight >= limit) await new Promise<void>(r => waiting.push(r)); inFlight += 1; try { return await cm.decryptBytes(blob); } finally { inFlight -= 1; waiting.shift()?.(); } }
-
In the browser, Argon2id blocks the UI thread.
hash-wasmcomputes synchronously and enforces no memory ceiling of its own, which is why the browser default budget is tighter. Consider tightening it further and running decryption in a Web Worker. This is the receiver-side answer to the 128 MiB-decrypt caveat above: rather than trusting whoever produced the ciphertext, cap what you will honour. -
Do not echo
CryptoError.messageto untrusted callers. It names the configured budget and the offending value, which is for your logs. -
The budget is probeable, but only if you configured it above the defaults. Because an omitted ceiling widens to this instance's own cost, an attacker who can submit ciphertexts and observe accept-versus-reject can binary-search each boundary and learn your configured
memoryCost/timeCost/parallelism/pbkdf2Iterations, and the derivedmaxWork, wherever they exceed the defaults. A manager left at the defaults leaks nothing at all: every ceiling resolves to the shipped default table, so every such instance answers identically. Low value even then, since any ciphertext you produce already publishes its Argon2 parameters in cleartext in the header, though it is new information for a service that only ever decrypts. Note that the channel is the accept/reject decision, not the error text: a refusal is also distinguishable by its timing (microseconds, because it precedes the derivation) and by its error code, so redacting the message would close none of the three.
inspectHeader is deliberately not subject to this policy, so it can
classify any ciphertext. That makes it the right tool for a gateway that wants to
triage before committing to a decrypt, and the assertion helper is exported from
both entry points.
A successful inspection does not mean the ciphertext is safe to decrypt. It
applies the wire-format caps only, and those are inclusive: on their own they
still admit memoryCost = 2 ** 22 (4 GiB). Apply the budget yourself.
Classify the format before calling it. inspectHeader is a v1 inspector, not
a format sniffer: a v2 container shares the HPCR magic and makes it throw
UNSUPPORTED_VERSION, so a gateway that handles both formats must branch on the
version byte first.
import {
MAGIC_BYTES,
MAGIC_LENGTH,
FORMAT_VERSION,
CONTAINER_VERSION,
assertKdfWithinDecryptLimits,
} from '@hiprax/crypto';
const isHpcr =
blob.length > MAGIC_LENGTH && MAGIC_BYTES.every((b, i) => blob[i] === b);
if (isHpcr && blob[MAGIC_LENGTH] === FORMAT_VERSION) {
const header = cm.inspectHeader(blob); // never null on this branch
// Throws before you spend anything.
assertKdfWithinDecryptLimits(header!.params, cm.getDecryptKdfLimits());
} else if (isHpcr && blob[MAGIC_LENGTH] === CONTAINER_VERSION) {
// v2 container: decryptContainer applies the budget itself.
} else {
// Legacy v0, or not ours at all.
}If you do not need to triage, just call the decrypt method: it applies the budget itself, before deriving anything.
deriveKey, deriveKeySync, encryptData and decryptData take your
parameters rather than a ciphertext's, so they are deliberately unpoliced, the
same caller-obligation boundary that governs (key, iv) reuse. The consequence
is worth stating plainly, because the dangerous shape looks reasonable:
// UNSAFE on untrusted input. This is the pre-1.8.0 vulnerability, rebuilt by hand:
const header = cm.inspectHeader(blob);
const key = await cm.deriveKey(password, salt, header!.params);Nothing between those two lines bounds the work the header asked for. If you must derive by hand, apply the budget first:
const header = cm.inspectHeader(blob);
if (header) {
assertKdfWithinDecryptLimits(header.params, cm.getDecryptKdfLimits());
const key = await cm.deriveKey(password, salt, header.params);
}There is no in-library way to do this: the async paths are Argon2id-only. Run the synchronous call in a worker instead, which keeps the main loop free (measured: 106 of ~108 expected timer ticks, against 0 of ~99 on the main thread).
// worker.mjs
import { parentPort, workerData } from 'node:worker_threads';
import { CryptoManager } from '@hiprax/crypto';
const cm = new CryptoManager();
parentPort.postMessage(cm.decryptTextSync(workerData.blob, workerData.password));// caller
import { Worker } from 'node:worker_threads';
const plaintext = await new Promise((resolve, reject) => {
const w = new Worker(new URL('./worker.mjs', import.meta.url), {
workerData: { blob, password },
});
w.on('message', resolve);
w.on('error', reject);
});The same pattern is the fallback for Argon2id when you cannot avoid the WASM provider.
An attacker can also make a ciphertext too cheap. If a service decrypts
attacker-supplied blobs using a held defaultPassphrase and the outcome is
observable, a header asking for minimal work removes the KDF cost that was
supposed to rate-limit guessing, turning the service into a cheap oracle for
candidates computed offline. minWork and minPbkdf2Iterations set a floor.
Since v1.9.0 a floor binds headerless input too. A v0 ciphertext carries no
header, so there are no attacker-supplied parameters to police: the derivation
runs at your own fallback cost instead, which for the synchronous paths is
legacyPbkdf2Iterations (default 100,000). Until v1.9.0 the floor was simply
skipped there, which made it bypassable by stripping the header and being
answered six times cheaper than a typical 600,000 floor.
The check now asks whether the derivation about to happen meets the floor. If
it does not, the input is refused with
CryptoError(INVALID_INPUT, 'FALLBACK_KDF_COST_BELOW_DECRYPT_MINIMUM') before any
key is derived. If it does, the input is accepted exactly as before, so raising
legacyPbkdf2Iterations (or your own memoryCost/timeCost) to meet the floor
remains a working remedy, as does legacyMode: 'strict'/'reject'. Together
with the ceilings this gives the complete invariant the option is supposed to
mean: every derivation this manager performs costs at least the floor.
Note the check is reached for anything the default legacyMode: 'auto' routes to
the headerless path, which includes a ciphertext handed to the wrong sync/async
method. That is deliberate rather than incidental: those inputs are about to be
derived at exactly the same fallback cost, so it is the same question.
They default to off, for two reasons. A floor would refuse legitimate
low-cost ciphertext — this library's own browser default is 32 MiB, and the
MEDIUM tier is only 32,768 work. More importantly a floor is in direct tension
with the ceilings beside it: enforcing a minimum forces a full Argon2id
derivation on your server for every request an attacker sends. Application-level
rate limiting remains the primary defence there. One knob is enough for both
axes: since timeCost <= maxTimeCost, requiring m × t >= minWork implies
m >= minWork / maxTimeCost, so at the default maxTimeCost of 10 a minWork
of 393,216 already forces memoryCost >= ~38 MiB.
- AES-256-GCM: Authenticated encryption with Galois/Counter Mode
- Argon2id: Memory-hard key derivation function (winner of Password Hashing Competition)
- Secure Random: every salt, IV and temp-file suffix comes from the platform CSPRNG —
crypto.randomBytes()in Node,crypto.getRandomValues()in the browser. There is no userland PRNG anywhere in the library - Constant-time Operations:
crypto.timingSafeEqualforsecureStringCompare, and a constant-time byte compare for the container's embedded SHA-256 check
WARNING. The low-level
encryptData(data, key, iv)/decryptData(data, key, iv, tag)methods place the IV-uniqueness obligation on the caller. AES-GCM is catastrophically broken if a(key, iv)pair is ever used to encrypt two different plaintexts — an observer can XOR the two ciphertexts to recover the XOR of the plaintexts (the keystream cancels), and additionally the GCM authentication subkey leaks, which lets the attacker forge authenticated ciphertexts under that key. The integrity guarantee is gone.When you call
encryptDatadirectly:
- Use a fresh random IV for every message. Generate it with
cm.generateSecureRandom(12)and never persist or reuse it under the same key.- Cap each key at roughly
2 ** 32invocations. With random 96-bit IVs, collisions become non-negligible beyond this birthday bound (NIST SP 800-38D); rotate the key — for example, by re-deriving from a fresh per-message salt — well before then.- Treat
encryptDatawith a fixed(key, iv)as deterministic by design. This is not a feature, it is a symptom of the attack surface above. The testencryptData with reused (key, iv) is deterministic — security boundary documentationin the test suite locks this property in as a guardrail.The high-level methods (
encryptText/encryptFileand theirSyncsiblings) do not expose this footgun: they generate a fresh random IV per message AND derive a fresh key from a fresh per-message salt, so a(key, iv)collision across two messages is computationally negligible. Prefer the high-level API for any work that does not have a specific reason to manage IVs by hand.
- Secure Clearing: derived keys, plaintext copies and decoded ciphertext buffers are zero-filled after use, on both the success and the failure path
- Bounded retention: the file paths scrub their 64 KiB reuse buffer and their key/salt/IV/tag material; the container path scrubs the KEK, the DEK and the cleartext metadata block
- Best-effort, not forensic: this reaches byte arrays only. Immutable V8 strings (passwords, decrypted text, a configured
defaultPassphrase) and Web Crypto's opaqueCryptoKeycannot be scrubbed — see Threat Model for the exact boundary
- Path Sanitization: Prevents path traversal attacks
- Type Safety: Full TypeScript support prevents type-related vulnerabilities
- Parameter Validation: Comprehensive input validation with detailed error messages
Every byte this library encrypts is protected by cryptography that the world's leading standards bodies treat as quantum-resistant. Not by accident — by design: the library is symmetric-only, and symmetric cryptography at 256-bit strength survives the quantum era.
Quantum computers threaten cryptography through two very different algorithms:
- Shor's algorithm completely breaks public-key cryptography — RSA, Diffie-Hellman, ECDH, ECDSA, all elliptic curves. This is the catastrophic, exponential break driving the global post-quantum migration. This library contains zero public-key cryptography — no key exchange, no signatures, no certificates — so there is nothing for Shor's algorithm to attack.
- Grover's algorithm yields only a quadratic speedup against symmetric cryptography: a 256-bit key drops to roughly 128-bit effective strength — still far beyond any conceivable attack, and in practice even that figure is pessimistic for the attacker because Grover parallelizes poorly and is gate-depth limited. Every key, salt, and KDF output in this library is 256-bit.
The NIST post-quantum standards (FIPS 203 ML-KEM, FIPS 204 ML-DSA, FIPS 205 SLH-DSA) replace public-key primitives only; NIST states explicitly that its symmetric standards are not part of the PQC transition. A symmetric-only library therefore needs no post-quantum migration — no algorithm swap, no format change, and nothing for consumers to update, ever, for quantum reasons.
This posture is identical in the browser build. The isomorphic browser build uses the same symmetric primitives (AES-256-GCM, Argon2id, SHA-256) over the same wire format — Web Crypto and WebAssembly Argon2id are different implementations of the same algorithms, not different algorithms. The only cross-runtime difference is the Argon2id default memory cost (32 MiB in the browser vs 128 MiB in Node), which changes brute-force cost, not the post-quantum standing of any primitive.
| Primitive | Role | Post-quantum status |
|---|---|---|
| AES-256-GCM | Authenticated encryption | NIST post-quantum security Category 5 (the highest tier). NSA CNSA 2.0 retains AES-256 for National Security Systems up to TOP SECRET in the quantum era. BSI recommends 256-bit symmetric keys for long-term protection. |
| Argon2id (async KDF) | Password → key | Memory-hardness survives quantum evaluation: peer-reviewed analysis shows the memory cost carries over into the reversible circuits Grover requires, making every quantum guess astronomically expensive. |
| PBKDF2-HMAC-SHA256 (sync KDF) | Password → key | HMAC and KDF constructions are listed by NIST IR 8547 among the symmetric standards that will not be transitioned. 256-bit output ≈ 128-bit effective post-quantum strength. |
| SHA-256 (utility) | Hashing | 256-bit preimage strength = NIST post-quantum Category 5. |
CSPRNG (crypto.randomBytes) |
Salts, IVs | OS-level SP 800-90A DRBGs reseeded from kernel entropy; quantum computing changes nothing structural about their security. |
These are not marketing claims — each one traces to an official standards-body position or peer-reviewed result:
- NIST, Post-Quantum Cryptography FAQ — Grover's algorithm "will provide little or no advantage in attacking AES", key lengths do not need to be doubled, and even AES-128 "will remain secure for decades to come". https://csrc.nist.gov/projects/post-quantum-cryptography/faqs
- NIST IR 8547, Transition to Post-Quantum Cryptography Standards — symmetric standards (block ciphers, hash functions, HMAC, KDFs) are excluded from the PQC transition; AES-256 anchors security Category 5; the 2030/2035 deprecation timelines apply to RSA/ECC only. https://nvlpubs.nist.gov/nistpubs/ir/2024/NIST.IR.8547.ipd.pdf
- NSA, Commercial National Security Algorithm Suite 2.0 — retains AES-256 as the symmetric cipher for National Security Systems up to TOP SECRET; only public-key algorithms are replaced (by ML-KEM / ML-DSA). https://media.defense.gov/2022/Sep/07/2003071836/-1/-1/0/CSI_CNSA_2.0_FAQ_.PDF (that direct PDF link is often blocked to non-browser clients; the stable landing page is https://www.nsa.gov/Press-Room/Digital-Media-Center/Document-Gallery/igphoto/2003071836/)
- BSI TR-02102-1 (Germany's federal cyber-security agency) — the impact of quantum computers on symmetric mechanisms is far milder than on asymmetric ones; 256-bit keys are recommended for high or long-term protection needs. https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TG02102/BSI-TR-02102-1.pdf
- Amy, Di Matteo, Gheorghiu, Mosca, Parent & Schanck (SAC 2016) — a fault-tolerant Grover preimage attack on SHA-256 costs ≈2^153.8 surface-code cycles of depth, vastly above the naive 2^128 query count, because Grover cannot be parallelized efficiently. https://arxiv.org/abs/1603.09383
- Blocki, Holman & Lee (TCC 2022), with the EUROCRYPT 2025 follow-up — memory-hard functions retain their cost inside the reversible circuits a quantum attacker must build; cheap memory-erasing classical strategies do not translate into cheap quantum circuits. https://arxiv.org/abs/2110.04191, https://eprint.iacr.org/2024/334
- Song, Eum, Kwon, Sim, Lee & Seo (2023) — a concrete Grover-oracle quantum circuit for Argon2, quantifying the enormous qubit and depth cost of attacking a memory-hard KDF on quantum hardware. https://eprint.iacr.org/2023/1150
- Kaplan, Leurent, Leverrier & Naya-Plasencia (CRYPTO 2016) — the only known "quantum break" of GCM (a Simon's-algorithm forgery) requires the attacker to query your live keyed encryption device in quantum superposition (the "Q2" model) — a scenario that cannot arise against encrypted data at rest. Even within that model, GCM's confidentiality core (CTR mode) remains provably secure. https://arxiv.org/abs/1602.05973
A quantum computer will not break AES-256 — it will try to guess your password. Grover's algorithm halves the effective entropy of a password search, so the password, not the cipher, is the residual quantum attack surface. Three design properties blunt that attack:
- Every ciphertext uses a fresh 32-byte random salt, so an attacker cannot amortize one search across many ciphertexts — precomputation and batch attacks are dead on arrival.
- On the async path, every single guess must pay the full Argon2id memory-hard evaluation (128 MiB at the Node default profile; 32 MiB at the browser default) — which on quantum hardware must be built as a reversible circuit holding the entire memory array in logical qubits, an astronomical overhead (proofs 6-7 above).
- The sync PBKDF2 path is equally quantum-resistant at the primitive level, but it is not memory-hard — prefer the async (Argon2id) methods for long-lived, high-value data.
Recommendation for "harvest now, decrypt later" threat models: use the async (Argon2id) methods with a high-entropy passphrase — for example 8-10 randomly generated diceware words (≈103-129 bits of entropy). Even against an idealized Grover attacker that leaves ≈52-65 bits of quantum-effective entropy where every guess costs a full memory-hard KDF evaluation — comfortably out of reach.
Because it would be security theater. ML-KEM (Kyber) protects key exchange; ML-DSA (Dilithium) provides digital signatures. This library performs neither: keys are derived locally from your password, and nothing is ever negotiated over a wire or signed. There is no classical key exchange to hybridize and no signature scheme to upgrade. Bolting PQC primitives onto a symmetric-only design would add attack surface and dependencies without adding security.
Every v1 ciphertext embeds its own KDF identifier and full KDF parameters in the versioned header, so stronger defaults or an additional KDF can ship in a minor release while every old ciphertext remains decryptable forever — they are self-describing. The header's version byte reserves a clean, well-defined escape hatch for a cipher change if cryptographic guidance ever demands one — 0x01 is this v1 ciphertext format and 0x02 is the additive v2 container, so 0x03 onward remain unallocated; none is needed or foreseen. One layer is deliberately out of this library's hands: the distribution channel (npm provenance signatures, registry TLS) relies on the ecosystem's classical public-key infrastructure. That layer is npm's and Sigstore's to migrate, is a real-time integrity check rather than harvestable ciphertext, and has no bearing on the confidentiality of anything you encrypt.
The formal, audit-facing version of this posture lives in SECURITY.md.
Run the test suite:
npm testRun tests with coverage:
npm run test:coverageRun tests in watch mode:
npm run test:watchThe Node suite runs under Jest (npm test, aliased as npm run test:node). The real-browser suite runs under Vitest Browser Mode in headless Chromium — it imports the built browser entry and proves cross-runtime interop (a Node-produced ciphertext decrypts in a real browser):
npm run build # the browser suite imports dist/index.browser.js
npm run test:browser # headless Chromium via PlaywrightFour static gates back the browser build and the published package, and all four run in CI on every push: npm run check:browser (an esbuild platform:'browser' bundle that fails on any node: specifier reaching the browser graph), npm run check:types:browser (type-checks a browser-only consumer against dist/ with no Node types available, so a Node type in the browser declaration graph fails the build), npm run check:exports (publint + @arethetypeswrong/cli), and npm run check:tarball (asks npm what it would actually pack, then requires every path the exports map resolves to and forbids anything that must never ship — sources, tests, source maps, lockfiles, env files, credential material and editor/backup scratch files). The last one exists because npm pack --dry-run asserts nothing on its own, and because npm force-includes any root file whose name stem is readme, license, licence or copying regardless of .npmignore, so a stray README.md.tmp would otherwise be published. It deliberately pins no file count, which would go red on every legitimate new dist/ module.
Everything above is chained into a single command, which stops at the first failure:
npm run verifyIt runs lint → type-check → build → test → check:browser → check:types:browser → check:exports → check:tarball, and takes roughly two minutes. prepublishOnly is defined as exactly npm run verify, so the gate that guards a release and the gate a contributor runs are one command and cannot drift apart. npm run test:coverage enforces the coverage floor separately (a one-way ratchet: currently 96% statements, 87% branches, 98% functions, 96% lines), and npm run test:browser covers the real-Chromium tier.
Those commands form three tiers, measured on 2026-09-22 on Node v24.19.0 at the current 28 suites / 1,224 tests. Read FAST and the 1 m 56 s FULL figure as one paired measurement from a single session — that pairing is what makes FULL equal FAST plus the four check:* gates. The Jest step dominates every tier and its run-to-run spread is wide (observed 92 s to 140 s across sessions and machine loads), so treat these as an order of magnitude rather than a budget, and re-measure when a tier's contents change:
| Tier | Command | Measured | When |
|---|---|---|---|
| FAST | npm run lint && npm run type-check && npm run build && npm test |
~1 m 41 s (lint 6.9 s, type-check 1.3 s, build 1.7 s, test 91.0 s) | every change |
| FULL | npm run verify |
1 m 56 s paired with the FAST row above — FAST plus the four check:* gates |
before every push; also prepublishOnly |
| BROWSER | npm run build && npm run test:browser |
~5 s (37 Chromium tests), after a one-time npx playwright install --with-deps chromium |
when the browser graph, the wire format or the container format changes, and before every release |
FAST is a strict prefix of FULL, and within one session the ~9 seconds between them buy all four remaining gates, so there is no reason to run FAST when FULL is affordable. The coverage ratchet is enforced by npm run test:coverage, which CI runs in its equivalent form (npm test -- --coverage) on its Ubuntu / Node 22 leg; note that npm run verify runs Jest without --coverage, so a green verify does not by itself assert the coverage floor.
A tinybench-driven benchmark suite lives in bench/. It measures the paths that are most representative of real workloads — the base64url and hex codecs and inspectHeader on their own, Argon2id and PBKDF2 key derivation, encryptText at 1 KiB and 1 MiB, and encryptFile / decryptFile streaming on a 10 MiB payload — against the compiled dist/ output, so build first:
npm run build
npm run benchThe full suite takes roughly 2.5-5.5 minutes on a modern laptop. Argon2id at the default 128 MiB / t=3 / p=1 profile dominates the wall time (each derivation takes ~100-400 ms depending on the host), and every encrypt path performs one derivation per call. The codec group is the exception: it performs no key derivation in any measured case, which is the point of it — the KDF is large enough to hide a codec regression entirely. It runs first and takes ~45 s. To run a single bench file in isolation:
node bench/codec.mjs # base64url + hex codecs + inspectHeader (no KDF in any MEASURED
# case; one cheap Argon2id run happens in setup to build the
# fixture)
node bench/kdf.mjs # Argon2id + PBKDF2 only
node bench/encrypt-text.mjs # 1 KiB and 1 MiB text encrypt
node bench/encrypt-file.mjs # 10 MiB encrypt + decrypt streamingBenchmarks are intentionally not wired into CI — the absolute numbers vary too much between hosted-runner generations to be useful as a regression check, and 128 MiB Argon2id is too slow for the matrix budget. See bench/README.md for the full output format and per-case methodology notes.
The library uses custom error types for better error handling:
import { CryptoError, CryptoErrorType } from '@hiprax/crypto';
try {
await crypto.encryptText('', '');
} catch (error) {
if (error instanceof CryptoError) {
console.log('Error Type:', error.type);
console.log('Error Code:', error.code);
console.log('Message:', error.message);
}
}INVALID_PASSWORD: Password-related errorsINVALID_INPUT: Invalid input parametersENCRYPTION_FAILED: Encryption operation failuresDECRYPTION_FAILED: Decryption operation failuresFILE_ERROR: File system errorsMEMORY_ERROR: Memory-related errorsVALIDATION_ERROR: Validation failures
Branch on code, not on type. CryptoError.type is one of the seven coarse categories above; CryptoError.code is the specific, stable discriminator — the library raises 84 distinct codes. New codes are only ever added to code (a plain string), never to the CryptoErrorType enum, so a release can describe a new failure without a breaking type change.
Two consequences worth knowing when writing handlers:
- A code is not pinned to one type.
INVALID_HEADER_PARAMisINVALID_INPUTwhenpackHeaderrejects an out-of-width value andDECRYPTION_FAILEDwhenparseHeaderrejects a non-positive parameter;UNSUPPORTED_KDFsplits the same way. Matching oncodealone is correct in both. - A key-derivation failure is re-typed on decrypt paths.
KEY_DERIVATION_FAILED/SYNC_KEY_DERIVATION_FAILEDcarryENCRYPTION_FAILEDwhen raised by a directderiveKey/deriveKeySynccall (a derivation is neither direction), and are re-typed toDECRYPTION_FAILEDwhen they surface inside a decrypt. Thecodeis preserved either way.
Notable codes beyond the format/parser codes above: UNSUPPORTED_IN_BROWSER (type INVALID_INPUT) — a Node-only method was called on the browser build; ARGON2_NOT_AVAILABLE (type MEMORY_ERROR) — no Argon2id provider is available (in Node, run on 24.7.0+ for the built-in, or install native build tools or hash-wasm — see Argon2 providers; in the browser, install hash-wasm); DATA_TOO_LARGE_FOR_GCM (type INVALID_INPUT) — see below; and the container-mode codes such as CONTAINER_INTEGRITY_FAILED.
NIST SP 800-38D section 5.2.1.1 caps a single AES-GCM invocation at 2 ** 39 - 256 bits of plaintext — 2 ** 36 - 32 bytes, 68,719,476,704 bytes, about 64 GiB. Beyond that the 32-bit block counter wraps, the keystream repeats, and the result has neither confidentiality nor authenticity. Neither OpenSSL's EVP layer nor Node's crypto.createCipheriv enforces it, so a library that does not check simply returns broken ciphertext.
This library checks. encryptBytes, decryptBytes, encryptData, decryptData, encryptFile, encryptFileSync, decryptFile and decryptFileSync throw CryptoError with type INVALID_INPUT and code DATA_TOO_LARGE_FOR_GCM for anything past the bound. The check runs before any key derivation, before any temp file is created and before any cipher is constructed, so a refused call leaves no output file, no .tmp file, and no wasted Argon2id run. There is deliberately no opt-out: an opt-out would be an opt-in to a broken construction. Inputs at exactly the limit are accepted.
The bound is asserted on the ciphertext body too, which has one consequence when upgrading: a ciphertext larger than the bound produced by 1.5.0 or earlier is now refused on decryption rather than decrypted. Counter wrap is deterministic and the tag mask is fixed, so an earlier version could read back its own oversized output even though that output had neither confidentiality nor authenticity. If you hold such an artifact — a single file over 68,719,476,704 bytes — pin @hiprax/crypto@1.5.0 to recover the plaintext, then re-encrypt it in chunks.
The bound and its assertion helper are exported from both entry points, so you can range-check ahead of a call:
import {
MAX_GCM_PLAINTEXT_BYTES,
assertGcmPlaintextLimit,
} from '@hiprax/crypto';
console.log(MAX_GCM_PLAINTEXT_BYTES); // 68719476704
// Throws CryptoError(INVALID_INPUT, 'DATA_TOO_LARGE_FOR_GCM') past the bound.
assertGcmPlaintextLimit(fileSizeInBytes);In practice this is a bound on a single call, not on how much data the library can protect: split larger payloads across separate calls, each of which gets its own fresh salt, key and IV. Container mode is already stricter — a v2 container's payload is capped at 0xffffffff bytes (just under 4 GiB, the width of the size field in its metadata block) and rejects anything larger with CONTAINER_DATA_TOO_LARGE, well below the GCM limit — and the text API is bounded far earlier still by V8's maximum string length. Read it off your own runtime rather than trusting a figure here: require('buffer').constants.MAX_STRING_LENGTH is 536870888 (512 MiB of characters) on Node 24.19.0, and base64url expands 4:3, so encryptText tops out near 384 MiB of plaintext. encryptBytes has no such ceiling — it never materialises a string.
npm run verifyThe single gate. It chains lint → type-check → build → test → check:browser → check:types:browser → check:exports → check:tarball and stops at the first failure (roughly two minutes). prepublishOnly is defined as exactly this command, so a change cannot pass a working session and then fail a release. Run it before opening a pull request; the individual steps below are for iterating.
npm run buildnpm run lint
npm run lint:fixnpm run formatnpm run type-check- Fork the repository
- Create a feature branch
- Make your changes
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
A cryptography library is only as useful as its honesty about what it does and does not protect against. The list below is descriptive, not aspirational — it documents the actual security boundary of this codebase. The companion SECURITY.md covers the disclosure policy and the formal in-scope / out-of-scope split for vulnerability reports; the section below is the engineering-level rationale.
- Confidentiality of plaintext under a strong password, against a passive observer who captures the ciphertext at rest or in transit. Confidentiality is bounded by AES-256-GCM and the chosen KDF (Argon2id or PBKDF2-HMAC-SHA256 — see Security Levels).
- Ciphertext indistinguishability under chosen-plaintext attack (IND-CPA). Each encryption draws a fresh 32-byte salt and a fresh 96-bit IV from the OS CSPRNG, so two encryptions of the same plaintext under the same password are distinct ciphertexts and cannot be correlated by an observer who has seen previous outputs. AES-256-GCM's underlying CTR-mode keystream provides the standard IND-CPA guarantee.
- Authenticity and integrity of ciphertext (including the v1 header). AES-256-GCM produces a 128-bit authentication tag covering the salt, IV, ciphertext body, and AAD. As of v1.1.0 the AAD bound to v1 ciphertexts includes the configured AAD context string (
"secure-crypto-tool-v2"by default) concatenated with the verbatim 22 bytes of the v1 header — so any single-bit modification to the ciphertext, salt, IV, header (including the reserved-byte regions inside the KDF parameter block), or AAD context causesdecrypt*to fail withDECRYPTION_FAILEDrather than return wrong plaintext. v0 (legacy) ciphertexts use just the AAD context string for AAD, since they have no header to bind. v1 ciphertexts produced by v1.0.0 (which pre-dates the header binding) can be decrypted by setting the constructor optionlegacyHeaderAad: true— see Migration: v1.0.0 → v1.1.0 below. - DoS protection against attacker-controlled KDF cost, in two layers. The header of a ciphertext carries the KDF parameters that produced it, and those bytes are unauthenticated at the moment they are read — the GCM tag covering them cannot be checked until a key has been derived, which is the expensive step they control. Layer one, the wire-format caps:
parseHeaderrefusesmemoryCost > 2^22(4 GiB),timeCost > 100,parallelism > 64, or PBKDF2iterations > 10_000_000withCryptoError(INVALID_INPUT, 'KDF_PARAMS_OUT_OF_BOUNDS'). These bound what the format can express, and they apply toinspectHeadertoo, so tooling sees the same bounded values as decrypt. Layer two, the per-instance decrypt budget (decryptKdfLimits, added in 1.8.0): what this process is willing to spend before it knows the ciphertext is genuine, defaulting to 512 MiB /2^22KiB-passes on Node and tighter in the browser. Both layers reject before any key derivation, so a malicious ~100-byte ciphertext cannot pin gigabytes of RAM or burn minutes of CPU. See Decrypting untrusted ciphertext for what the second layer does and does not buy — in particular, the synchronous PBKDF2 paths remain unsuitable for untrusted input. - Format integrity. The v1 ciphertext header (
HPCRmagic + version + KDF id + KDF params) is parsed with explicit length checks and bounded numeric ranges; malformed input surfaces asCryptoErrorwith a specific code (TRUNCATED_HEADER,INVALID_HEADER_PARAM,KDF_PARAMS_OUT_OF_BOUNDS,UNSUPPORTED_VERSION, etc.) rather than a crash, infinite loop, or out-of-bounds read. See Ciphertext Format (v1). - Path traversal in the file APIs (syntactic).
validatePathrejects null bytes, ASCII control characters (< 0x20or0x7F), Windows-illegal characters, and literal..segments afterpath.normalize. The optionalallowedRootoption performs a segment-aware resolved-prefix containment check so within-drive cross-traversal (e.g.C:\\Users\\..\\WindowsagainstallowedRoot: 'C:\\Users') is caught even thoughpath.normalizecollapses the..to a clean string.sanitizeFilenameneutralises literal..sequences and preserves the file extension when truncating to 255 chars. - Constant-time comparison primitives.
secureStringCompareusescrypto.timingSafeEqualso equal-length string compares do not leak bytewise differences via timing. (Length itself is leaked — see Out of Scope.) - Memory hygiene for buffer-resident secrets.
secureClearzeroes Buffer-backed allocations holding key material and plaintext after use, on a best-effort basis (see Out of Scope for the V8 caveat).
- Rubber-hose cryptanalysis. If the password is coerced out of the user, the library cannot help. Strong-password validation does not survive an attacker who can compel the user to reveal it.
- Weak passwords. Encryption is only as strong as the password. The library validates strength at encryption time (8-char composition rule OR ≥20-char passphrase rule — see Password Requirements), but the caller is responsible for sourcing high-entropy inputs and protecting against credential reuse. A weak password makes Argon2id/PBKDF2 brute-force tractable regardless of the parameters.
- Side-channel attacks beyond constant-time comparison. The library uses
crypto.timingSafeEqualfor tag/string comparisons, but it does NOT defend against cache-timing, power-analysis, electromagnetic-emanation, or microarchitectural side channels in the underlying AES-256-GCM, Argon2id, or PBKDF2 implementations (which run in OpenSSL via Node.js'scryptomodule and, for Argon2id, in whichever of the three providers resolves — theargon2native addon, Node's owncrypto.argon2, or thehash-wasmWebAssembly build). Hardened deployments must rely on the host's mitigations (microcode, hypervisor isolation, etc.). - Low password entropy against a future quantum adversary. The primitives themselves are quantum-resistant — the library contains no public-key cryptography for Shor's algorithm to break, and AES-256, Argon2id, and PBKDF2-HMAC-SHA256 retain ≥128-bit effective strength under Grover's algorithm per NIST, NSA CNSA 2.0, and BSI guidance (see Post-Quantum Security for the full posture and sources). What no cipher can fix is a weak password: Grover halves the effective entropy of the password search space, so a "harvest now, decrypt later" adversary with future quantum capability attacks the password, not AES-256. If that adversary is in your threat model, use the async (Argon2id) path with a high-entropy passphrase (e.g. 8-10 random diceware words, ≈103-129 bits). The deliberate absence of a Kyber/Dilithium hybrid mode is not a gap: those standards replace key exchange and signatures, and this library performs neither.
- OS CSPRNG compromise. All randomness (salt, IV, container DEK, temp-file suffix) is sourced from
crypto.randomBytes/crypto.randomUUIDin Node andcrypto.getRandomValuesin the browser, all of which delegate to the host's CSPRNG (getrandom(2)on Linux,BCryptGenRandomon Windows,SecRandomCopyByteson macOS). If the OS RNG is backdoored, virtualised onto a deterministic shim, or seeded with insufficient entropy at boot, the library inherits that compromise — IVs may collide, salts may be predictable, and the IND-CPA guarantee degrades. Detecting OS-level RNG compromise is outside the library's scope. - String-copy memory leaks via V8.
secureClearzeroes the underlyingArrayBufferslab of a Buffer, but V8 may have already created internal string copies of password or plaintext data for hashing, interning, or deoptimisation paths. Those copies are unreachable toBuffer.fill(0)and live until garbage collection. TreatsecureClearas defence-in-depth, not as a forensic-grade wipe. The same caveat applies — more directly, by deliberate retention rather than incidental V8 behaviour — toCryptoManagerinstances configured withdefaultPassphrase: the library stores the passphrase as a regular V8 string for the manager's lifetime and cannot scrub it. For sensitive workloads, pass the password explicitly to eachencrypt*/decrypt*call instead of configuringdefaultPassphrase. See Password Requirements for the full retention discussion. - Symlink-based path traversal.
validatePathis a syntactic check; it does not callfs.realpathand does not prevent a path like/safe/dir/symlinkToEtcfrom escaping via the symlink. Callers that need symlink-safe path validation must perform their ownrealpath-based check or operate inside a chroot/sandbox. - Length leaks in comparison.
secureStringCompareonly protects against bytewise timing differences within an equal-length compare. The lengths of the inputs are leaked via the early-return-on-length-mismatch path. For comparing values where length itself is sensitive, hash both sides first. - Denial of service via legitimate-input resource exhaustion. A caller that asks the library to encrypt a 100 GiB file will see 100 GiB of disk and memory pressure on the streaming write — that is a workload property, not a vulnerability. Argon2id at the default
memoryCost: 2^17allocates 128 MiB per derivation; an attacker who can trigger many concurrent derivations against a single host can DoS it. Rate-limiting and resource sandboxing are caller responsibilities. Note: DoS via maliciously-crafted ciphertext headers — a ~100-byte ciphertext asking the parser to honour an expensivememoryCost/timeCost/iterations— is in scope, and is bounded in two layers:KDF_PARAMS_OUT_OF_BOUNDSfor anything past the wire-format caps, andKDF_COST_EXCEEDS_DECRYPT_LIMITSfor anything past this instance'sdecryptKdfLimitsbudget. Both fire before any KDF work runs. Note that the wire-format caps are inclusive:memoryCost = 4 GiBis exactly the largest value they accept, which is why the second layer exists. See "What this library defends against" above.
For the formal vulnerability disclosure policy, scope rules, supported-versions table, and contact channels, see SECURITY.md.
Release notes for every version live in CHANGELOG.md. The CHANGELOG is bundled into the published npm tarball, so it is also available offline at node_modules/@hiprax/crypto/CHANGELOG.md after install.
MIT License - see LICENSE file for details.
This library is designed for security but should be used as part of a comprehensive security strategy. Always:
- Use strong, unique passwords
- Keep your dependencies updated
- Follow security best practices
- Consider additional security measures for critical applications
For the full security policy — including the Threat Model section above for what is and isn't in scope — see SECURITY.md.
If you believe you have found a security issue in @hiprax/crypto, please do not file a public issue. Read SECURITY.md for the full disclosure policy and report the issue privately via GitHub Security Advisories or by emailing security@hiprax.com. Initial acknowledgements are sent within 72 hours.
For support, please:
- Check the documentation
- Search existing issues
- Create a new issue if needed
Made with ❤️ for secure applications