npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hiprax/crypto

v1.5.0

Published

High-security encryption/decryption library using AES-256-GCM and Argon2id

Downloads

925

Readme

Crypto

🔐 High-security encryption/decryption library using AES-256-GCM and Argon2id for Node.js applications with full TypeScript support.

CI codecov CodeQL npm provenance Post-Quantum Ready License: MIT npm version TypeScript Node.js

✨ Features

  • 🔐 AES-256-GCM authenticated encryption
  • 🔑 Argon2id memory-hard key derivation
  • 🌐 Isomorphic (Node + browser) — the same encryptBytes / decryptBytes / encryptText / decryptText run 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 with 80%+ coverage
  • 🚀 Modern ES modules with tree-shaking support
  • 🔒 Security-focused with constant-time comparisons
  • 🔑 Default passphrase support for simplified usage

📦 Installation

npm install @hiprax/crypto

Module system

This 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.

Argon2 native dependency (optional, with WASM fallback)

The async key-derivation paths (encryptText, decryptText, encryptFile, decryptFile, deriveKey) use Argon2id — the gold standard for password hashing. Two providers are supported and tried in order:

  1. Native argon2 — fastest, but requires a working C++ toolchain (Python + node-gyp) at install time on platforms without a prebuilt binary.
  2. WASM hash-wasm — pure WebAssembly, zero native deps, works everywhere Node.js runs. Roughly 2-3× slower than native at the default 128 MiB profile, but the same RFC 9106 Argon2id reference, so the derived keys are bit-identical between providers and v1 ciphertexts produced by either round-trip across both.

Both packages are declared as optional dependencies. The library tries native first (highest performance) and transparently falls back to WASM if native is unavailable. If BOTH 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 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>. WASM error: <msg>.

(the trailing Native error: … WASM error: … carries the concrete failure reason from each provider.)

To recover from a "both unavailable" error, do any of:

  1. Install build tools and reinstall so argon2's node-gyp step succeeds (best performance), or
  2. Install hash-wasm explicitly: npm install hash-wasm (no build tools needed; transparent fallback once installed), or
  3. Use the synchronous methods (encryptTextSync, decryptTextSync, encryptFileSync, decryptFileSync, deriveKeySync) which use PBKDF2-HMAC-SHA256 and have no native-module dependency. Note that PBKDF2 is materially weaker than Argon2id against GPU/ASIC adversaries — prefer (1) or (2) 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 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.

Browser build

@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. Two complementary static gates enforce this continuously (in CI and at publish): an esbuild platform:'browser' bundle gate (npm run check:browser) fails on any node: specifier reaching the browser graph, and an ESLint no-restricted-globals (Buffer/process) + no-restricted-imports (node:*) override on the isomorphic source files catches a bare Buffer/process global that esbuild cannot see.

# 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.

CommonJS interop

@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).

🚀 Quick Start

Basic Usage

Asynchronous Operations (Recommended)

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);

Synchronous Operations

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);

Using Default Passphrase

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 defaultPassphrase keeps the password resident as a regular V8 string for the full lifetime of the CryptoManager instance — 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; secureClear only zero-fills Buffer-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 of defaultPassphrase is 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.

File Encryption

Asynchronous File Operations (Recommended)

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!');

Synchronous File Operations

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!');

File Encryption with Default Passphrase

Asynchronous Operations

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!');

Synchronous Operations

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!');

Custom Configuration

import { CryptoManager } from '@hiprax/crypto';

const crypto = new CryptoManager({
  memoryCost: 2 ** 19, // 512MB (post-Task-18 ULTRA tier)
  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.

📚 API Reference

CryptoManager

The main class for encryption/decryption operations.

Constructor

const crypto = new CryptoManager(options?: CryptoManagerOptions);

Options:

  • memoryCost (number): Argon2 memory cost (default: 1310722 ** 17, 128 MiB; OWASP 2026 first-choice tier for Argon2id, see Security Levels). Resource-constrained callers (mobile, embedded, low-memory containers) can opt back into the previous 64 MiB profile by passing memoryCost: 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 methods
  • legacyMode ('auto' | 'strict' | 'reject'): How to handle legacy (pre-v1) ciphertexts during decryption — 'auto' (default) accepts them, 'strict' rejects with LEGACY_FORMAT_REJECTED, 'reject' rejects with UNSUPPORTED_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): When true, the constructor skips strength validation of defaultPassphrase only (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.
  • legacyHeaderAad (boolean): Backward-compat shim for v1 ciphertexts produced by v1.0.0 (default: false). When true, v1 ciphertext AAD reverts to the v1.0.0 format (just aad, header bytes not bound) so v1.0.0-produced ciphertexts still decrypt; the default false binds the header bytes into the AAD. Affects v1 ciphertexts only (v0 always uses aad alone). Leave false for new code; use only as a temporary migration aid. See Migration: v1.0.0 → v1.1.0.

Methods

encryptText(text: string, password?: string): Promise<string>

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');
decryptText(encryptedText: string, password?: string): Promise<string>

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);
encryptBytes(data: Uint8Array, password?: string): Promise<Uint8Array>

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)
decryptBytes(data: Uint8Array, password?: string): Promise<Uint8Array>

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'
encryptTextSync(text: string, password?: string): string

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');
decryptTextSync(encryptedText: string, password?: string): string

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 via fs.readSync/fs.writeSync, 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).
decryptContainer(container: Uint8Array, password?: string): Promise<DecryptedContainer>

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); // { filename: 'report.txt', mime: 'text/plain', size: 15 }
console.log(new TextDecoder().decode(data)); // 'report contents'
validatePassword(password: string): boolean

Validates password strength.

const isValid = crypto.validatePassword('MySecureP@ssw0rd123!');
// Returns: boolean
generateSecureRandom(length: number): Buffer

Generates cryptographically secure random bytes.

const random = crypto.generateSecureRandom(32);
// Returns: Buffer
deriveKey(password: string, salt: Buffer): Promise<Buffer>

Derives an encryption key from a password using Argon2id.

const salt = crypto.generateSecureRandom(32);
const key = await crypto.deriveKey('MySecureP@ssw0rd123!', salt);
// Returns: 32-byte Buffer
deriveKeySync(password: string, salt: Buffer, iterations?: number): Buffer

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).

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);
encryptData(data: Buffer, key: Buffer, iv: Buffer): EncryptionResult

Low-level AES-256-GCM encryption. Returns { encrypted: Buffer, tag: Buffer }.

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. Prefer encryptText / encryptFile for any code that does not have a specific reason to manage IVs by hand.

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): 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 (and the matching aad, configured on the CryptoManager instance). 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.

const decrypted = crypto.decryptData(encrypted, key, iv, tag);
secureClear(buffer: Uint8Array): void

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);
getParameters(): EncryptionParameters

Gets current encryption parameters.

const params = crypto.getParameters();
// Returns: object with algorithm details
getSecurityLevel(): SecurityLevel

Gets security level based on configuration.

const level = crypto.getSecurityLevel();
// Returns: 'low' | 'medium' | 'high' | 'ultra'
hasDefaultPassphrase(): boolean

Checks if a default passphrase is configured.

const hasDefault = crypto.hasDefaultPassphrase();
// Returns: boolean indicating if default passphrase is set
getLegacyMode(): LegacyMode

Returns 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'
inspectHeader(input: string | Uint8Array): ParsedHeader | null

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. A buffer that begins with the v1 magic but is otherwise malformed throws a specific parser CryptoError (e.g. TRUNCATED_HEADER, UNSUPPORTED_VERSION). See the worked example under Ciphertext Format (v1).

Types and Enums

The library exports all types, interfaces, and enums for TypeScript consumers:

import {
  // Error handling
  CryptoError,
  CryptoErrorType,
  // Enums
  SecurityLevel,
  EncryptionAlgorithm,
  // Interfaces
  type CryptoManagerOptions,
  type EncryptionResult,
  type EncryptionParameters,
  type ValidationResult,
  type FileInfo,
  type RetryConfig,
  type ProgressCallback,
  // Container mode (v2 envelope)
  type ContainerMetadataInput,
  type ContainerMetadata,
  type DecryptedContainer,
} from '@hiprax/crypto';

Utility Functions

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

const escape = validatePath('C:\\Users\\..\\Windows', {
  allowedRoot: 'C:\\Users',
});
// escape.isValid === false, escape.error === 'Path is outside the allowed root'

// 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
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)
const isValidUrl = isValidBase64Url('SGVsbG8gV29ybGQ');

// Create progress bar
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. generateRandomString and generateRandomHex draw 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) or generateRandomString(44) (≈262 bits). The defaults (32 chars) are ample for identifiers and classical threat models. generateUUID output 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.

🌐 Isomorphic API & Browser Support

@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 + native/WASM Argon2id, 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.

Isomorphic in-memory API

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-identical

The 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).

Cross-runtime interop

Because both runtimes share one format and one KDF (Argon2id, whose native/WASM outputs are 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.

Browser usage (and Blob output)

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-wasm
import { 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.

Browser Argon2id profile (32 MiB default) and the 128 MiB-decrypt caveat

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) — still ≈1.68× the OWASP 2025/2026 Argon2id memory minimum (19 MiB), but classified MEDIUM by getSecurityLevel() because 32 MiB is below the 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 exact memoryCost used 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.

Content-Security-Policy (WASM)

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+.

Browser memory-hygiene caveats

The browser build is honest about weaker memory hygiene than Node:

  • Opaque CryptoKey. Web Crypto importKey copies the raw key bytes into a CryptoKey object the library can no longer reach — so secureClear cannot scrub it. The engine zeroes the transient raw-key copy it owns immediately after import, but the CryptoKey itself 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.subtle is only available in a secure context (HTTPS or localhost). On an insecure origin, globalThis.crypto.subtle is undefined and the engine throws.

Node-only methods throw in the browser

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 they are present as throwing stubs that raise CryptoError(INVALID_INPUT, 'UNSUPPORTED_IN_BROWSER'): encryptTextSync, decryptTextSync, encryptFile, decryptFile, encryptFileSync, decryptFileSync, encryptData, decryptData, deriveKey, deriveKeySync, and generateSecureRandom. 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 (v2 envelope)

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:

  1. 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.
  2. Confidential metadata. The optional filename / mime (plus the derived size and 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.
  3. End-to-end integrity. The SHA-256 of the plaintext is embedded and re-verified on decrypt (CONTAINER_INTEGRITY_FAILED on 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.

Usage

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); // { filename: 'report.txt', mime: 'text/plain', size: 15 }

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.

Version isolation

Container mode and the v1 ciphertext path reject each other's blobs:

  • Feeding a v2 container to decryptBytes / decryptText throws CryptoError (in the default legacyMode: 'auto' the v1 header parser rejects the 0x02 version and the v0 fallback cannot rescue it, surfacing as DECRYPTION_FAILED; strict / reject surface UNSUPPORTED_VERSION).
  • Feeding a v0/v1 blob to decryptContainer is rejected by a pure, DoS-bounded header parse before any key derivation runs (no HPCR magic → CONTAINER_INVALID_MAGIC; wrong version → CONTAINER_UNSUPPORTED_VERSION).

Container error codes

CONTAINER_INTEGRITY_FAILED (decrypted payload does not match its embedded SHA-256), CONTAINER_METADATA_MALFORMED, CONTAINER_METADATA_TOO_LARGE / CONTAINER_DATA_TOO_LARGE (field/payload exceeds its wire cap), INVALID_CONTAINER_META (non-string filename/mime), plus the pre-authentication parser codes TRUNCATED_CONTAINER, CONTAINER_INVALID_MAGIC, CONTAINER_UNSUPPORTED_VERSION, CONTAINER_UNSUPPORTED_KDF, CONTAINER_INVALID_HEADER_PARAM, and CONTAINER_KDF_PARAMS_OUT_OF_BOUNDS. The byte layout is documented under Container Format (v2).

🔧 Configuration

Asynchronous vs Synchronous Operations

The library provides both asynchronous and synchronous versions of encryption/decryption operations:

Asynchronous Operations (Recommended)

  • Use Argon2id for key derivation (more secure)
  • Better for performance and scalability
  • Non-blocking operations
  • Methods: encryptText(), decryptText(), encryptFile(), decryptFile()

Synchronous Operations

  • 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.

Progress callbacks for file ops

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.

Contract

| 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).

Throwing inside a progress callback aborts the operation

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
    }
  }
);

Examples

import { CryptoManager, 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.

Security Levels

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^14 OR timeCost < 2 (Fast, less secure — fallback tier)
  • Medium: memoryCost: 2^14 (16 MiB), timeCost: 2 (Balanced — minimum acceptable)
  • High: memoryCost: 2^17 (128 MiB), timeCost: 3 (Default, OWASP 2026 first choice)
  • 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.

Migration note (any pre-1.0 dev release → v1.0.0)

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 / parallelism that 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 ** 16 to the CryptoManager constructor. This is the recommended escape hatch for resource-constrained environments (mobile, embedded, low-memory containers, shared free-tier hosts). Note: a CryptoManager configured this way will report getSecurityLevel() === 'medium' rather than 'high', which accurately reflects the post-bump threshold table.
  • To opt INTO the previous ULTRA classification (256 MiB), you now need to supply memoryCost: 2 ** 19 AND timeCost: 4 — the previous ULTRA settings (2 ** 18, 4) now classify as HIGH.

Programmatic introspection

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.

Migration: v1.0.0 → v1.1.0

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):

  1. 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.
  2. parseHeader rejects pathologically-large KDF parameters with KDF_PARAMS_OUT_OF_BOUNDS BEFORE invoking the KDF. Pre-fix, a malicious 100-byte ciphertext could request memoryCost = 4 GiB or iterations = 100M and pin the host for seconds-to-minutes. Caps: Argon2id memoryCost <= 2^22 (4 GiB), timeCost <= 100, parallelism <= 64; PBKDF2 iterations <= 10_000_000. Additionally, parseHeader enforces the Argon2id RFC 9106 §3.1 cross-field floor memoryCost >= 8 * parallelism (code INVALID_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.

Password Requirements

A password is accepted if either of the following holds:

  1. 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 longer whose entropy comes from word choice rather than category mixing.
  2. 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").

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.

Ciphertext Format (v1)

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 this CryptoManager.
  • 'strict': legacy v0 ciphertexts are rejected with CryptoError code `LEGACY_FO