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.
- ✓ No third-party crypto libraries — only the browser's own
window.crypto.subtle. - ✓ No telemetry in these files. No fetch calls other than to
/api/secrets. - ✓ Bit-for-bit deterministic— the code shipped to your browser matches what's below.
lib/crypto.ts
190 lines · 5.9 KBSymmetric 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 KBAsymmetric 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.