Encrypted Transfer

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.

AES-256-GCMHKDF-SHA256Web Crypto APICloudflare WorkersR2 StorageTypeScriptHonoZero Dependencies (crypto)
01 / Engineering

How it works

Client-side encryption, zero-knowledge server, research-first architecture.

02 / The Code

From the source

AES-GCM encryption, HKDF key derivation, and one-time download deletion. Copied from the repo.

Client-Side Encryption
AES-256-GCM via Web Crypto API. Key derived via HKDF. Nonce prepended to ciphertext.
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;
}
HKDF Key Expansion
128-bit random key → 256-bit AES-GCM key. Non-extractable CryptoKey stays in browser engine.
export async function deriveEncryptionKey(
  rawKey: Uint8Array,
): Promise<CryptoKey> {
  const keyMaterial = await crypto.subtle.importKey(
    "raw", rawKey, "HKDF", false, ["deriveKey"],
  );

  return crypto.subtle.deriveKey(
    {
      name: "HKDF",
      hash: "SHA-256",
      salt: new TextEncoder().encode("vault-enc"),
      info: new TextEncoder().encode("aes-256-gcm"),
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false, // non-extractable
    ["encrypt", "decrypt"],
  );
}
One-Time Download
Increment count, delete if final download. Blob gone before the response finishes.
// 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)
    ),
  },
});
0
tests passing
0
research documents
0
external crypto deps
0
limitations stated