cipher/public/js/app.js
maverick bd112d07fe Fix: namespace encrypted store per-account so two accounts can coexist on one browser
Root cause of 'two users can't chat': all accounts on one browser shared a
single IndexedDB, so the second registration overwrote the first's Signal
identity keys, corrupting both. Verified via two-isolated-client integration
test (real crypto.js + store.js) that the protocol itself is correct.

- store.js: useAccount(username) namespaces IndexedDB as cipher:<username>;
  keys generated at registration now persist under the right account
- accounts.js: localStorage-backed account registry + active pointer
- app.js: multi-account boot/activate, account-switch/add/logout menu,
  error toasts on send/decrypt failures (no more silent failures)
- login guards against missing on-device keys (multi-device linking still TODO)
- SW cache v2->v3 + precache accounts.js

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 02:48:00 +10:00

564 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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";
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" });
form.append(title, tag, err, username, displayName, password, loginBtn, registerBtn);
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();
};
}
// ---------- 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 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,
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();