DEV Community

Shreyash Tripathi
Shreyash Tripathi

Posted on

Building a zero-knowledge vault in the browser with WebCrypto (no crypto libraries)

At work I built an internal tool for our team's hardware inventory, service logins, environment variables and vendor bills. Because it holds credentials, I gave it one hard rule: the database must never see plaintext. Not the passwords, not the titles, not even which services we use.

The whole encryption layer runs in the browser on the built-in WebCrypto API, with no third-party crypto libraries. This post walks through the design and the real code, trimmed for length.

The shape of it

master password + per-vault salt  --PBKDF2-SHA256 x600k-->  256-bit AES key
each record                       --AES-256-GCM--------->  { iv, ciphertext }
each attached file                --AES-256-GCM--------->  opaque blob
Enter fullscreen mode Exit fullscreen mode

The backend (Postgres plus object storage) stores iv and ciphertext, nothing else. Titles, usernames, URLs and owners all live inside the ciphertext, so the database leaks no metadata either. The key exists only in the tab's memory and is dropped when the vault locks.

1. Password to key

PBKDF2 turns the master password into an AES key. The slowness is the point: 600,000 iterations of SHA-256 makes every password guess expensive.

export async function deriveKey(password: string, saltBase64: string, iterations: number) {
  const material = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]
  );
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt: fromBase64(saltBase64), iterations, hash: "SHA-256" },
    material,
    { name: "AES-GCM", length: 256 },
    false,                    // non-extractable: the key can never be read back out
    ["encrypt", "decrypt"]
  );
}
Enter fullscreen mode Exit fullscreen mode

extractable: false matters: even code running in the page can use the key, but can't export its bytes.

2. Sealing and opening records

Each record is JSON, encrypted with AES-256-GCM and a fresh 12-byte IV:

export async function seal(key: CryptoKey, value: unknown) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = new TextEncoder().encode(JSON.stringify(value));
  const buffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
  return { iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(buffer)) };
}

export async function open<T>(key: CryptoKey, sealed: { iv: string; ciphertext: string }) {
  try {
    const buffer = await crypto.subtle.decrypt(
      { name: "AES-GCM", iv: fromBase64(sealed.iv) }, key, fromBase64(sealed.ciphertext)
    );
    return JSON.parse(new TextDecoder().decode(buffer)) as T;
  } catch {
    // A failed GCM tag check is what a wrong password looks like.
    throw new DecryptionError();
  }
}
Enter fullscreen mode Exit fullscreen mode

GCM is authenticated encryption: if a single byte of the ciphertext is changed, or the key is wrong, decryption fails instead of returning garbage.

To tell the user "wrong password" without trying every record, the vault stores one small verifier: a known string sealed under the key. Unlocking just tries to open it.

3. More than one password, without re-encrypting anything

The first version had a single master password. Then the team wanted separate passwords per person. The naive fix re-encrypts every record under a new key, which is slow, risky, and has to happen again whenever someone leaves.

Instead I added a key envelope. The vault key stays exactly what it always was, and each person stores their own copy of it, sealed under a key derived from their password:

// The vault key as raw bytes (same PBKDF2 output that deriveKey uses internally)
const vaultKeyBytes = await crypto.subtle.deriveBits(
  { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, material, 256
);

// Each account: wrap those bytes under a key derived from that account's password
export async function wrapSecret(kek: CryptoKey, raw: ArrayBuffer) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const buffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, kek, raw);
  return { iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(buffer)) };
}
Enter fullscreen mode Exit fullscreen mode

Adding or removing a person rewrites one small record and touches no data. Because deriveBits(..., 256) returns byte-for-byte what deriveKey uses internally, records written before the change still open.

4. Files: encrypt before upload, and skip base64

Invoices (PDFs and photos) are encrypted in the tab before upload, so object storage only ever holds noise under random names:

export async function sealBytes(key: CryptoKey, bytes: ArrayBuffer) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const buffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, bytes);
  return { iv: toBase64(iv), data: new Blob([buffer]) };
}
Enter fullscreen mode Exit fullscreen mode

Files stay as raw bytes instead of going through base64 like the JSON records. Base64 grows a file by a third, which on a small storage plan is a third of the budget spent on nothing.

5. Share links that are useless if leaked

Sometimes one credential has to go to one person. A share link is derived from two things: a random token in the URL fragment (the part after #, which browsers never send to a server) and the recipient's name:

async function deriveShareKey(tokenBytes: Uint8Array, recipientName: string) {
  // The token is already unique, so its hash doubles as the salt.
  const salt = new Uint8Array(await crypto.subtle.digest("SHA-256", tokenBytes));
  const name = new TextEncoder().encode("\0" + normalizeName(recipientName));

  const material = new Uint8Array(tokenBytes.length + name.length);
  material.set(tokenBytes, 0);
  material.set(name, tokenBytes.length);

  const base = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveKey"]);
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt, iterations: 300_000, hash: "SHA-256" },
    base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
  );
}
Enter fullscreen mode Exit fullscreen mode

The name is never stored, not even as a hash; it's only an input to the key. A wrong name produces a wrong key, and the GCM tag fails. So a link pasted into the wrong chat is useless to whoever finds it, and the server has nothing to check a name against. The 300,000 iterations make guessing names against a leaked link slow.

6. The smallest possible supply chain

Every library that touches plaintext is a risk, so I kept that set small on purpose:

  • No crypto libraries. WebCrypto is built into every browser and is audited far more than any npm package.
  • A hand-written .xlsx reader on the browser's DecompressionStream instead of a spreadsheet library, for importing old spreadsheets.
  • Service logos bundled as path data, never fetched from a logo CDN. Asking a third party for a Stripe logo would tell them the vault holds a Stripe account.

What this design doesn't protect against

Zero-knowledge is strong, but it isn't magic:

  • XSS is the real threat. Anything that can run script in the unlocked tab can use the key. A strict Content Security Policy and few dependencies matter more than the cipher choice.
  • You trust the JavaScript you're served. A compromised deploy could ship code that leaks the password. Deploy access has to be protected like the data.
  • No password recovery. Lose every password and nobody can open the data, not even whoever owns the database. That's the point, but people need to know it upfront.
  • PBKDF2, not Argon2. WebCrypto has no memory-hard KDF, so PBKDF2 with a high iteration count is the best built-in option.

Takeaways

  • WebCrypto is enough for a serious vault: PBKDF2, AES-GCM, deriveBits and non-extractable keys cover it.
  • Encrypt metadata too. A database of encrypted passwords with plaintext titles still tells an attacker which services to target.
  • Key envelopes make multi-user access and offboarding cheap.
  • URL fragments are a great place for secrets that must reach the browser but never the server.

I wrote up the whole tool (asset inventory with QR labels, invoice OCR, spend tracking) as a case study on my portfolio: shreyashtripathi.in/projects/asset-credential-vault. I'm a Frontend Developer and UI/UX Engineer in Noida. Questions about browser crypto are welcome in the comments.

Top comments (0)