Answers 'how do you get keys to login elsewhere' — both mechanisms, Signal/WhatsApp style: Server: - migration 0002: key_backups (recovery blobs) + provisions (QR relay), ciphertext only - routes/backup.js: POST/GET /api/backup, /backup/exists; provision new/get/complete - routes/keys.js: POST /api/keys/reset — atomically wipe stale prekeys + install fresh (fixes Bad MAC: restore regenerates prekey IDs that INSERT-OR-IGNORE kept stale) Crypto (crypto.js): - exportIdentityBlob/importIdentityBlob (identity travels only encrypted) - generateRecoveryCode (120-bit Crockford base32) + encrypt/decryptWithCode (PBKDF2 250k) - ephemeral ECDH (P-256) encrypt/decrypt for QR transfer Client (app.js): account-menu 'Set up recovery code' + 'Link a device' (camera scan via BarcodeDetector or paste); auth-screen 'Restore with recovery code' + 'Link from another device' (shows QR + code, polls, restores). Vendored qrcode-generator. All verified live end-to-end (3-process isolated-store integration tests): restore-from-code and QR-link both let a fresh device receive + decrypt messages. SW cache v3->v4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
3.8 KiB
JavaScript
120 lines
3.8 KiB
JavaScript
// Thin client for the Cipher Worker API + the mailbox WebSocket.
|
|
|
|
let token = null;
|
|
export function setToken(t) {
|
|
token = t;
|
|
}
|
|
|
|
async function req(method, path, body, raw = false) {
|
|
const headers = {};
|
|
if (token) headers["Authorization"] = "Bearer " + token;
|
|
const opts = { method, headers };
|
|
if (body !== undefined) {
|
|
if (raw) {
|
|
opts.body = body; // ArrayBuffer / Blob
|
|
} else {
|
|
headers["Content-Type"] = "application/json";
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
}
|
|
const res = await fetch(path, opts);
|
|
if (!res.ok) {
|
|
let msg = res.statusText;
|
|
try {
|
|
msg = (await res.json()).error || msg;
|
|
} catch {}
|
|
throw new Error(msg);
|
|
}
|
|
const ct = res.headers.get("Content-Type") || "";
|
|
return ct.includes("application/json") ? res.json() : res;
|
|
}
|
|
|
|
export const api = {
|
|
register: (payload) => req("POST", "/api/auth/register", payload),
|
|
login: (payload) => req("POST", "/api/auth/login", payload),
|
|
lookup: (username) => req("GET", "/api/users/" + encodeURIComponent(username)),
|
|
bundle: (userId) => req("GET", "/api/keys/bundle/" + userId),
|
|
keyCount: () => req("GET", "/api/keys/count"),
|
|
addPreKeys: (oneTimePreKeys) => req("POST", "/api/keys/prekeys", { oneTimePreKeys }),
|
|
resetKeys: (signedPreKey, oneTimePreKeys) =>
|
|
req("POST", "/api/keys/reset", { signedPreKey, oneTimePreKeys }),
|
|
send: (toUserId, messages) => req("POST", "/api/messages/send", { toUserId, messages }),
|
|
|
|
// recovery backup (server stores/serves only ciphertext)
|
|
putBackup: (payload) => req("POST", "/api/backup", { payload }),
|
|
getBackup: () => req("GET", "/api/backup"),
|
|
backupExists: () => req("GET", "/api/backup/exists"),
|
|
|
|
// QR provisioning relay
|
|
provisionNew: (ephemeralPub) => req("POST", "/api/provision/new", { ephemeralPub }),
|
|
provisionGet: (id) => req("GET", "/api/provision/" + id),
|
|
provisionComplete: (id, payload) => req("POST", "/api/provision/" + id + "/complete", { payload }),
|
|
uploadMedia: async (ciphertext) => {
|
|
const res = await req("POST", "/api/media", ciphertext, true);
|
|
return res.json ? res.json() : res;
|
|
},
|
|
fetchMedia: async (key) => {
|
|
const res = await req("GET", "/api/media/" + key);
|
|
return res.arrayBuffer();
|
|
},
|
|
};
|
|
|
|
// ---- WebSocket with auto-reconnect + ack ----
|
|
export class MailboxSocket {
|
|
constructor(tok, onEnvelope) {
|
|
this.tok = tok;
|
|
this.onEnvelope = onEnvelope;
|
|
this.ws = null;
|
|
this.backoff = 1000;
|
|
this.closed = false;
|
|
this.pending = []; // envelope ids awaiting ack batch
|
|
}
|
|
|
|
connect() {
|
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
this.ws = new WebSocket(`${proto}//${location.host}/api/ws?token=${encodeURIComponent(this.tok)}`);
|
|
this.ws.onopen = () => {
|
|
this.backoff = 1000;
|
|
this.ws.send(JSON.stringify({ t: "pull" }));
|
|
};
|
|
this.ws.onmessage = async (ev) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(ev.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (msg.t === "message" && msg.envelope) {
|
|
try {
|
|
await this.onEnvelope(msg.envelope);
|
|
this.ack(msg.envelope.id);
|
|
} catch (e) {
|
|
console.error("envelope handling failed", e);
|
|
}
|
|
}
|
|
};
|
|
this.ws.onclose = () => {
|
|
if (this.closed) return;
|
|
setTimeout(() => this.connect(), this.backoff);
|
|
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
};
|
|
this.ws.onerror = () => this.ws && this.ws.close();
|
|
}
|
|
|
|
ack(id) {
|
|
// Batch acks briefly to reduce chatter.
|
|
this.pending.push(id);
|
|
clearTimeout(this._ackTimer);
|
|
this._ackTimer = setTimeout(() => {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.pending.length) {
|
|
this.ws.send(JSON.stringify({ t: "ack", ids: this.pending }));
|
|
this.pending = [];
|
|
}
|
|
}, 150);
|
|
}
|
|
|
|
close() {
|
|
this.closed = true;
|
|
if (this.ws) this.ws.close();
|
|
}
|
|
}
|