eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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
9export 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
18export 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.
26export 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
35export 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
50const EC = { name: "ECDH", namedCurve: "P-256" };
51
52async 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.
64export 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.
81export 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).
87export 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.
99export async function generateDEK() {
100 return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
101}
102
103export async function exportDEK(dek) {
104 return bytesToB64url(new Uint8Array(await crypto.subtle.exportKey("raw", dek)));
105}
106
107export 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).
112export 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
117export 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.
127export const BLOB_PAD = 16384;
128
129// pad: 4-byte big-endian length prefix, then the bytes, then zeros to BLOB_PAD.
130export 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
138export 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
152const 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 }.
156export 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.
170export 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.
180export 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