🔍 Audit me

The crypto.
In full.

The exact files your browser runs to encrypt your secrets. Inspect them, copy them, run them through your own static analyzer. We don't hide anything.

lib/crypto.ts

190 lines · 5.9 KB

Symmetric path: text + file envelopes encrypted with AES-256-GCM. Key generated in-browser, never sent to the server.

/**
 * Client-side WebCrypto wrappers. The whole zero-knowledge promise hinges on
 * the encryption key never touching the server, so every function here is
 * designed to run in the browser. The server only ever sees ciphertext.
 *
 * Algorithm: AES-256-GCM. The 12-byte IV is generated per-encryption and
 * prepended to the ciphertext. The key is exported as raw bytes and stuffed
 * into the URL fragment (the `#` portion of a URL is never sent to the
 * server by browsers).
 */

const ALGO = "AES-GCM";
const KEY_LENGTH = 256;
const IV_LENGTH = 12;

export interface EncryptedPayload {
  /** base64url of (iv ‖ ciphertext) */
  ciphertext: string;
  /** base64url of the 32-byte raw AES key — stays in URL fragment, never sent to server */
  key: string;
}

export interface FileEnvelope {
  /** Original file name (encrypted alongside contents) */
  name: string;
  /** MIME type (encrypted alongside contents) */
  mime: string;
  /** Size in bytes (sender side, for display only) */
  size: number;
  /** base64-encoded binary payload */
  b64: string;
}

function bytesToB64Url(bytes: Uint8Array): string {
  let bin = "";
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function b64UrlToBytes(s: string): Uint8Array {
  const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
  const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

function bytesToB64(bytes: Uint8Array): string {
  let bin = "";
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  return btoa(bin);
}

function b64ToBytes(b64: string): Uint8Array {
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

/**
 * Encrypt arbitrary text. The result is the ciphertext (base64url) plus the
 * AES key (also base64url) the caller will put in the URL fragment.
 */
export async function encryptSecret(plaintext: string): Promise<EncryptedPayload> {
  return encryptBytes(new TextEncoder().encode(plaintext));
}

/**
 * Encrypt a wrapped envelope describing one or more files plus optional note
 * text. The envelope is JSON-serialized then encrypted.
 */
export async function encryptEnvelope(envelope: {
  text?: string;
  files?: FileEnvelope[];
}): Promise<EncryptedPayload> {
  return encryptBytes(new TextEncoder().encode(JSON.stringify(envelope)));
}

async function encryptBytes(bytes: Uint8Array): Promise<EncryptedPayload> {
  const key = await crypto.subtle.generateKey(
    { name: ALGO, length: KEY_LENGTH },
    true,
    ["encrypt", "decrypt"],
  );
  const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
  const ct = new Uint8Array(
    await crypto.subtle.encrypt(
      { name: ALGO, iv: iv as BufferSource },
      key,
      bytes as BufferSource,
    ),
  );

  const combined = new Uint8Array(iv.length + ct.length);
  combined.set(iv, 0);
  combined.set(ct, iv.length);

  const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));

  return {
    ciphertext: bytesToB64Url(combined),
    key: bytesToB64Url(rawKey),
  };
}

export async function decryptSecret(
  ciphertextB64: string,
  keyB64: string,
): Promise<string> {
  const bytes = await decryptToBytes(ciphertextB64, keyB64);
  return new TextDecoder().decode(bytes);
}

/**
 * Decrypt an envelope. Returns the parsed object (text + optional files).
 * Falls back to a {text} envelope if the plaintext is not valid JSON (i.e.
 * legacy text-only secrets created before the envelope format existed).
 */
export async function decryptEnvelope(
  ciphertextB64: string,
  keyB64: string,
): Promise<{ text?: string; files?: FileEnvelope[] }> {
  const decoded = await decryptSecret(ciphertextB64, keyB64);
  try {
    const parsed = JSON.parse(decoded) as { text?: string; files?: FileEnvelope[] };
    if (parsed && typeof parsed === "object" && ("text" in parsed || "files" in parsed)) {
      return parsed;
    }
  } catch {
    // legacy plain-text payload
  }
  return { text: decoded };
}

async function decryptToBytes(
  ciphertextB64: string,
  keyB64: string,
): Promise<Uint8Array> {
  const combined = b64UrlToBytes(ciphertextB64);
  if (combined.length < IV_LENGTH + 1) throw new Error("Malformed ciphertext");
  const iv = combined.slice(0, IV_LENGTH);
  const ct = combined.slice(IV_LENGTH);
  const rawKey = b64UrlToBytes(keyB64);
  if (rawKey.length !== 32) throw new Error("Invalid key length");

  const key = await crypto.subtle.importKey(
    "raw",
    rawKey as BufferSource,
    { name: ALGO },
    false,
    ["decrypt"],
  );

  const pt = await crypto.subtle.decrypt(
    { name: ALGO, iv: iv as BufferSource },
    key,
    ct as BufferSource,
  );
  return new Uint8Array(pt);
}

/** Random URL-safe ID for the public link. ~128 bits of entropy. */
export function generateLinkId(): string {
  const bytes = crypto.getRandomValues(new Uint8Array(16));
  return bytesToB64Url(bytes);
}

/** Read a File from a <input type="file"> into a FileEnvelope (base64-wrapped). */
export async function readFileAsEnvelope(file: File): Promise<FileEnvelope> {
  const buf = new Uint8Array(await file.arrayBuffer());
  return {
    name: file.name,
    mime: file.type || "application/octet-stream",
    size: file.size,
    b64: bytesToB64(buf),
  };
}

/** Turn a decrypted FileEnvelope back into a Blob URL for download. */
export function envelopeToBlobUrl(env: FileEnvelope): string {
  const bytes = b64ToBytes(env.b64);
  // Need to copy bytes into a real ArrayBuffer-backed Uint8Array for the Blob constructor type.
  const copy = new Uint8Array(bytes.length);
  copy.set(bytes);
  const blob = new Blob([copy as BlobPart], { type: env.mime || "application/octet-stream" });
  return URL.createObjectURL(blob);
}

lib/asymCrypto.ts

154 lines · 5.2 KB

Asymmetric path: RSA-OAEP keypair for Secret Requests. Hybrid AES wrap so fulfillers can encrypt to the owner without ever talking to them.

/**
 * Asymmetric crypto for the Secret Request flow.
 *
 * The owner (Alice) generates an RSA-OAEP keypair in her browser. The public
 * key (JWK) goes to the server attached to the request. The private key (JWK,
 * base64url-encoded) goes into the URL fragment of her dashboard view link —
 * never sent over the wire.
 *
 * The fulfiller (Bob) opens /r/<id>, fetches Alice's public key, and does
 * hybrid encryption:
 *   1. Generate a one-shot AES-256-GCM key
 *   2. Encrypt the secret with the AES key
 *   3. Encrypt the AES key with Alice's RSA public key
 *   4. Submit { ciphertext, wrappedKey } to the server
 *
 * Alice later opens her view link, fetches { ciphertext, wrappedKey }, and:
 *   1. Decrypts wrappedKey with her RSA private key → recovers the AES key
 *   2. Decrypts ciphertext with the AES key → recovers the plaintext
 *
 * The server only ever sees ciphertext + a wrapped AES key. Without Alice's
 * private key (which lives only in the URL fragment) nothing can be read.
 */

const RSA_ALGO: RsaHashedKeyGenParams = {
  name: "RSA-OAEP",
  modulusLength: 2048,
  publicExponent: new Uint8Array([1, 0, 1]),
  hash: "SHA-256",
};
const AES_ALGO = "AES-GCM";
const IV_LENGTH = 12;

export interface RequestKeypair {
  /** RSA public key as JWK — gets posted to the server */
  publicJwk: JsonWebKey;
  /** RSA private key as JWK, base64url-encoded — goes in URL fragment, never to server */
  privateB64: string;
}

export interface HybridPayload {
  /** base64url of (iv ‖ ciphertext) — the AES-encrypted secret body */
  ciphertext: string;
  /** base64url of the RSA-wrapped AES key */
  wrappedKey: string;
}

function bytesToB64Url(bytes: Uint8Array): string {
  let bin = "";
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function b64UrlToBytes(s: string): Uint8Array {
  const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
  const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

/** Generate a request keypair. Run in the owner's browser. */
export async function generateRequestKeypair(): Promise<RequestKeypair> {
  const kp = await crypto.subtle.generateKey(RSA_ALGO, true, ["encrypt", "decrypt"]);
  const publicJwk = await crypto.subtle.exportKey("jwk", kp.publicKey);
  const privateJwk = await crypto.subtle.exportKey("jwk", kp.privateKey);
  const privateB64 = bytesToB64Url(new TextEncoder().encode(JSON.stringify(privateJwk)));
  return { publicJwk, privateB64 };
}

/** Hybrid-encrypt to the owner's public key. Run in the fulfiller's browser. */
export async function hybridEncrypt(
  plaintext: string,
  publicJwk: JsonWebKey,
): Promise<HybridPayload> {
  // 1. Generate ephemeral AES key
  const aesKey = await crypto.subtle.generateKey({ name: AES_ALGO, length: 256 }, true, ["encrypt"]);
  const rawAes = new Uint8Array(await crypto.subtle.exportKey("raw", aesKey));

  // 2. Encrypt plaintext with AES-GCM
  const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
  const ct = new Uint8Array(
    await crypto.subtle.encrypt(
      { name: AES_ALGO, iv: iv as BufferSource },
      aesKey,
      new TextEncoder().encode(plaintext) as BufferSource,
    ),
  );
  const ivCipher = new Uint8Array(iv.length + ct.length);
  ivCipher.set(iv, 0);
  ivCipher.set(ct, iv.length);

  // 3. Wrap AES key with the owner's RSA public key
  const importedPublic = await crypto.subtle.importKey(
    "jwk",
    publicJwk,
    { name: RSA_ALGO.name, hash: RSA_ALGO.hash },
    false,
    ["encrypt"],
  );
  const wrapped = new Uint8Array(
    await crypto.subtle.encrypt({ name: RSA_ALGO.name }, importedPublic, rawAes as BufferSource),
  );

  return {
    ciphertext: bytesToB64Url(ivCipher),
    wrappedKey: bytesToB64Url(wrapped),
  };
}

/** Hybrid-decrypt with the owner's private key. Run in the owner's browser. */
export async function hybridDecrypt(payload: HybridPayload, privateB64: string): Promise<string> {
  // Recover RSA private key
  const privJwkStr = new TextDecoder().decode(b64UrlToBytes(privateB64));
  const privateJwk = JSON.parse(privJwkStr) as JsonWebKey;
  const importedPrivate = await crypto.subtle.importKey(
    "jwk",
    privateJwk,
    { name: RSA_ALGO.name, hash: RSA_ALGO.hash },
    false,
    ["decrypt"],
  );

  // Unwrap the AES key
  const wrapped = b64UrlToBytes(payload.wrappedKey);
  const rawAes = new Uint8Array(
    await crypto.subtle.decrypt({ name: RSA_ALGO.name }, importedPrivate, wrapped as BufferSource),
  );
  const aesKey = await crypto.subtle.importKey(
    "raw",
    rawAes as BufferSource,
    { name: AES_ALGO },
    false,
    ["decrypt"],
  );

  // Decrypt the body
  const combined = b64UrlToBytes(payload.ciphertext);
  const iv = combined.slice(0, IV_LENGTH);
  const ct = combined.slice(IV_LENGTH);
  const pt = await crypto.subtle.decrypt(
    { name: AES_ALGO, iv: iv as BufferSource },
    aesKey,
    ct as BufferSource,
  );
  return new TextDecoder().decode(pt);
}

/** Random URL-safe ID for a request. */
export function generateRequestId(): string {
  const bytes = crypto.getRandomValues(new Uint8Array(16));
  return bytesToB64Url(bytes);
}

Want to dig deeper?

Open your browser's DevTools → Sources panel → find _next/static/chunks/lib/crypto.ts. The minified code there should match what's on this page (variable names will be munged, structure won't).

If you spot a regression, mismatch, or a JS dependency that shouldn't be there — email us via the contact in the footer. We treat that as a security bug.