// Cipher client orchestration: auth, contacts, chat, media. Ties crypto + api + UI. import { api, setToken, MailboxSocket } from "./api.js"; import * as C from "./crypto.js"; import { useAccount, contacts, messages as msgStore } from "./store.js"; import * as accounts from "./accounts.js"; import qrcode from "./vendor/qrcode.js"; const $ = (sel) => document.querySelector(sel); const el = (tag, props = {}, ...children) => { const n = Object.assign(document.createElement(tag), props); for (const c of children) n.append(c); return n; }; const state = { me: null, // { userId, deviceId, username, displayName } token: null, socket: null, activeContact: null, // contact object }; // ---------- boot ---------- async function boot() { const saved = accounts.getActive(); if (saved?.token) { await activateAccount(saved); } else { renderAuth(); } } // Switch the whole app to a given account: point the encrypted store at that // account's namespace, set auth, and (re)enter the app. async function activateAccount(acct) { if (state.socket) { state.socket.close(); state.socket = null; } state.me = acct; state.token = acct.token; state.activeContact = null; useAccount(acct.username); // per-account IndexedDB namespace setToken(acct.token); accounts.setActive(acct.username); await enterApp(); } // ---------- auth ---------- function renderAuth() { const root = $("#app"); root.innerHTML = ""; const form = el("form", { className: "auth-card" }); const title = el("h1", { textContent: "Cipher" }); const tag = el("p", { className: "tagline", textContent: "end-to-end encrypted messaging" }); const err = el("div", { className: "auth-err" }); const username = el("input", { placeholder: "username", autocomplete: "username", required: true }); const displayName = el("input", { placeholder: "display name (register only)", autocomplete: "name" }); const password = el("input", { type: "password", placeholder: "password", autocomplete: "current-password", required: true }); const loginBtn = el("button", { type: "submit", className: "primary", textContent: "Log in" }); const registerBtn = el("button", { type: "button", className: "ghost", textContent: "Create account" }); const divider = el("div", { className: "auth-divider", textContent: "new device?" }); const restoreBtn = el("button", { type: "button", className: "link-btn", textContent: "Restore with recovery code", onclick: (e) => { e.preventDefault(); startRecoveryRestore(); } }); const qrLinkBtn = el("button", { type: "button", className: "link-btn", textContent: "Link from another device", onclick: (e) => { e.preventDefault(); startQrLink(); } }); form.append(title, tag, err, username, displayName, password, loginBtn, registerBtn, divider, restoreBtn, qrLinkBtn); root.append(form); form.onsubmit = async (e) => { e.preventDefault(); err.textContent = ""; const uname = username.value.trim().toLowerCase(); try { loginBtn.disabled = true; // Point the store at this account BEFORE anything touches it, so the keys // this browser generated at registration are found under the right namespace. useAccount(uname); const res = await api.login({ username: uname, password: password.value }); const identity = await C.store.getIdentityKeyPair(); if (!identity) { err.textContent = "No keys for @" + uname + " on this device. Log in on the device where you created the account (multi-device linking is coming)."; loginBtn.disabled = false; return; } await onAuthenticated(res); } catch (ex) { err.textContent = ex.message; } finally { loginBtn.disabled = false; } }; registerBtn.onclick = async () => { err.textContent = ""; const uname = username.value.trim().toLowerCase(); if (!uname || password.value.length < 8) { err.textContent = "username + 8-char password required"; return; } if (accounts.list().some((a) => a.username === uname)) { err.textContent = "@" + uname + " already signed in on this browser — use Log in / Switch"; return; } try { registerBtn.disabled = true; registerBtn.textContent = "Generating keys…"; useAccount(uname); // namespace the store before generating/persisting keys const device = await C.generateRegistration(); const res = await api.register({ username: uname, displayName: displayName.value || uname, password: password.value, device, }); await onAuthenticated(res); } catch (ex) { err.textContent = ex.message; } finally { registerBtn.disabled = false; registerBtn.textContent = "Create account"; } }; } async function onAuthenticated(res) { const acct = { userId: res.userId, deviceId: res.deviceId, username: res.username, displayName: res.displayName || res.username, token: res.token, }; accounts.save(acct); accounts.setActive(acct.username); await activateAccount(acct); } // ---------- main app ---------- async function enterApp() { renderShell(); await refreshContacts(); connectSocket(); maybeReplenishPreKeys(); registerServiceWorker(); } function renderShell() { const root = $("#app"); root.innerHTML = ""; const shell = el("div", { className: "shell" }); // Sidebar const sidebar = el("aside", { className: "sidebar" }); const header = el("div", { className: "side-header", title: "Accounts", onclick: openAccountMenu }); header.append( el("div", { className: "me" }, document.createTextNode(state.me.displayName || state.me.username), el("span", { className: "caret", textContent: " ▾" }) ), el("div", { className: "handle", textContent: "@" + state.me.username }) ); const newChat = el("button", { className: "new-chat", textContent: "+ New chat", onclick: startNewChat }); const list = el("div", { className: "chat-list", id: "chatList" }); sidebar.append(header, newChat, list); // Main pane const main = el("section", { className: "main", id: "main" }); main.append(el("div", { className: "empty", textContent: "Select a chat or start a new one" })); shell.append(sidebar, main); root.append(shell); } async function refreshContacts() { const list = $("#chatList"); if (!list) return; list.innerHTML = ""; const all = await contacts.all(); for (const c of all) { const item = el("div", { className: "chat-item", onclick: () => openChat(c) }); item.append( el("div", { className: "avatar", textContent: (c.displayName || c.username)[0].toUpperCase() }), el("div", { className: "chat-meta" }, el("div", { className: "chat-name", textContent: c.displayName || c.username }), el("div", { className: "chat-sub", textContent: "@" + c.username }) ) ); list.append(item); } } function startNewChat() { openModal({ title: "New chat", placeholder: "Enter a username", action: "Start chat", onSubmit: async (value, setError, close) => { const uname = value.trim().toLowerCase(); if (!uname) return setError("Enter a username"); if (uname === state.me.username) return setError("That's you 🙂"); try { const user = await api.lookup(uname); const contact = { userId: user.userId, username: user.username, displayName: user.displayName, deviceIds: [], }; await contacts.put(contact); await refreshContacts(); close(); await openChat(contact); } catch (ex) { setError(ex.message === "not found" ? "No user found: @" + uname : ex.message); } }, }); } // Lightweight in-app modal (replaces window.prompt, which Brave/PWAs suppress). function openModal({ title, placeholder, action, onSubmit }) { const input = el("input", { className: "modal-input", placeholder, autocomplete: "off" }); const err = el("div", { className: "modal-err" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const submit = el("button", { type: "button", className: "primary", textContent: action }); const card = el("div", { className: "modal-card" }, el("h2", { className: "modal-title", textContent: title }), input, err, el("div", { className: "modal-actions" }, cancel, submit) ); const overlay = el("div", { className: "modal-overlay" }, card); document.body.append(overlay); setTimeout(() => input.focus(), 0); const close = () => overlay.remove(); const setError = (m) => { err.textContent = m; }; const go = async () => { submit.disabled = true; setError(""); try { await onSubmit(input.value, setError, close); } finally { submit.disabled = false; } }; cancel.onclick = close; submit.onclick = go; overlay.onclick = (e) => { if (e.target === overlay) close(); }; input.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); go(); } if (e.key === "Escape") close(); }; } // ---------- overlay + toast helpers ---------- function makeOverlay(...children) { const card = el("div", { className: "modal-card" }, ...children); const overlay = el("div", { className: "modal-overlay" }, card); overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; document.body.append(overlay); return { overlay, card, close: () => overlay.remove() }; } function encodeLink(obj) { return btoa(JSON.stringify(obj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function decodeLink(s) { const b = s.trim().replace(/-/g, "+").replace(/_/g, "/"); return JSON.parse(atob(b)); } function qrImg(text) { const qr = qrcode(0, "M"); qr.addData(text); qr.make(); const img = el("img", { className: "qr", alt: "linking QR code" }); img.src = qr.createDataURL(6, 12); return img; } // ---------- recovery code: set up backup ---------- async function setupRecovery() { let code; try { const blob = await C.exportIdentityBlob(state.me); code = C.generateRecoveryCode(); const payload = await C.encryptWithCode(blob, code); await api.putBackup(payload); } catch (ex) { toast("Couldn't set up recovery: " + ex.message); return; } const codeBox = el("div", { className: "code-box", textContent: code }); const copy = el("button", { type: "button", className: "primary", textContent: "Copy code", onclick: () => { navigator.clipboard?.writeText(code); copy.textContent = "Copied ✓"; }, }); const done = el("button", { type: "button", className: "ghost", textContent: "I've saved it" }); const { close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Your recovery code" }), el("p", { className: "modal-note", textContent: "Save this somewhere safe. It's the ONLY way to restore your account on a new device if you lose this one. We can't recover it for you." }), codeBox, el("div", { className: "modal-actions" }, copy, done) ); done.onclick = close; } // ---------- recovery code: restore on a new device ---------- function startRecoveryRestore() { const uname = el("input", { className: "modal-input", placeholder: "username", autocomplete: "username" }); const pass = el("input", { type: "password", className: "modal-input", placeholder: "password", autocomplete: "current-password" }); const code = el("input", { className: "modal-input", placeholder: "recovery code (XXXX-XXXX-…)" }); const err = el("div", { className: "modal-err" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const go = el("button", { type: "button", className: "primary", textContent: "Restore" }); const { close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Restore with recovery code" }), el("p", { className: "modal-note", textContent: "Enter your login and the recovery code you saved when you set up this account." }), uname, pass, code, err, el("div", { className: "modal-actions" }, cancel, go) ); cancel.onclick = close; go.onclick = async () => { err.textContent = ""; const u = uname.value.trim().toLowerCase(); if (!u || !pass.value || !code.value) { err.textContent = "All fields required"; return; } go.disabled = true; go.textContent = "Restoring…"; try { useAccount(u); const res = await api.login({ username: u, password: pass.value }); setToken(res.token); const { payload } = await api.getBackup(); let blob; try { blob = await C.decryptWithCode(payload, code.value); } catch { throw new Error("Wrong recovery code"); } const fresh = await C.importIdentityBlob(blob); await api.resetKeys(fresh.signedPreKey, fresh.oneTimePreKeys); close(); await onAuthenticated(res); toast("Account restored on this device", "info"); } catch (ex) { err.textContent = ex.message === "no backup" ? "No recovery backup found for that account" : ex.message; go.disabled = false; go.textContent = "Restore"; } }; } // ---------- QR linking: authorize a new device (from the signed-in device) ---------- function linkDeviceAuthorizer() { const paste = el("textarea", { className: "modal-input", placeholder: "Paste the linking code from your new device", rows: 3 }); const err = el("div", { className: "modal-err" }); const status = el("div", { className: "modal-note" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const approve = el("button", { type: "button", className: "primary", textContent: "Approve link" }); const scanBtn = el("button", { type: "button", className: "ghost", textContent: "📷 Scan QR" }); const scanArea = el("div", {}); const { close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Link a device" }), el("p", { className: "modal-note", textContent: "On your new device choose “Link from another device”, then scan its QR here or paste its code." }), scanBtn, scanArea, paste, status, err, el("div", { className: "modal-actions" }, cancel, approve) ); cancel.onclick = close; const doApprove = async (linkText) => { err.textContent = ""; let link; try { link = decodeLink(linkText); } catch { err.textContent = "Invalid linking code"; return; } if (!link.p || !link.k) { err.textContent = "Invalid linking code"; return; } approve.disabled = true; status.textContent = "Approving…"; try { const blob = { ...(await C.exportIdentityBlob(state.me)), token: state.me.token }; const payload = await C.encryptToEphemeral(blob, link.k); await api.provisionComplete(link.p, payload); close(); toast("Device linked ✓", "info"); } catch (ex) { err.textContent = ex.message; approve.disabled = false; status.textContent = ""; } }; approve.onclick = () => doApprove(paste.value); scanBtn.onclick = async () => { if (!("BarcodeDetector" in window)) { err.textContent = "Camera scanning not supported here — paste the code instead"; return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); const video = el("video", { className: "qr-video", autoplay: true, playsInline: true, muted: true }); video.srcObject = stream; scanArea.innerHTML = ""; scanArea.append(video); const detector = new BarcodeDetector({ formats: ["qr_code"] }); const tick = async () => { if (!video.isConnected) { stream.getTracks().forEach((t) => t.stop()); return; } try { const codes = await detector.detect(video); if (codes.length) { stream.getTracks().forEach((t) => t.stop()); scanArea.innerHTML = ""; await doApprove(codes[0].rawValue); return; } } catch {} requestAnimationFrame(tick); }; requestAnimationFrame(tick); } catch { err.textContent = "Couldn't open camera — paste the code instead"; } }; } // ---------- QR linking: get linked (on the new device) ---------- async function startQrLink() { let eph, provisionId; const status = el("div", { className: "modal-note", textContent: "Generating link…" }); const codeText = el("div", { className: "code-box small" }); const copy = el("button", { type: "button", className: "ghost", textContent: "Copy code" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const qrHolder = el("div", {}); const { overlay, close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Link from another device" }), el("p", { className: "modal-note", textContent: "On a device already signed in, choose “Link a device” and scan this QR (or paste the code)." }), qrHolder, status, codeText, el("div", { className: "modal-actions" }, copy, cancel) ); let cancelled = false; cancel.onclick = () => { cancelled = true; close(); }; try { eph = await C.generateEphemeralKeyPair(); const res = await api.provisionNew(eph.pubB64); provisionId = res.provisionId; } catch (ex) { status.textContent = "Couldn't start linking: " + ex.message; return; } const linkCode = encodeLink({ p: provisionId, k: eph.pubB64 }); qrHolder.append(qrImg(linkCode)); codeText.textContent = linkCode; status.textContent = "Waiting for approval…"; copy.onclick = () => { navigator.clipboard?.writeText(linkCode); copy.textContent = "Copied ✓"; }; // poll for the encrypted blob for (let i = 0; i < 150 && !cancelled; i++) { await new Promise((r) => setTimeout(r, 1200)); let got; try { got = await api.provisionGet(provisionId); } catch { continue; } if (got?.payload) { status.textContent = "Approved — restoring…"; try { const blob = await C.decryptFromEphemeral(got.payload, eph.keyPair); useAccount(blob.username); setToken(blob.token); const fresh = await C.importIdentityBlob(blob); await api.resetKeys(fresh.signedPreKey, fresh.oneTimePreKeys); close(); await onAuthenticated({ userId: blob.userId, deviceId: blob.deviceId, username: blob.username, displayName: blob.displayName, token: blob.token, }); toast("Device linked ✓", "info"); } catch (ex) { status.textContent = "Link failed: " + ex.message; } return; } } if (!cancelled) status.textContent = "Link timed out — try again"; } // ---------- account menu (switch / add / log out) ---------- function openAccountMenu() { const all = accounts.list(); const rows = []; for (const a of all) { const active = a.username === state.me.username; rows.push( el("button", { type: "button", className: "acct-row" + (active ? " active" : ""), textContent: (active ? "● " : "○ ") + "@" + a.username, onclick: async () => { overlay.remove(); if (!active) await activateAccount(a); }, }) ); } const recoveryBtn = el("button", { type: "button", className: "acct-action", textContent: "🔑 Set up recovery code", onclick: () => { overlay.remove(); setupRecovery(); }, }); const linkBtn = el("button", { type: "button", className: "acct-action", textContent: "🔗 Link a device", onclick: () => { overlay.remove(); linkDeviceAuthorizer(); }, }); const addBtn = el("button", { type: "button", className: "acct-action", textContent: "+ Add another account", onclick: () => { overlay.remove(); renderAuth(); }, }); const logoutBtn = el("button", { type: "button", className: "acct-action danger", textContent: "Log out @" + state.me.username, onclick: async () => { overlay.remove(); accounts.remove(state.me.username); if (state.socket) { state.socket.close(); state.socket = null; } const remaining = accounts.list(); if (remaining.length) await activateAccount(remaining[0]); else renderAuth(); }, }); const card = el("div", { className: "modal-card" }, el("h2", { className: "modal-title", textContent: "Accounts" }), ...rows, recoveryBtn, linkBtn, addBtn, logoutBtn ); const overlay = el("div", { className: "modal-overlay" }, card); overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; document.body.append(overlay); } // ---------- toast (surface errors instead of failing silently) ---------- function toast(message, kind = "error") { const t = el("div", { className: "toast " + kind, textContent: message }); let host = $("#toasts"); if (!host) { host = el("div", { id: "toasts" }); document.body.append(host); } host.append(t); setTimeout(() => t.remove(), 4000); } // ---------- chat view ---------- async function openChat(contact) { state.activeContact = contact; $(".shell")?.classList.add("chat-open"); const main = $("#main"); main.innerHTML = ""; const back = el("button", { className: "back-btn", textContent: "‹", title: "Back", onclick: () => { state.activeContact = null; $(".shell")?.classList.remove("chat-open"); }, }); const head = el("div", { className: "chat-head" }); head.append( back, el("div", { className: "avatar", textContent: (contact.displayName || contact.username)[0].toUpperCase() }), el("div", {}, el("div", { className: "chat-name", textContent: contact.displayName || contact.username }), el("div", { className: "chat-sub", textContent: "🔒 end-to-end encrypted" }) ) ); const thread = el("div", { className: "thread", id: "thread" }); const composer = el("form", { className: "composer" }); const input = el("input", { placeholder: "Type a message", autocomplete: "off" }); const attach = el("label", { className: "attach", textContent: "📎" }); const fileInput = el("input", { type: "file", style: "display:none" }); attach.append(fileInput); const sendBtn = el("button", { type: "submit", className: "send", textContent: "Send" }); composer.append(attach, input, sendBtn); main.append(head, thread, composer); // load history const convo = state.me.userId + ":" + contact.userId; const history = await msgStore.forConvo(convo); for (const m of history) renderMessage(m); scrollThread(); composer.onsubmit = async (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; input.value = ""; try { await sendText(contact, text); } catch (ex) { console.error("send failed", ex); toast("Couldn't send: " + ex.message); input.value = text; // restore so the message isn't lost } }; fileInput.onchange = async () => { const file = fileInput.files[0]; if (file) { try { await sendMedia(contact, file); } catch (ex) { console.error("media send failed", ex); toast("Couldn't send file: " + ex.message); } } fileInput.value = ""; }; } function convoKey(otherUserId) { return state.me.userId + ":" + otherUserId; } async function ensureRecipientDevices(contact) { const bundle = await api.bundle(contact.userId); contact.deviceIds = bundle.bundles.map((b) => b.deviceId); await contacts.put(contact); await C.ensureSessions(bundle); return contact.deviceIds; } async function sendText(contact, text) { // structured payload lets us carry media refs in the same E2E channel const payload = JSON.stringify({ k: "text", text }); await deliver(contact, payload); await persistAndRender(contact.userId, { dir: "out", kind: "text", text, ts: Date.now() }); } async function sendMedia(contact, file) { const buf = await file.arrayBuffer(); const enc = await C.encryptMedia(buf); const { key } = await api.uploadMedia(enc.ciphertext); const payload = JSON.stringify({ k: "media", key, keyB64: enc.keyB64, ivB64: enc.ivB64, name: file.name, type: file.type, size: file.size, }); await deliver(contact, payload); await persistAndRender(contact.userId, { dir: "out", kind: "media", name: file.name, mime: file.type, localUrl: URL.createObjectURL(file), ts: Date.now(), }); } async function deliver(contact, plaintextPayload) { let deviceIds = contact.deviceIds; if (!deviceIds || !deviceIds.length) deviceIds = await ensureRecipientDevices(contact); const messages = await C.encryptForUser(contact.userId, deviceIds, plaintextPayload); await api.send(contact.userId, messages); } // ---------- receiving ---------- function connectSocket() { if (state.socket) state.socket.close(); state.socket = new MailboxSocket(state.token, onEnvelope); state.socket.connect(); } async function onEnvelope(envelope) { // Look up / create the sender contact. let contact = await contactByUserId(envelope.fromUserId); let plaintext; try { plaintext = await C.decryptEnvelope(envelope, envelope.fromUserId); } catch (ex) { console.error("decrypt failed", ex); toast("Couldn't decrypt a message (session mismatch)"); throw ex; // leave it unacked so it can retry after the session heals } let data; try { data = JSON.parse(plaintext); } catch { data = { k: "text", text: plaintext }; } if (!contact) { // Unknown sender — create a stub contact so the message has a home. contact = { userId: envelope.fromUserId, username: envelope.fromUserId.slice(0, 8), displayName: "New contact", deviceIds: [] }; await contacts.put(contact); await refreshContacts(); } if (data.k === "media") { await persistAndRender(envelope.fromUserId, { dir: "in", kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 }, ts: envelope.sentAt || Date.now(), }); } else { await persistAndRender(envelope.fromUserId, { dir: "in", kind: "text", text: data.text, ts: envelope.sentAt || Date.now() }); } } async function contactByUserId(userId) { return contacts.get(userId); } // ---------- persistence + render ---------- async function persistAndRender(otherUserId, msg) { const record = { ...msg, convo: convoKey(otherUserId), otherUserId }; await msgStore.add(record); if (state.activeContact && state.activeContact.userId === otherUserId) { renderMessage(record); scrollThread(); } } function renderMessage(m) { const thread = $("#thread"); if (!thread) return; const bubble = el("div", { className: "bubble " + (m.dir === "out" ? "out" : "in") }); if (m.kind === "media") { if (m.mime && m.mime.startsWith("image/")) { const img = el("img", { className: "media-img", alt: m.name || "image" }); if (m.localUrl) img.src = m.localUrl; else if (m.mediaRef) loadMediaInto(img, m.mediaRef); bubble.append(img); } else { const link = el("a", { className: "media-file", textContent: "📄 " + (m.name || "file") }); if (m.mediaRef) makeDownloadable(link, m.mediaRef, m.name); bubble.append(link); } } else { bubble.append(el("div", { className: "text", textContent: m.text })); } bubble.append(el("div", { className: "time", textContent: fmtTime(m.ts) })); thread.append(bubble); } async function loadMediaInto(img, ref) { try { const ct = await api.fetchMedia(ref.key); const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64); img.src = URL.createObjectURL(new Blob([plain])); } catch (e) { img.alt = "failed to load media"; } } async function makeDownloadable(link, ref, name) { link.onclick = async (e) => { e.preventDefault(); const ct = await api.fetchMedia(ref.key); const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64); const url = URL.createObjectURL(new Blob([plain])); const a = el("a", { href: url, download: name || "file" }); a.click(); }; } function scrollThread() { const t = $("#thread"); if (t) t.scrollTop = t.scrollHeight; } function fmtTime(ts) { const d = new Date(ts); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } // ---------- prekey replenishment ---------- async function maybeReplenishPreKeys() { try { const { count } = await api.keyCount(); if (count < 20) { const otks = await C.generateMoreOneTimePreKeys(100); await api.addPreKeys(otks); } } catch {} } function registerServiceWorker() { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } } boot();