// All blob encryption happens in the browser. The passkey's PRF output (only // ever visible client-side) derives a per-credential KEK; a random long-lived // DEK encrypts the blob; the DEK is wrapped once per enrolled passkey's KEK // (envelope encryption — adding a second passkey = wrapping the DEK again). // The plaintext is padded to a fixed size BEFORE encryption, so the ciphertext // length leaks nothing about the contents. The server only ever stores/relays // the opaque envelope produced here. export function b64urlToBytes(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } export function bytesToB64url(bytes) { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // encryptBytes/decryptBytes: raw iv(12)||ciphertext, same format as the // messenger's crypto.js. export async function encryptBytes(key, bytes) { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, bytes)); const out = new Uint8Array(iv.length + ct.length); out.set(iv, 0); out.set(ct, iv.length); return out; } export async function decryptBytes(key, bytes) { const iv = bytes.slice(0, 12); const ct = bytes.slice(12); const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); return new Uint8Array(pt); } // ---- ECIES wrap (restore hand-back) ---------------------------------------- // // Restore returns a chat's credentials to the instance, so they must NOT sit // plaintext in the return URL. The instance generates an ephemeral ECDH keypair // and sends its public key; we wrap the credentials to it here. This mirrors the // messenger's crypto.js wrapThreadKey EXACTLY (same curve, HKDF info, and // ephPub(65)||iv||ct layout) so the instance's unwrapThreadKey recovers it. const EC = { name: "ECDH", namedCurve: "P-256" }; async function eciesKey(privKey, pubKey) { const bits = await crypto.subtle.deriveBits({ name: "ECDH", public: pubKey }, privKey, 256); const ikm = await crypto.subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("lchat-thread-wrap") }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } // wrapToPublicKey ECIES-encrypts raw bytes (base64url) to a recipient's ECDH // public key (base64url raw). Returns base64url( ephPub(65) || iv(12) || ct ) — // the messenger's unwrapThreadKey(wrapped, privKey) format. export async function wrapToPublicKey(bytesB64, recipientPubB64) { const recipient = await crypto.subtle.importKey("raw", b64urlToBytes(recipientPubB64), EC, false, []); const eph = await crypto.subtle.generateKey(EC, true, ["deriveBits"]); const aes = await eciesKey(eph.privateKey, recipient); const body = await encryptBytes(aes, b64urlToBytes(bytesB64)); // iv||ct const ephPub = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey)); const out = new Uint8Array(ephPub.length + body.length); out.set(ephPub, 0); out.set(body, ephPub.length); return bytesToB64url(out); } // ---- PRF -> KEK ----------------------------------------------------------- // The PRF eval input is a fixed, public constant: the secrecy lives entirely in // the passkey (the authenticator mixes the input with a per-credential secret). // Versioned so a future KDF change can request both and migrate. export function prfEvalInput() { return new TextEncoder().encode("eleven-home-kek-v1"); } // kekFromPRF derives the AES-GCM key-encryption key from a PRF output // (ArrayBuffer/Uint8Array, from getClientExtensionResults().prf.results.first). export async function kekFromPRF(prfOutput) { const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("eleven-home-kek") }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } // ---- DEK (the long-lived blob key) ---------------------------------------- // The DEK is extractable so it can be wrapped to each passkey's KEK and cached // (session-only by default) for silent sync later. export async function generateDEK() { return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); } export async function exportDEK(dek) { return bytesToB64url(new Uint8Array(await crypto.subtle.exportKey("raw", dek))); } export async function importDEK(b64) { return crypto.subtle.importKey("raw", b64urlToBytes(b64), "AES-GCM", true, ["encrypt", "decrypt"]); } // wrapDEK/unwrapDEK: the DEK's raw bytes under a KEK, as base64url(iv||ct). export async function wrapDEK(dek, kek) { const raw = new Uint8Array(await crypto.subtle.exportKey("raw", dek)); return bytesToB64url(await encryptBytes(kek, raw)); } export async function unwrapDEK(wrappedB64, kek) { const raw = await decryptBytes(kek, b64urlToBytes(wrappedB64)); return crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]); } // ---- fixed-size padding ----------------------------------------------------- // Every blob's plaintext is padded to exactly this many bytes before // encryption, so the (server-visible) ciphertext length reveals nothing about // how many servers are in the list or how big their entries are. export const BLOB_PAD = 16384; // pad: 4-byte big-endian length prefix, then the bytes, then zeros to BLOB_PAD. export function pad(bytes) { if (bytes.length > BLOB_PAD - 4) throw new Error("blob contents too large"); const out = new Uint8Array(BLOB_PAD); new DataView(out.buffer).setUint32(0, bytes.length); out.set(bytes, 4); return out; } export function unpad(bytes) { const len = new DataView(bytes.buffer, bytes.byteOffset).getUint32(0); return bytes.slice(4, 4 + len); } // ---- the envelope ------------------------------------------------------------ // // Wire format (what PUT /api/blob stores, opaque to the server): // "EHB1" (4) || u32 BE headerLen || header JSON || iv(12) || ciphertext // header = { keys: { : } } — one entry per // enrolled passkey. The header is in clear, but holds nothing the server // doesn't already know (it stores the credentials) and nothing usable without // an authenticator: wrapped DEKs are ciphertext under per-passkey KEKs. const MAGIC = new TextEncoder().encode("EHB1"); // sealBlob encrypts obj (padded) under dek and packs it with the wrapped-key // header. keys is { credentialId: wrappedDEK }. export async function sealBlob(obj, dek, keys) { const pt = pad(new TextEncoder().encode(JSON.stringify(obj))); const body = await encryptBytes(dek, pt); // iv||ct const header = new TextEncoder().encode(JSON.stringify({ keys })); const out = new Uint8Array(4 + 4 + header.length + body.length); out.set(MAGIC, 0); new DataView(out.buffer).setUint32(4, header.length); out.set(header, 8); out.set(body, 8 + header.length); return out; } // unpackBlob splits an envelope into { keys, body } without decrypting — used // both to find our wrapped DEK and to re-seal preserving other passkeys' keys. export function unpackBlob(bytes) { for (let i = 0; i < 4; i++) { if (bytes[i] !== MAGIC[i]) throw new Error("not a home blob"); } const headerLen = new DataView(bytes.buffer, bytes.byteOffset).getUint32(4); const header = JSON.parse(new TextDecoder().decode(bytes.slice(8, 8 + headerLen))); return { keys: header.keys || {}, body: bytes.slice(8 + headerLen) }; } // openBlob decrypts an envelope with an already-unwrapped DEK. export async function openBlob(bytes, dek) { const { keys, body } = unpackBlob(bytes); const pt = unpad(await decryptBytes(dek, body)); return { obj: JSON.parse(new TextDecoder().decode(pt)), keys }; }