vault

Encrypted document exchange. Files are encrypted in your browser before they leave your device. The server stores ciphertext. The key exists only in the link you share. I wrote 9 research documents before a single line of code.

TypeScriptAES-256-GCMHKDF-SHA256Web CryptoHonoCloudflare WorkersR2D1
Source 43 tests · 9 research docs · 0 external crypto deps

The research came first

I spent two weeks reading about how browser-based encryption tools fail before writing anything. The ETH Zurich team that broke MEGA's encryption (IEEE S&P 2023) demonstrated five attacks, all from the same root causes: key reuse, unauthenticated key material, non-standard crypto. Every one of those failures is avoidable with standard primitives used correctly.

The Bitwarden Send architecture informed the URL fragment key delivery model. The Firefox Send shutdown informed the abuse policy. The W3C Web Crypto spec disclaimers informed the honest limitations about memory persistence. OWASP password storage guidelines set the PBKDF2 iteration count. Nine documents total, published alongside the code. Every design decision traces to a specific finding.

How the encryption works

Each file gets a unique 128-bit random key via crypto.getRandomValues(). The key is expanded to 256-bit AES-GCM via HKDF-SHA256 (RFC 5869). A fresh 96-bit nonce is generated per file. The server receives nonce || ciphertext || auth tag. It never sees the key, the plaintext, or the filename.

Zero external crypto dependencies. Everything uses the Web Crypto API natively, which means encryption runs in the browser engine's C++ layer, not in JavaScript. The derived CryptoKey is marked non-extractable — it stays inside the browser's crypto module.

URL fragment key delivery

RFC 3986 §3.5: the URL fragment is not included in any HTTP request. Not in the page load, not in Referer headers, not in redirects. The key exists only in the URL you share. When the recipient opens the link, JavaScript reads window.location.hash, immediately strips it from browser history via replaceState, derives the encryption key, and decrypts locally.

The optional password is authentication only — it gates the download endpoint on the server. It doesn't participate in encryption. The encryption key is always the random value in the URL, not something derived from a password.

The code delivery problem

The server delivers the JavaScript that performs encryption. A compromised server could serve modified JavaScript that captures keys before encrypting. This applies to every browser-based encryption tool ever built — ProtonMail, MEGA, Bitwarden's web vault.

SRI doesn't solve it because the HTML page containing the SRI attributes is also served by the server. vault states this limitation prominently, not buried in a FAQ. The hardest part of building this tool wasn't the encryption. It was deciding what to be honest about.

Figure 1 — Information the server learns, by channel (bits)entropy budget
file contents0 — protectedAES-256-GCMauth tag forgery prob. 2⁻¹²⁸filename0 — protectednot transmittedencrypted with payloadencryption key0 — protectedURL fragmentRFC 3986 §3.5 — never sentfile size24 bitsstructuralciphertext = plaintext + 28 bupload timestamp19 bitsserver logminute-precision, 1 yr rangeuploader IP22 bitsTCP / TLSafter ISP clusteringdownloader IP22 bitsTCP / TLSafter ISP clusteringdownload count3 bitsDB columncapped per share (≤ ~10)08162432bits revealed to serverchannelΣ leaked = 90 bitsprotecting primitive
Three of the eight channels emit zero bits to the server — that’s the architectural claim, enforced by AES-256-GCM, payload-side filename encryption, and RFC 3986 §3.5 (URL fragments are never sent in HTTP requests). The remaining five channels are honest leaks: file size is structural, timestamps and IPs are operationally unavoidable, the download counter is a DB column by design. The chart is what vault is actually claiming when it says “zero-knowledge.” The bound is over content; the metadata column tells the rest of the story, which is why vault recommends Tor for sender/receiver anonymity rather than pretending the IP layer is hidden.

Firefox Send's lesson

Mozilla built a technically sound encrypted file sharing tool and it died within a year. Malware operators loved it: trusted Mozilla domain, encrypted files that can't be scanned, auto-expiry that destroys evidence. Perfect for C2 payload delivery.

Zero-knowledge means no content scanning. vault accepts this tradeoff honestly. Rate limiting (10 uploads/hour per IP), file size cap (100MB), download limits, and link takedown are the only mitigations. This is stated in the README, not discovered by journalists after a shutdown.

From the source

Client-side encryption — AES-256-GCM via Web Cryptoapps/web/src/crypto.ts
export async function encrypt(
  plaintext: ArrayBuffer,
  rawKey: Uint8Array,
): Promise<ArrayBuffer> {
  const key = await deriveEncryptionKey(rawKey);
  const nonce = crypto.getRandomValues(new Uint8Array(12));

  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv: nonce },
    key,
    plaintext,
  );

  // Prepend nonce to ciphertext
  const result = new Uint8Array(12 + ciphertext.byteLength);
  result.set(nonce, 0);
  result.set(new Uint8Array(ciphertext), 12);
  return result.buffer;
}
One-time download — blob deleted before response finishesapps/api/src/index.ts
// Increment download count
const newCount = await incrementDownloads(c.env, id);

// If this was the last allowed download, delete immediately
if (newCount >= meta.max_downloads) {
  await deleteBlob(c.env, id);
}

return new Response(blob, {
  status: 200,
  headers: {
    "Content-Type": "application/octet-stream",
    "Cache-Control": "no-store",
    "X-Downloads-Remaining": String(
      Math.max(0, meta.max_downloads - newCount)
    ),
  },
});

What it cannot protect against

  • The server delivers the code that performs encryption. A compromised server could serve modified JavaScript that captures keys. This applies to all browser-based encryption.
  • The key is in the URL fragment. Browser history, cloud sync, and extensions with page access can read it. The fragment is stripped after reading, but copies may persist on synced devices.
  • File size is visible to the server. The encrypted blob is plaintext size plus 28 bytes (nonce + auth tag). No padding.
  • The server sees uploader and downloader IP addresses. vault protects file contents, not communication metadata. Use Tor for sender/receiver anonymity.
  • Deletion is best-effort. Cloud storage backups, CDN edge caches, and the recipient's browser download folder may retain copies.
  • JavaScript has no memzero(). Key material persists in the garbage-collected heap until the runtime frees it. The W3C Web Crypto spec explicitly disclaims responsibility for this.
  • A browser extension with page access can read window.location.hash (the key) and any decrypted content rendered to the DOM. No defence against this.
  • Once decrypted content is on screen, it can be captured by OS screenshot tools, screen recording, or print-to-PDF. No DRM-style prevention is attempted.
  • Zero-knowledge means no content scanning. The tool can be used to share malware. Rate limits and link takedown are the only mitigations.
  • PBKDF2 is the only password KDF available in Web Crypto API. It is GPU-friendly. The password is authentication only — encryption uses the random URL key.

Stack

AES-256-GCM + HKDF-SHA256 via Web Crypto API (zero external crypto dependencies). Cloudflare Workers (API) + R2 (blob storage) + D1 (metadata). Hono router. TypeScript throughout.

Authorised use

Use to exchange documents you are entitled to share. Encryption does not, by itself, make any onward disclosure lawful. The tool is provided without warranty; do not rely on it for evidentiary or regulatory workflows. See /scope.