Universal (browser + Node.js) crypto vault — zero deps, ESM. Uses AES-GCM-256 (Web Crypto API / Node WebCrypto), supports strings and files/binary buffers (small or large) with chunked encryption, optional gzip compression, and always serializes to Base64URL so you can store/send ciphertext as plain text (e.g. via APIs or DB).
📚 API documentation: https://salvobee.github.io/crypto-vault/
npm i @salvobee/crypto-vault
# or
pnpm add @salvobee/crypto-vault
# or
yarn add @salvobee/crypto-vault
This package is ESM and works in modern browsers (served over HTTPS / localhost) and Node.js ≥ 18.
<script type="module">
import {
generateAesKey,
encryptString, decryptToString,
encryptBlob, decryptToBlob,
exportKeyToBase64, importKeyFromBase64,
} from "@salvobee/crypto-vault";
// 1) Key (recommended: generate once, then export & store safely)
const key = await generateAesKey();
// 2) Encrypt / decrypt a string
const packedText = await encryptString("Hello vault!", key, { compress: true });
const plainText = await decryptToString(packedText, key);
// 3) Encrypt / decrypt a file/blob (e.g. from <input type="file">)
const file = new File(["hello"], "hello.txt", { type: "text/plain" });
const packedBlob = await encryptBlob(file, key, { compress: true, chunkSize: 1024 * 1024 });
const decryptedBlob = await decryptToBlob(packedBlob, key);
// 4) Export/import key as Base64URL JWK string (for download/backup)
const keyB64 = await exportKeyToBase64(key);
const key2 = await importKeyFromBase64(keyB64);
</script>
If you cannot store a randomly generated AES key, you can derive it later from a
passphrase as long as you keep the same salt. Generate the salt once,
persist it alongside the ciphertext (for example as a Base64URL string), and
feed both the passphrase and the saved salt to deriveKeyFromPassphrase
whenever you need to decrypt again.
import {
SALT_BYTES,
deriveKeyFromPassphrase,
encryptString,
decryptToString,
toBase64Url,
fromBase64Url,
} from "@salvobee/crypto-vault";
// 1) Collect a passphrase from the user (e.g. form input)
const passphrase = "correct horse battery staple";
// 2) Generate a random salt once and store it with your ciphertext entry
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const saltB64 = toBase64Url(salt); // store this string alongside the ciphertext
// 3) Derive the key and encrypt some content
const key = await deriveKeyFromPassphrase(passphrase, salt);
const ciphertext = await encryptString("Hello vault!", key);
// Persist both ciphertext and saltB64 (Base64URL) e.g. in your database
await saveSecret({ ciphertext, salt: saltB64 });
// 4) Later, rehydrate the salt and derive the same key to decrypt
const { ciphertext: storedCiphertext, salt: storedSaltB64 } = await loadSecret();
const storedSalt = fromBase64Url(storedSaltB64);
const keyAgain = await deriveKeyFromPassphrase(passphrase, storedSalt);
const plainText = await decryptToString(storedCiphertext, keyAgain);
🔁 The same passphrase + salt pair always yields the same AES key. Keep the salt with the ciphertext so you can reconstruct the key later, and share both if collaborators need to decrypt with a shared passphrase.
The published package includes full TypeScript declarations and rich TSDoc
comments for every exported function. Run npm run build to emit dist/index.js
alongside dist/index.d.ts locally, or install the package in a TypeScript
project to get inline documentation via your editor's hover tooltips.
Static HTML docs are published automatically from main via GitHub Pages.
Prefer working offline? Use the built-in script to regenerate them locally:
npm run docs
crypto.subtle) — widely supported on modern browsers when served over HTTPS or localhost.CompressionStream/DecompressionStream) — optional; if unavailable, compression is silently skipped during encryption and decryption.globalThis.crypto, WHATWG streams, and Blob).node:zlib when browser streams are not available.For very large outputs, Base64URL strings can be huge; consider chunking at the application level if you need to stream/store in slices.
All functions are async unless noted.
generateAesKey(): Promise<CryptoKey>Generates a new AES-GCM-256 symmetric key (["encrypt","decrypt"], extractable).
const key = await generateAesKey();
exportKeyToBase64(key: CryptoKey): Promise<string>Exports a CryptoKey to a Base64URL string containing a JWK JSON.
Use this to download/backup the key or move it between devices.
const b64 = await exportKeyToBase64(key); // e.g. store it or let user download it
importKeyFromBase64(b64: string): Promise<CryptoKey>Imports a CryptoKey previously exported with exportKeyToBase64.
const key = await importKeyFromBase64(b64);
deriveKeyFromPassphrase(passphrase: string, saltU8: Uint8Array, iterations?: number): Promise<CryptoKey>Derives an AES-GCM key from a passphrase via PBKDF2-SHA256 (default 250k iterations).
Uint8Array, recommended 16 bytes).// one-time setup
const salt = crypto.getRandomValues(new Uint8Array(16));
// later you can re-derive the same key with the same passphrase+salt
const key = await deriveKeyFromPassphrase("correct horse battery staple", salt);
Passphrase-derived keys are convenient but generally weaker than random keys; prefer
generateAesKey()for best security and wrap it with public-key crypto if you need sharing.
Use the built-in RSA-OAEP helpers to exchange a freshly generated AES key with collaborators. Each participant generates their own RSA key pair once, exports the public key (safe to share) and keeps the private key secret. Whenever you need to share an AES-GCM key, wrap it with the recipient's public key and send the wrapped blob over any channel. Only the recipient's private key can unwrap the AES key.
import {
generateAesKey,
generateRsaKeyPair,
wrapKeyForRecipient,
unwrapKeyForRecipient,
exportPublicKeyToBase64,
exportPrivateKeyToBase64,
importPublicKeyFromBase64,
importPrivateKeyFromBase64,
exportKeyToBase64,
} from "@salvobee/crypto-vault";
// Alice creates an RSA-OAEP key pair once and stores the private key securely
const alicePair = await generateRsaKeyPair();
const alicePublicB64 = await exportPublicKeyToBase64(alicePair.publicKey); // share this
const alicePrivateB64 = await exportPrivateKeyToBase64(alicePair.privateKey); // keep safe
// Bob wants to send an AES key to Alice
const dataKey = await generateAesKey();
const alicePublicKey = await importPublicKeyFromBase64(alicePublicB64);
const wrappedForAlice = await wrapKeyForRecipient(alicePublicKey, dataKey);
// Alice restores her private key and unwraps the AES key when needed
const alicePrivateKey = await importPrivateKeyFromBase64(alicePrivateB64);
const aliceDataKey = await unwrapKeyForRecipient(wrappedForAlice, alicePrivateKey);
// The AES key matches what Bob originally wrapped
const bobSerialized = await exportKeyToBase64(dataKey);
const aliceSerialized = await exportKeyToBase64(aliceDataKey);
console.assert(bobSerialized === aliceSerialized);
Security trade-offs
generateRsaKeyPair(): Promise<CryptoKeyPair>Generates a 4096-bit RSA-OAEP key pair (["wrapKey","unwrapKey"], extractable) that can be backed up as JWK strings.
const { publicKey, privateKey } = await generateRsaKeyPair();
wrapKeyForRecipient(recipientPublicKey: CryptoKey, keyToWrap: CryptoKey): Promise<string>Wraps an AES key for a collaborator using their RSA-OAEP public key and returns the wrapped blob as a Base64URL string.
const wrapped = await wrapKeyForRecipient(recipientPublicKey, dataKey);
unwrapKeyForRecipient(wrappedKeyB64: string, recipientPrivateKey: CryptoKey): Promise<CryptoKey>Unwraps a Base64URL-wrapped AES key using the recipient's private RSA key.
const dataKey = await unwrapKeyForRecipient(wrapped, privateKey);
exportPublicKeyToBase64(key: CryptoKey): Promise<string> & importPublicKeyFromBase64(b64: string): Promise<CryptoKey>Export/import RSA public keys as Base64URL-encoded JWK strings to move them between devices or share them with collaborators.
exportPrivateKeyToBase64(key: CryptoKey): Promise<string> & importPrivateKeyFromBase64(b64: string): Promise<CryptoKey>Backup and restore RSA private keys. Treat the Base64URL string as a sensitive secret.
All ciphertexts are returned as Base64URL strings that contain a compact binary container (see “Container format”).
encryptString(plainText: string, key: CryptoKey, opts?: { compress?: boolean }): Promise<string>Encrypts a UTF-8 string.
compress (default true): gzip before encrypting (saves space for text).const packed = await encryptString("Hello!", key, { compress: true });
// -> "WCV1..." (Base64URL string)
decryptToString(packedB64u: string, key: CryptoKey): Promise<string>Decrypts a packed Base64URL produced by encryptString.
const text = await decryptToString(packed, key);
encryptBlob(blob: Blob | ArrayBuffer | ArrayBufferView | Buffer, key: CryptoKey, opts?: { compress?: boolean, chunkSize?: number, mimeType?: string }): Promise<string>Encrypts binary data from browsers (Blob/File) or Node (Buffer/Uint8Array).
Small files are encrypted as a single chunk.
Large files (default threshold 64 MiB) are chunked; each chunk is encrypted with a fresh IV.
compress (default true) uses gzip if CompressionStream is available:
chunkSize (default 1 MiB) controls chunk granularity for large files.
mimeType lets you provide a MIME type when the input is not a Blob (e.g. Node buffers).
const packed = await encryptBlob(file, key, { compress: true, chunkSize: 2 * 1024 * 1024 });
decryptToBlob(packedB64u: string, key: CryptoKey, opts?: { output?: "blob" | "uint8array" | "buffer" }): Promise<Blob | Uint8Array | Buffer>Decrypts a packed Base64URL produced by encryptBlob.
Blob (browser-friendly).output: "uint8array" returns the raw bytes.output: "buffer" (Node only) returns a Buffer.const blob = await decryptToBlob(packed, key);
const url = URL.createObjectURL(blob);
downloadText(filename: string, text: string): Blob | Uint8Array | Buffer | voidTiny helper to trigger a download of a text string. In browsers it triggers the download and returns the generated Blob. In Node it returns a Buffer (or Uint8Array if Buffer is unavailable) so you can persist the data manually.
downloadText("vault-key.jwk.b64u.txt", await exportKeyToBase64(key));
toBase64Url(u8: Uint8Array): string / fromBase64Url(b64u: string): Uint8ArrayRobust Base64URL encode/decode for binary data (chunk-safe, browser-friendly). Exposed in case you need consistent Base64URL conversion elsewhere in your app.
<input type="file" id="pick" accept="image/*,video/*" />
<img id="img" style="display:none;max-width:100%" />
<video id="vid" controls style="display:none;max-width:100%"></video>
<script type="module">
import { generateAesKey, encryptBlob, decryptToBlob } from "@salvobee/crypto-vault";
const key = await generateAesKey();
document.getElementById("pick").addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Encrypt -> store packed string somewhere (DB/API)
const packed = await encryptBlob(file, key, { compress: true });
// Later: decrypt to Blob and preview
const blob = await decryptToBlob(packed, key);
const url = URL.createObjectURL(blob);
const img = document.getElementById("img");
const vid = document.getElementById("vid");
img.style.display = vid.style.display = "none";
if (blob.type.startsWith("image/")) {
img.src = url; img.style.display = "block";
} else if (blob.type.startsWith("video/")) {
vid.src = url; vid.style.display = "block";
}
});
</script>
import { generateAesKey, exportKeyToBase64, importKeyFromBase64 } from "@salvobee/crypto-vault";
const key = await generateAesKey();
const b64 = await exportKeyToBase64(key);
// Save `b64` (e.g., IndexedDB, download, secure server)
const key2 = await importKeyFromBase64(b64); // Restore later
import { deriveKeyFromPassphrase } from "@salvobee/crypto-vault";
const salt = crypto.getRandomValues(new Uint8Array(16));
// store salt somewhere safe with the ciphertext
const key = await deriveKeyFromPassphrase(prompt("passphrase"), salt);
Every ciphertext is a Base64URL string wrapping a compact binary container:
[MAGIC "WCV1"][VERSION 1B][FLAGS 1B][ALG_ID 1B][META_LEN 4B BE][META JSON][PAYLOAD]
FLAGS: bit0=compressed, bit1=chunked
ALG_ID: 0x01 = AES-GCM-256
META JSON (examples):
{ type: "text", alg: "AES-GCM", iv, compressed }{ type:"blob", alg:"AES-GCM", mime, size, single:true, iv, compressed }{ type:"blob", alg:"AES-GCM", mime, size, chunked:true, chunkSize, compressed }PAYLOAD:
ct+tag[len 4B BE][iv 12B][ct+tag] for each chunkThis lets you store, transport, and version the ciphertext cleanly across systems.
When you need to hand encrypted data to someone else or recover it in the future, choose the approach that fits your threat model:
generateAesKey(), then export it and
distribute the Base64URL JWK via a secure channel. Pros: full 256-bit entropy.
Cons: the key must be stored/transported safely, and loss of the exported key
means permanent data loss.Some teams mix the two: store the random key encrypted with a stronger passphrase-derived key, or wrap it with public-key crypto to share to specific recipients.
RangeError: too many function arguments — You likely tried to Base64-encode a massive buffer using spread. The package already uses chunked encoding, so if you copied custom code, use toBase64Url provided here.DecompressionStream not available — Your browser doesn’t support Compression Streams; encryption still works, just without gzip.Operation is not supported — Some Web Crypto features require HTTPS or localhost context.Built with ❤️ on top of standard Web Crypto API and Compression Streams API to keep your encrypted content portable and easy to store as text.