Universal, zero-deps encryption for Browser + Node.js. Encrypt strings and files (small or huge) with AES-GCM-256, optional gzip, and ship the result as a single Base64URL string—perfect for APIs, DBs, and copy-paste sharing.
📚 API docs: https://salvobee.github.io/crypto-vault/
Most apps don’t need a heavyweight crypto stack—they need something portable, boring-reliable, and easy to ship:
Concept: Crypto Vault turns any input into an opaque, versioned, self-described blob you can store as text and decrypt only with the right key—on any modern runtime.
npm i @salvobee/crypto-vault
# or
pnpm add @salvobee/crypto-vault
# or
yarn add @salvobee/crypto-vault
ESM only. Works in modern browsers (HTTPS/localhost) and Node.js ≥ 18.
<script type="module">
import {
generateAesKey,
encryptString, decryptToString,
encryptBlob, decryptToBlob,
} from "@salvobee/crypto-vault";
// 1) Generate a key (do this once, store it safely)
const key = await generateAesKey();
// 2) Encrypt / decrypt a string
const packed = await encryptString("Hello vault!", key, { compress: true });
const text = await decryptToString(packed, key);
// 3) Encrypt / decrypt a file/blob
const file = new File(["hello"], "hello.txt", { type: "text/plain" });
const packedBlob = await encryptBlob(file, key); // Base64URL
const blob = await decryptToBlob(packedBlob, key); // Blob
</script>
That’s it. You now have ciphertext you can safely store/send as plain text.
zlib in Node).Full signatures & details: API docs → https://salvobee.github.io/crypto-vault/
Key management
generateAesKey()exportKeyToBase64(key) / importKeyFromBase64(b64)deriveKeyFromPassphrase(passphrase, saltU8, iterations?)generateRsaKeyPair(), wrapKeyForRecipient(), unwrapKeyForRecipient(), and RSA import/export helpersHigh-level primitives
encryptString() / decryptToString()encryptBlob() / decryptToBlob() (Blob | ArrayBuffer | Buffer, auto-chunking)Utilities
downloadText(filename, text) (browser download / Node Buffer)toBase64Url(u8) / fromBase64Url(b64u)If you can’t persist a random AES key, derive it from a passphrase + a stable salt. Store the salt with the ciphertext as Base64URL.
import {
SALT_BYTES,
deriveKeyFromPassphrase,
encryptString,
decryptToString,
toBase64Url,
fromBase64Url,
} from "@salvobee/crypto-vault";
const passphrase = "correct horse battery staple";
// Generate once, store alongside ciphertext
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const saltB64 = toBase64Url(salt);
// Derive & encrypt
const key = await deriveKeyFromPassphrase(passphrase, salt);
const ciphertext = await encryptString("Hello vault!", key);
// Later: restore salt & derive again to decrypt
const keyAgain = await deriveKeyFromPassphrase(passphrase, fromBase64Url(saltB64));
const plain = await decryptToString(ciphertext, keyAgain);
🔁 Same passphrase + salt → same AES key. Keep both to re-derive; share both if collaborators decrypt with a shared passphrase.
<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;
const packed = await encryptBlob(file, key, { compress: true });
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 (IndexedDB, download, server…)
const key2 = await importKeyFromBase64(b64); // restore later
import { generateAesKey, encryptBlob, decryptToBlob } from "@salvobee/crypto-vault";
import { readFileSync, writeFileSync } from "node:fs";
const key = await generateAesKey();
const input = readFileSync("input.pdf");
const packed = await encryptBlob(input, key, { compress: true });
const outBuf = await decryptToBlob(packed, key, { output: "buffer" });
writeFileSync("output.pdf", outBuf);
Use RSA only to wrap the AES key—not for bulk data.
import {
generateAesKey,
generateRsaKeyPair,
wrapKeyForRecipient,
unwrapKeyForRecipient,
exportPublicKeyToBase64, importPublicKeyFromBase64,
exportPrivateKeyToBase64, importPrivateKeyFromBase64,
exportKeyToBase64,
} from "@salvobee/crypto-vault";
// Alice
const alicePair = await generateRsaKeyPair();
const alicePubB64 = await exportPublicKeyToBase64(alicePair.publicKey);
const alicePrivB64 = await exportPrivateKeyToBase64(alicePair.privateKey);
// Bob wraps an AES key for Alice
const dataKey = await generateAesKey();
const alicePub = await importPublicKeyFromBase64(alicePubB64);
const wrappedForAlice = await wrapKeyForRecipient(alicePub, dataKey);
// Alice unwraps
const alicePriv = await importPrivateKeyFromBase64(alicePrivB64);
const aliceDataKey = await unwrapKeyForRecipient(wrappedForAlice, alicePriv);
// Sanity check
console.assert(
(await exportKeyToBase64(dataKey)) === (await exportKeyToBase64(aliceDataKey))
);
Trade-offs: extractable RSA private keys make backups easy; RSA-OAEP-4096 is heavy → use it only for key exchange; rotate keys for better forward secrecy.
compress: "auto" policy automatically skips compression for content types that gain nothing (JPEG, PNG, WebP, AVIF, HEIC, video/*, audio/mpeg|aac|opus|ogg|flac, ZIP, GZIP, 7z, RAR, PDF, EPUB, OOXML). Force the behaviour with compress: true or compress: false. Compression is also skipped silently if the runtime lacks Compression Streams.generateAesKey) for maximum entropy; use passphrase-derived keys only when necessary.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]
VERSION: 0x02 (current). The reader still parses 0x01 containers for
backward compatibility, but new ciphertexts are always written as v2.
FLAGS (v2): bit0 reserved (always 0), bit1=chunked. In v1 bit0 indicated whether the payload was compressed; v2 moves that information into the encrypted envelope.
ALG_ID: 0x01 = AES-GCM-256
META JSON (clear, v2 — minimal dispatch fields only):
{ type:"text", alg:"AES-GCM", iv }{ type:"blob", alg:"AES-GCM", single:true, iv }{ type:"blob", alg:"AES-GCM", chunked:true }PAYLOAD (v2):
[u32be env_len][env JSON][raw bytes],
where env carries { mime, size, compressed } for blobs (or
{ compressed } for text).[len 4B BE][iv 12B][ct+tag] (carrying
{ mime, size, compressed, chunkSize }) followed by data frames
[len 4B BE][iv 12B][ct+tag] per chunk.In v1 the meta JSON was sent in the clear and included mime, size, and
compressed. For private media vaults that's already metadata an attacker can
profile from the bucket without ever seeing the key. v2 hides those fields
inside the AES-GCM authentication scope; the clear meta only carries what the
reader needs before decryption (algorithm, IV, framing). v1 containers can
still be read.
RangeError: too many function arguments
Likely attempted to Base64-encode a massive buffer using spread. Use the built-in toBase64Url which is chunk-safe.
DecompressionStream not available
Your browser doesn’t support Compression Streams; encryption still works (without gzip).
Operation is not supported
Web Crypto often requires HTTPS or localhost.
MIT — see LICENSE.
Built with ❤️ on standard Web Crypto API and Compression Streams API so your encrypted content stays portable—and easy to store as text.