| 1 | // All blob encryption happens in the browser. The passkey's PRF output (only |
| 2 | // ever visible client-side) derives a per-credential KEK; a random long-lived |
| 3 | // DEK encrypts the blob; the DEK is wrapped once per enrolled passkey's KEK |
| 4 | // (envelope encryption — adding a second passkey = wrapping the DEK again). |
| 5 | // The plaintext is padded to a fixed size BEFORE encryption, so the ciphertext |
| 6 | // length leaks nothing about the contents. The server only ever stores/relays |
| 7 | // the opaque envelope produced here. |
| 8 | |
| 9 | export function b64urlToBytes(s) { |
| 10 | s = s.replace(/-/g, "+").replace(/_/g, "/"); |
| 11 | while (s.length % 4) s += "="; |
| 12 | const bin = atob(s); |
| 13 | const out = new Uint8Array(bin.length); |
| 14 | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| 15 | return out; |
| 16 | } |
| 17 | |
| 18 | export function bytesToB64url(bytes) { |
| 19 | let bin = ""; |
| 20 | for (const b of bytes) bin += String.fromCharCode(b); |
| 21 | return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 22 | } |
| 23 | |
| 24 | // encryptBytes/decryptBytes: raw iv(12)||ciphertext, same format as the |
| 25 | // messenger's crypto.js. |
| 26 | export async function encryptBytes(key, bytes) { |
| 27 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 28 | const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, bytes)); |
| 29 | const out = new Uint8Array(iv.length + ct.length); |
| 30 | out.set(iv, 0); |
| 31 | out.set(ct, iv.length); |
| 32 | return out; |
| 33 | } |
| 34 | |
| 35 | export async function decryptBytes(key, bytes) { |
| 36 | const iv = bytes.slice(0, 12); |
| 37 | const ct = bytes.slice(12); |
| 38 | const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); |
| 39 | return new Uint8Array(pt); |
| 40 | } |
| 41 | |
| 42 | // ---- ECIES wrap (restore hand-back) ---------------------------------------- |
| 43 | // |
| 44 | // Restore returns a chat's credentials to the instance, so they must NOT sit |
| 45 | // plaintext in the return URL. The instance generates an ephemeral ECDH keypair |
| 46 | // and sends its public key; we wrap the credentials to it here. This mirrors the |
| 47 | // messenger's crypto.js wrapThreadKey EXACTLY (same curve, HKDF info, and |
| 48 | // ephPub(65)||iv||ct layout) so the instance's unwrapThreadKey recovers it. |
| 49 | |
| 50 | const EC = { name: "ECDH", namedCurve: "P-256" }; |
| 51 | |
| 52 | async function eciesKey(privKey, pubKey) { |
| 53 | const bits = await crypto.subtle.deriveBits({ name: "ECDH", public: pubKey }, privKey, 256); |
| 54 | const ikm = await crypto.subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]); |
| 55 | return crypto.subtle.deriveKey( |
| 56 | { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("lchat-thread-wrap") }, |
| 57 | ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | // wrapToPublicKey ECIES-encrypts raw bytes (base64url) to a recipient's ECDH |
| 62 | // public key (base64url raw). Returns base64url( ephPub(65) || iv(12) || ct ) — |
| 63 | // the messenger's unwrapThreadKey(wrapped, privKey) format. |
| 64 | export async function wrapToPublicKey(bytesB64, recipientPubB64) { |
| 65 | const recipient = await crypto.subtle.importKey("raw", b64urlToBytes(recipientPubB64), EC, false, []); |
| 66 | const eph = await crypto.subtle.generateKey(EC, true, ["deriveBits"]); |
| 67 | const aes = await eciesKey(eph.privateKey, recipient); |
| 68 | const body = await encryptBytes(aes, b64urlToBytes(bytesB64)); // iv||ct |
| 69 | const ephPub = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey)); |
| 70 | const out = new Uint8Array(ephPub.length + body.length); |
| 71 | out.set(ephPub, 0); |
| 72 | out.set(body, ephPub.length); |
| 73 | return bytesToB64url(out); |
| 74 | } |
| 75 | |
| 76 | // ---- PRF -> KEK ----------------------------------------------------------- |
| 77 | |
| 78 | // The PRF eval input is a fixed, public constant: the secrecy lives entirely in |
| 79 | // the passkey (the authenticator mixes the input with a per-credential secret). |
| 80 | // Versioned so a future KDF change can request both and migrate. |
| 81 | export function prfEvalInput() { |
| 82 | return new TextEncoder().encode("eleven-home-kek-v1"); |
| 83 | } |
| 84 | |
| 85 | // kekFromPRF derives the AES-GCM key-encryption key from a PRF output |
| 86 | // (ArrayBuffer/Uint8Array, from getClientExtensionResults().prf.results.first). |
| 87 | export async function kekFromPRF(prfOutput) { |
| 88 | const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]); |
| 89 | return crypto.subtle.deriveKey( |
| 90 | { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("eleven-home-kek") }, |
| 91 | ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] |
| 92 | ); |
| 93 | } |
| 94 | |
| 95 | // ---- DEK (the long-lived blob key) ---------------------------------------- |
| 96 | |
| 97 | // The DEK is extractable so it can be wrapped to each passkey's KEK and cached |
| 98 | // (session-only by default) for silent sync later. |
| 99 | export async function generateDEK() { |
| 100 | return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); |
| 101 | } |
| 102 | |
| 103 | export async function exportDEK(dek) { |
| 104 | return bytesToB64url(new Uint8Array(await crypto.subtle.exportKey("raw", dek))); |
| 105 | } |
| 106 | |
| 107 | export async function importDEK(b64) { |
| 108 | return crypto.subtle.importKey("raw", b64urlToBytes(b64), "AES-GCM", true, ["encrypt", "decrypt"]); |
| 109 | } |
| 110 | |
| 111 | // wrapDEK/unwrapDEK: the DEK's raw bytes under a KEK, as base64url(iv||ct). |
| 112 | export async function wrapDEK(dek, kek) { |
| 113 | const raw = new Uint8Array(await crypto.subtle.exportKey("raw", dek)); |
| 114 | return bytesToB64url(await encryptBytes(kek, raw)); |
| 115 | } |
| 116 | |
| 117 | export async function unwrapDEK(wrappedB64, kek) { |
| 118 | const raw = await decryptBytes(kek, b64urlToBytes(wrappedB64)); |
| 119 | return crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]); |
| 120 | } |
| 121 | |
| 122 | // ---- fixed-size padding ----------------------------------------------------- |
| 123 | |
| 124 | // Every blob's plaintext is padded to exactly this many bytes before |
| 125 | // encryption, so the (server-visible) ciphertext length reveals nothing about |
| 126 | // how many servers are in the list or how big their entries are. |
| 127 | export const BLOB_PAD = 16384; |
| 128 | |
| 129 | // pad: 4-byte big-endian length prefix, then the bytes, then zeros to BLOB_PAD. |
| 130 | export function pad(bytes) { |
| 131 | if (bytes.length > BLOB_PAD - 4) throw new Error("blob contents too large"); |
| 132 | const out = new Uint8Array(BLOB_PAD); |
| 133 | new DataView(out.buffer).setUint32(0, bytes.length); |
| 134 | out.set(bytes, 4); |
| 135 | return out; |
| 136 | } |
| 137 | |
| 138 | export function unpad(bytes) { |
| 139 | const len = new DataView(bytes.buffer, bytes.byteOffset).getUint32(0); |
| 140 | return bytes.slice(4, 4 + len); |
| 141 | } |
| 142 | |
| 143 | // ---- the envelope ------------------------------------------------------------ |
| 144 | // |
| 145 | // Wire format (what PUT /api/blob stores, opaque to the server): |
| 146 | // "EHB1" (4) || u32 BE headerLen || header JSON || iv(12) || ciphertext |
| 147 | // header = { keys: { <credentialId>: <wrapped DEK b64url> } } — one entry per |
| 148 | // enrolled passkey. The header is in clear, but holds nothing the server |
| 149 | // doesn't already know (it stores the credentials) and nothing usable without |
| 150 | // an authenticator: wrapped DEKs are ciphertext under per-passkey KEKs. |
| 151 | |
| 152 | const MAGIC = new TextEncoder().encode("EHB1"); |
| 153 | |
| 154 | // sealBlob encrypts obj (padded) under dek and packs it with the wrapped-key |
| 155 | // header. keys is { credentialId: wrappedDEK }. |
| 156 | export async function sealBlob(obj, dek, keys) { |
| 157 | const pt = pad(new TextEncoder().encode(JSON.stringify(obj))); |
| 158 | const body = await encryptBytes(dek, pt); // iv||ct |
| 159 | const header = new TextEncoder().encode(JSON.stringify({ keys })); |
| 160 | const out = new Uint8Array(4 + 4 + header.length + body.length); |
| 161 | out.set(MAGIC, 0); |
| 162 | new DataView(out.buffer).setUint32(4, header.length); |
| 163 | out.set(header, 8); |
| 164 | out.set(body, 8 + header.length); |
| 165 | return out; |
| 166 | } |
| 167 | |
| 168 | // unpackBlob splits an envelope into { keys, body } without decrypting — used |
| 169 | // both to find our wrapped DEK and to re-seal preserving other passkeys' keys. |
| 170 | export function unpackBlob(bytes) { |
| 171 | for (let i = 0; i < 4; i++) { |
| 172 | if (bytes[i] !== MAGIC[i]) throw new Error("not a home blob"); |
| 173 | } |
| 174 | const headerLen = new DataView(bytes.buffer, bytes.byteOffset).getUint32(4); |
| 175 | const header = JSON.parse(new TextDecoder().decode(bytes.slice(8, 8 + headerLen))); |
| 176 | return { keys: header.keys || {}, body: bytes.slice(8 + headerLen) }; |
| 177 | } |
| 178 | |
| 179 | // openBlob decrypts an envelope with an already-unwrapped DEK. |
| 180 | export async function openBlob(bytes, dek) { |
| 181 | const { keys, body } = unpackBlob(bytes); |
| 182 | const pt = unpad(await decryptBytes(dek, body)); |
| 183 | return { obj: JSON.parse(new TextDecoder().decode(pt)), keys }; |
| 184 | } |
| 185 | |