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>
564 lines
18 KiB
JavaScript
564 lines
18 KiB
JavaScript
// 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();
|