@salvobee/crypto-vault
v2.1.0
Published
Browser crypto vault: AES-GCM 256, chunked blobs, optional gzip, base64url packaging.
Maintainers
Readme
Crypto Vault
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/
- 🔐 AES-GCM 256 (authenticated encryption)
- 🧩 Chunked blobs for large files (images, videos, PDFs…)
- 🗜️ Optional gzip compression
- 🔤 Base64URL packaging (portable, DB/API-friendly)
- 🌐 Works in modern browsers and Node 18+ (uses Web Crypto & gzip in each environment)
Install
npm i @salvobee/crypto-vault
# or
pnpm add @salvobee/crypto-vault
# or
yarn add @salvobee/crypto-vaultThis package is ESM and works in modern browsers (served over HTTPS / localhost) and Node.js ≥ 18.
Quickstart
<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>Passphrase-derived key example
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.
TypeScript support & API docs
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 docsRuntime requirements
Browsers
- Web Crypto API (
crypto.subtle) — widely supported on modern browsers when served over HTTPS or localhost. - Compression Streams API (
CompressionStream/DecompressionStream) — optional; if unavailable, compression is silently skipped during encryption and decryption.
Node.js
- Node 18+ (ships with
globalThis.crypto, WHATWG streams, andBlob). - Gzip compression uses
node:zlibwhen 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.
API Reference
All functions are async unless noted.
Key management
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 itimportKeyFromBase64(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).
- You must provide a random salt (
Uint8Array, recommended 16 bytes). - Store the salt alongside the ciphertext if you plan to reproduce the key later.
// 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.
Sharing AES keys between users
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
- RSA private keys are extractable so you can back them up or migrate devices; store the Base64URL/JWK string with the same care you would give to any other long-term secret.
- RSA-OAEP with 4096-bit modulus is widely supported but computationally heavy; use it for short-lived key exchanges only (the AES key), not for bulk data.
- Rotating RSA keys improves forward secrecy but requires redistributing the new public key to collaborators.
- Want stronger forward secrecy? Combine the wrapped AES key with per-message passphrases or rotate wrapped keys frequently; full ECDH support can be built on top of these helpers if you prefer ephemeral exchanges.
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.
High-level primitives
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(defaulttrue): 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(defaulttrue) uses gzip ifCompressionStreamis available:- For small files: compress whole buffer.
- For large files: compress per chunk.
chunkSize(default 1 MiB) controls chunk granularity for large files.mimeTypelets you provide a MIME type when the input is not aBlob(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.
- Default output is a
Blob(browser-friendly). output: "uint8array"returns the raw bytes.output: "buffer"(Node only) returns aBuffer.
const blob = await decryptToBlob(packed, key);
const url = URL.createObjectURL(blob);Utilities
downloadText(filename: string, text: string): Blob | Uint8Array | Buffer | void
Tiny 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): Uint8Array
Robust Base64URL encode/decode for binary data (chunk-safe, browser-friendly). Exposed in case you need consistent Base64URL conversion elsewhere in your app.
Usage examples
Show decrypted image/video in the page
<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>Persist and reload keys
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 laterDerive a key from passphrase
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);Container format (high level)
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-256META JSON (examples):
- text:
{ type: "text", alg: "AES-GCM", iv, compressed } - blob (single):
{ type:"blob", alg:"AES-GCM", mime, size, single:true, iv, compressed } - blob (chunked):
{ type:"blob", alg:"AES-GCM", mime, size, chunked:true, chunkSize, compressed }
- text:
PAYLOAD:
- non-chunked: raw
ct+tag - chunked: repeated
[len 4B BE][iv 12B][ct+tag]for each chunk
- non-chunked: raw
This lets you store, transport, and version the ciphertext cleanly across systems.
Security notes
- AES-GCM provides confidentiality + integrity. Without the key, decryption is computationally infeasible.
- A fresh random IV (96-bit) is used per message/chunk; do not reuse IV with the same key.
- Optional AAD binds content kind and version to the authentication tag to prevent format confusion.
- For sharing across users/devices, consider wrapping the symmetric key with public-key crypto and distributing encrypted key material (not part of this package yet).
- Keep keys out of logs / analytics and never hard-code them.
Sharing & collaboration strategies
When you need to hand encrypted data to someone else or recover it in the future, choose the approach that fits your threat model:
- Passphrase + salt – Everyone agrees on the same passphrase and stores the generated salt alongside the ciphertext (see the passphrase-derived key example). Pros: nothing sensitive to download or sync, the key can always be re-derived. Cons: entropy is capped by the passphrase quality, so keep the salt secret from attackers and prefer longer passphrases.
- Random AES key – Generate once via
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.
Performance & size tips
- Base64URL overhead ≈ 33%. For very large media, the string will be big; if needed, split the string into application-level segments (e.g., 1–5 MB) to stream/upload progressively.
- Compression helps mostly with text and some binary formats; many images/videos are already compressed — enabling gzip won’t hurt, it’s skipped if the browser lacks support.
Troubleshooting
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, usetoBase64Urlprovided 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.
License
Acknowledgements
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.
