eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// All message encryption happens in the browser. The AES-GCM key is carried in
2// the secret link's #fragment and never sent to the server. The server only
3// ever stores/relays the opaque blobs produced here.
4
5export function b64urlToBytes(s) {
6 s = s.replace(/-/g, "+").replace(/_/g, "/");
7 while (s.length % 4) s += "=";
8 const bin = atob(s);
9 const out = new Uint8Array(bin.length);
10 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
11 return out;
12}
13
14export function bytesToB64url(bytes) {
15 let bin = "";
16 for (const b of bytes) bin += String.fromCharCode(b);
17 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18}
19
20export async function generateKey() {
21 const key = await crypto.subtle.generateKey(
22 { name: "AES-GCM", length: 256 },
23 true,
24 ["encrypt", "decrypt"]
25 );
26 const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key));
27 return { key, b64: bytesToB64url(raw) };
28}
29
30export async function importKey(b64) {
31 return crypto.subtle.importKey(
32 "raw",
33 b64urlToBytes(b64),
34 "AES-GCM",
35 true,
36 ["encrypt", "decrypt"]
37 );
38}
39
40// encrypt returns base64url( iv(12) || ciphertext ).
41export async function encrypt(key, obj) {
42 const iv = crypto.getRandomValues(new Uint8Array(12));
43 const pt = new TextEncoder().encode(JSON.stringify(obj));
44 const ct = new Uint8Array(
45 await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, pt)
46 );
47 const combined = new Uint8Array(iv.length + ct.length);
48 combined.set(iv, 0);
49 combined.set(ct, iv.length);
50 return bytesToB64url(combined);
51}
52
53export async function decrypt(key, blob) {
54 const data = b64urlToBytes(blob);
55 const iv = data.slice(0, 12);
56 const ct = data.slice(12);
57 const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
58 return JSON.parse(new TextDecoder().decode(pt));
59}
60
61// Binary variants for file/image uploads. Returns/accepts raw iv(12)||ciphertext
62// bytes (Uint8Array) so attachments upload as octet-stream, not base64.
63export async function encryptBytes(key, bytes) {
64 const iv = crypto.getRandomValues(new Uint8Array(12));
65 const ct = new Uint8Array(
66 await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, bytes)
67 );
68 const out = new Uint8Array(iv.length + ct.length);
69 out.set(iv, 0);
70 out.set(ct, iv.length);
71 return out;
72}
73
74export async function decryptBytes(key, bytes) {
75 const iv = bytes.slice(0, 12);
76 const ct = bytes.slice(12);
77 const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
78 return new Uint8Array(pt);
79}
80
81// ---- invite key-wrapping (see docs/invite-redesign.md) ------------------
82// The invite secret T (base64url) derives a server lookup id + an AES wrapping
83// key via HKDF. The server only ever stores the id and the wrapped key — never
84// T — so it can't recover the group key.
85
86// A fresh 128-bit invite secret.
87export function randomSecret() {
88 return bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
89}
90
91async function hkdf(tB64, info) {
92 return crypto.subtle.importKey("raw", b64urlToBytes(tB64), "HKDF", false, ["deriveBits", "deriveKey"]).then((ikm) => ({
93 ikm,
94 params: { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode(info) },
95 }));
96}
97
98// sha256hex is the lowercase hex SHA-256 of a string's UTF-8 bytes — it matches
99// the server's hashToken, so a claimHash computed here verifies against the
100// claimSecret presented at claim time.
101async function sha256hex(str) {
102 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
103 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
104}
105
106// deriveInvite returns { id, wrapKey, claimSecret, claimHash, bindKey } from the
107// secret T, each domain-separated. claimSecret proves knowledge of T when
108// claiming; claimHash is what the inviter hands the server to store. bindKey is
109// the key-transparency binding key (#573): the joiner MACs its published public
110// key under it so a T-holder (the inviter) can verify the roster key wasn't
111// substituted (bindMac / verifyBindMac). The server keeps only id + claimHash,
112// so reading the DB isn't enough to claim OR to forge a bind-MAC — both need T.
113export async function deriveInvite(tB64) {
114 const idd = await hkdf(tB64, "lchat-invite-id");
115 const idBits = await crypto.subtle.deriveBits(idd.params, idd.ikm, 256);
116 const wkd = await hkdf(tB64, "lchat-invite-wrap");
117 const wrapKey = await crypto.subtle.deriveKey(
118 wkd.params, wkd.ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
119 );
120 const cld = await hkdf(tB64, "lchat-invite-claim");
121 const claimBits = await crypto.subtle.deriveBits(cld.params, cld.ikm, 256);
122 const claimSecret = bytesToB64url(new Uint8Array(claimBits));
123 const claimHash = await sha256hex(claimSecret);
124 const bkd = await hkdf(tB64, "lchat-invite-bind");
125 const bindBits = await crypto.subtle.deriveBits(bkd.params, bkd.ikm, 256);
126 const bindKey = bytesToB64url(new Uint8Array(bindBits));
127 return { id: bytesToB64url(new Uint8Array(idBits)), wrapKey, claimSecret, claimHash, bindKey };
128}
129
130// bindMac / verifyBindMac — the key-transparency binding (#573,
131// docs/tamper-evidence.md). The joiner signs its published public key string
132// with HMAC-SHA256 under bindKey (derived from the invite secret T); the server
133// stores the MAC beside the key and serves it in the roster. A client that
134// holds T for that invite (the inviter) recomputes and verifies before it
135// trusts/wraps to the member — catching a server that substituted its own key.
136// MAC over the DECODED public-key bytes, not its base64url string: the wrap path
137// only ever uses the raw key bytes, so a server that re-encodes the roster string
138// (padding, +/- vs -/_ — all tolerated by b64urlToBytes) changes the string but
139// not the key. MAC'ing the string would let that re-encoding break an honest
140// member's verification (a false-alarm / cry-wolf primitive); MAC'ing the bytes
141// makes the binding canonical.
142export async function bindMac(bindKeyB64, pubB64) {
143 const key = await crypto.subtle.importKey("raw", b64urlToBytes(bindKeyB64), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
144 const sig = await crypto.subtle.sign("HMAC", key, b64urlToBytes(pubB64));
145 return bytesToB64url(new Uint8Array(sig));
146}
147
148export async function verifyBindMac(bindKeyB64, pubB64, macB64) {
149 if (!bindKeyB64 || !pubB64 || !macB64) return false;
150 try {
151 const key = await crypto.subtle.importKey("raw", b64urlToBytes(bindKeyB64), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
152 return await crypto.subtle.verify("HMAC", key, b64urlToBytes(macB64), b64urlToBytes(pubB64));
153 } catch {
154 return false; // malformed MAC/key never throws — a bad MAC is a "false", surfaced by the caller
155 }
156}
157
158// wrap/unwrap the group key (base64url) under a derived wrapping CryptoKey.
159export async function wrapGroupKey(groupKeyB64, wrapKey) {
160 return bytesToB64url(await encryptBytes(wrapKey, b64urlToBytes(groupKeyB64)));
161}
162export async function unwrapGroupKey(wrappedB64, wrapKey) {
163 return bytesToB64url(await decryptBytes(wrapKey, b64urlToBytes(wrappedB64)));
164}
165
166// ---- user keypairs + thread-key wrapping (see docs/threads-and-dms.md) --
167// Each person has an ECDH P-256 keypair; the public key lives in the roster. A
168// thread's AES key is wrapped to each member's user public key (ECIES: ephemeral
169// ECDH -> HKDF -> AES-GCM), so the server stores only blobs it can't open. The
170// private key exports as pkcs8 so it can ride an add-device link to a new device.
171
172const EC = { name: "ECDH", namedCurve: "P-256" };
173
174// generateUserKeypair returns { publicKey, privateKey } as base64url
175// (raw point / pkcs8). The public key goes in the roster; the private key stays
176// in the session (and is shipped, wrapped, when adding a device).
177export async function generateUserKeypair() {
178 const kp = await crypto.subtle.generateKey(EC, true, ["deriveBits"]);
179 const pub = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
180 const priv = new Uint8Array(await crypto.subtle.exportKey("pkcs8", kp.privateKey));
181 return { publicKey: bytesToB64url(pub), privateKey: bytesToB64url(priv) };
182}
183
184// Derive the AES-GCM wrapping key shared by one ECDH private + one public key.
185async function eciesKey(privKey, pubKey) {
186 const bits = await crypto.subtle.deriveBits({ name: "ECDH", public: pubKey }, privKey, 256);
187 const ikm = await crypto.subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
188 return crypto.subtle.deriveKey(
189 { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("lchat-thread-wrap") },
190 ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
191 );
192}
193
194// wrapThreadKey wraps a thread's AES key (base64url raw) to a recipient's user
195// public key. Returns base64url( ephPub(65) || iv(12) || ciphertext ).
196export async function wrapThreadKey(threadKeyB64, recipientPubB64) {
197 const recipient = await crypto.subtle.importKey("raw", b64urlToBytes(recipientPubB64), EC, false, []);
198 const eph = await crypto.subtle.generateKey(EC, true, ["deriveBits"]);
199 const aes = await eciesKey(eph.privateKey, recipient);
200 const body = await encryptBytes(aes, b64urlToBytes(threadKeyB64)); // iv||ct
201 const ephPub = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey));
202 const out = new Uint8Array(ephPub.length + body.length);
203 out.set(ephPub, 0);
204 out.set(body, ephPub.length);
205 return bytesToB64url(out);
206}
207
208// unwrapThreadKey recovers the thread key (base64url raw) using your user
209// private key (base64url pkcs8).
210export async function unwrapThreadKey(wrappedB64, privKeyB64) {
211 const buf = b64urlToBytes(wrappedB64);
212 const ephPub = await crypto.subtle.importKey("raw", buf.slice(0, 65), EC, false, []);
213 const priv = await crypto.subtle.importKey("pkcs8", b64urlToBytes(privKeyB64), EC, false, ["deriveBits"]);
214 const aes = await eciesKey(priv, ephPub);
215 return bytesToB64url(await decryptBytes(aes, buf.slice(65)));
216}
217
218// ---- capability grants (see docs/connected-services.md) -----------------
219// sealForPub / openSealed are wrapThreadKey's sibling for arbitrary JSON: the
220// same ECIES-to-a-public-key wire shape (ephPub(65) || iv(12) || ciphertext),
221// but the sealed payload is a small object rather than a bare key. Binding
222// metadata *inside* the seal (e.g. an `exp` expiry) means the relaying server
223// can't extend or alter it — it only ever holds the opaque blob. Used to hand a
224// bot a time-boxed capability (a third-party token, or a data key) that only
225// that bot's private key can open.
226
227// sealForPub seals `obj` (JSON-serialisable) to a recipient's user public key.
228export async function sealForPub(recipientPubB64, obj) {
229 const recipient = await crypto.subtle.importKey("raw", b64urlToBytes(recipientPubB64), EC, false, []);
230 const eph = await crypto.subtle.generateKey(EC, true, ["deriveBits"]);
231 const aes = await eciesKey(eph.privateKey, recipient);
232 const body = await encryptBytes(aes, new TextEncoder().encode(JSON.stringify(obj))); // iv||ct
233 const ephPub = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey));
234 const out = new Uint8Array(ephPub.length + body.length);
235 out.set(ephPub, 0);
236 out.set(body, ephPub.length);
237 return bytesToB64url(out);
238}
239
240// openSealed opens a sealForPub blob with your user private key (base64url
241// pkcs8), returning the object. Throws if it wasn't sealed to your key.
242export async function openSealed(sealedB64, privKeyB64) {
243 const buf = b64urlToBytes(sealedB64);
244 const ephPub = await crypto.subtle.importKey("raw", buf.slice(0, 65), EC, false, []);
245 const priv = await crypto.subtle.importKey("pkcs8", b64urlToBytes(privKeyB64), EC, false, ["deriveBits"]);
246 const aes = await eciesKey(priv, ephPub);
247 const pt = await decryptBytes(aes, buf.slice(65));
248 return JSON.parse(new TextDecoder().decode(pt));
249}
250
251// ---- OAuth PKCE (see docs/connected-services.md) ------------------------
252// pkcePair mints a one-time PKCE verifier + its S256 challenge for an OAuth
253// authorization-code flow with a *public* client (no client secret). The
254// verifier stays in the browser and is presented only at the token exchange;
255// the challenge rides in the authorize URL. This is what lets a connected
256// service be authorized with no pasted key and no stored secret.
257export async function pkcePair() {
258 const verifier = bytesToB64url(crypto.getRandomValues(new Uint8Array(32))); // 43-char base64url
259 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
260 return { verifier, challenge: bytesToB64url(digest) };
261}
262
263// ---- the branding key (docs/no-plaintext.md) ------------------------------
264//
265// A per-instance key B, one trust rung below the instance key: it rides share
266// links' #fragments (never sent to the server) and unlocks the sealed branding
267// blob — the space's name, description, photo, and admin identity. B is short
268// by design (bulk-exposure protection, not targeted secrecy — the doc has the
269// honest math), so derivation is deliberately stretched with PBKDF2, the one
270// stretching KDF native to both WebCrypto and CommonCrypto (no-build-step rule
271// rules out WASM Argon2). Clients cache the derived key per session.
272
273export const BRANDING_KEY_CHARS = 10; // lowercase base32 → 50 bits
274export const BRANDING_KDF_ITERATIONS = 600_000;
275
276// mintBrandingKey() -> e.g. "k3m7p2qx4f". Uniform over the base32 alphabet
277// via rejection sampling (no modulo bias).
278export function mintBrandingKey() {
279 const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
280 let out = "";
281 while (out.length < BRANDING_KEY_CHARS) {
282 const [byte] = crypto.getRandomValues(new Uint8Array(1));
283 if (byte < 224) out += alphabet[byte % 32]; // 224 = 7×32: reject the biased tail
284 }
285 return out;
286}
287
288// deriveBrandingKey(b, saltB64) -> AES-GCM CryptoKey usable with the
289// encrypt/decrypt/encryptBytes/decryptBytes helpers above.
290export async function deriveBrandingKey(b, saltB64) {
291 const ikm = await crypto.subtle.importKey("raw", new TextEncoder().encode(b), "PBKDF2", false, [
292 "deriveKey",
293 ]);
294 return crypto.subtle.deriveKey(
295 { name: "PBKDF2", hash: "SHA-256", salt: b64urlToBytes(saltB64), iterations: BRANDING_KDF_ITERATIONS },
296 ikm,
297 { name: "AES-GCM", length: 256 },
298 false,
299 ["encrypt", "decrypt"],
300 );
301}
302