From bd112d07fecec5d1b0cd62ca09a57309eb10e5e3 Mon Sep 17 00:00:00 2001 From: maverick Date: Wed, 22 Jul 2026 02:48:00 +1000 Subject: [PATCH] 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:; 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) --- package-lock.json | 11 +++ package.json | 1 + public/index.html | 18 +++++ public/js/accounts.js | 39 +++++++++++ public/js/app.js | 156 ++++++++++++++++++++++++++++++++++++------ public/js/store.js | 20 +++++- public/sw.js | 3 +- 7 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 public/js/accounts.js diff --git a/package-lock.json b/package-lock.json index ed479af..7f0c9d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@privacyresearch/libsignal-protocol-typescript": "^0.0.16", "buffer": "^6.0.3", "esbuild": "^0.28.1", + "fake-indexeddb": "^6.2.5", "wrangler": "^4.96.0" } }, @@ -1431,6 +1432,16 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/package.json b/package.json index 1327bca..d41c1d9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@privacyresearch/libsignal-protocol-typescript": "^0.0.16", "buffer": "^6.0.3", "esbuild": "^0.28.1", + "fake-indexeddb": "^6.2.5", "wrangler": "^4.96.0" } } diff --git a/public/index.html b/public/index.html index 7a44cbb..48a8c76 100644 --- a/public/index.html +++ b/public/index.html @@ -105,6 +105,24 @@ .modal-actions { display: flex; gap: 10px; justify-content: flex-end; } .modal-actions button { padding: 10px 16px; } + /* ---- account menu ---- */ + .side-header { cursor: pointer; } + .side-header:hover { background: var(--panel-2); } + .caret { color: var(--muted); font-size: 12px; } + .acct-row, .acct-action { + text-align: left; background: var(--panel-2); color: var(--text); + padding: 11px 14px; border-radius: 10px; + } + .acct-row.active { outline: 1px solid var(--accent); } + .acct-action { background: transparent; color: var(--accent); border: 1px solid var(--panel-2); } + .acct-action.danger { color: #f15c6d; border-color: #4a2a2f; } + + /* ---- toasts ---- */ + #toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; } + .toast { padding: 12px 18px; border-radius: 10px; font-size: 14px; box-shadow: 0 8px 30px rgba(0,0,0,.5); max-width: 90vw; } + .toast.error { background: #3a1c22; color: #ffb3bd; border: 1px solid #5a2a33; } + .toast.info { background: var(--panel-2); color: var(--text); } + @media (max-width: 720px) { .shell { grid-template-columns: 1fr; } /* Sidebar shows by default; hide it once a chat is open so the thread is full-width. */ diff --git a/public/js/accounts.js b/public/js/accounts.js new file mode 100644 index 0000000..2fde938 --- /dev/null +++ b/public/js/accounts.js @@ -0,0 +1,39 @@ +// Account registry, stored in localStorage (global to the origin, unlike the +// per-account IndexedDB). Tracks which accounts have signed in on this browser +// and which one is currently active. Tokens live here; private keys never do. + +const KEY = "cipher.accounts"; +const ACTIVE = "cipher.activeUser"; + +export function list() { + try { + return JSON.parse(localStorage.getItem(KEY) || "[]"); + } catch { + return []; + } +} + +export function save(acct) { + // acct: { userId, deviceId, username, displayName, token } + const others = list().filter((a) => a.username !== acct.username); + others.push(acct); + localStorage.setItem(KEY, JSON.stringify(others)); +} + +export function remove(username) { + localStorage.setItem(KEY, JSON.stringify(list().filter((a) => a.username !== username))); + if (getActiveUsername() === username) localStorage.removeItem(ACTIVE); +} + +export function setActive(username) { + localStorage.setItem(ACTIVE, username); +} + +export function getActiveUsername() { + return localStorage.getItem(ACTIVE); +} + +export function getActive() { + const u = getActiveUsername(); + return u ? list().find((a) => a.username === u) || null : null; +} diff --git a/public/js/app.js b/public/js/app.js index 9a58fca..7bd427a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -2,7 +2,8 @@ import { api, setToken, MailboxSocket } from "./api.js"; import * as C from "./crypto.js"; -import { meta, contacts, messages as msgStore } from "./store.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) => { @@ -20,17 +21,27 @@ const state = { // ---------- boot ---------- async function boot() { - const saved = await meta.get("account"); + const saved = accounts.getActive(); if (saved?.token) { - state.me = saved; - state.token = saved.token; - setToken(saved.token); - await enterApp(); + 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"); @@ -53,9 +64,20 @@ function renderAuth() { form.onsubmit = async (e) => { e.preventDefault(); err.textContent = ""; + const uname = username.value.trim().toLowerCase(); try { loginBtn.disabled = true; - const res = await api.login({ username: username.value, password: password.value }); + // 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; @@ -66,17 +88,23 @@ function renderAuth() { registerBtn.onclick = async () => { err.textContent = ""; - if (!username.value || password.value.length < 8) { + 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: username.value, - displayName: displayName.value || username.value, + username: uname, + displayName: displayName.value || uname, password: password.value, device, }); @@ -91,11 +119,16 @@ function renderAuth() { } async function onAuthenticated(res) { - state.me = res; - state.token = res.token; - setToken(res.token); - await meta.set("account", res); - await enterApp(); + 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 ---------- @@ -114,9 +147,12 @@ function renderShell() { // Sidebar const sidebar = el("aside", { className: "sidebar" }); - const header = el("div", { className: "side-header" }); + const header = el("div", { className: "side-header", title: "Accounts", onclick: openAccountMenu }); header.append( - el("div", { className: "me", textContent: state.me.displayName || state.me.username }), + 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 }); @@ -213,6 +249,66 @@ function openModal({ title, placeholder, action, onSubmit }) { }; } +// ---------- 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; @@ -262,12 +358,25 @@ async function openChat(contact) { const text = input.value.trim(); if (!text) return; input.value = ""; - await sendText(contact, text); + 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) await sendMedia(contact, file); + if (file) { + try { + await sendMedia(contact, file); + } catch (ex) { + console.error("media send failed", ex); + toast("Couldn't send file: " + ex.message); + } + } fileInput.value = ""; }; } @@ -332,7 +441,14 @@ function connectSocket() { async function onEnvelope(envelope) { // Look up / create the sender contact. let contact = await contactByUserId(envelope.fromUserId); - const plaintext = await C.decryptEnvelope(envelope, 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); diff --git a/public/js/store.js b/public/js/store.js index 1a3a174..e83cb4a 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -1,12 +1,28 @@ // IndexedDB persistence: the Signal protocol store + app data (contacts, chats). // Private keys live ONLY here, in the browser. They are never sent to the server. -const DB_NAME = "cipher"; const DB_VERSION = 1; +// The store is namespaced PER ACCOUNT so multiple accounts can coexist on one +// browser without their Signal identities colliding. Namespace by username +// (chosen before key generation, unlike the server-assigned userId). +let _ns = null; +export function useAccount(username) { + const ns = String(username || "").toLowerCase(); + if (_ns !== ns) { + _ns = ns; + if (_db) { try { _db.close(); } catch {} } + _db = null; // force reopen against the new namespace + } +} +function dbName() { + if (!_ns) throw new Error("no active account (call useAccount first)"); + return "cipher:" + _ns; +} + function openDB() { return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, DB_VERSION); + const req = indexedDB.open(dbName(), DB_VERSION); req.onupgradeneeded = () => { const db = req.result; // Signal protocol material (identity keypair, prekeys, sessions, peer identities). diff --git a/public/sw.js b/public/sw.js index 84ae8b9..2fd1b90 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,7 @@ // Service worker: offline app shell + Web Push wake-up. // It never touches message plaintext — decryption happens only in the page. -const CACHE = "cipher-v2"; +const CACHE = "cipher-v3"; const SHELL = [ "/", "/index.html", @@ -10,6 +10,7 @@ const SHELL = [ "/js/api.js", "/js/crypto.js", "/js/store.js", + "/js/accounts.js", "/js/vendor/libsignal.js", "/icons/icon.svg", ];