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>
This commit is contained in:
maverick 2026-07-22 02:48:00 +10:00
parent fc3f79379e
commit bd112d07fe
7 changed files with 225 additions and 23 deletions

11
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@privacyresearch/libsignal-protocol-typescript": "^0.0.16", "@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"fake-indexeddb": "^6.2.5",
"wrangler": "^4.96.0" "wrangler": "^4.96.0"
} }
}, },
@ -1431,6 +1432,16 @@
"@esbuild/win32-x64": "0.28.1" "@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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",

View file

@ -17,6 +17,7 @@
"@privacyresearch/libsignal-protocol-typescript": "^0.0.16", "@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"fake-indexeddb": "^6.2.5",
"wrangler": "^4.96.0" "wrangler": "^4.96.0"
} }
} }

View file

@ -105,6 +105,24 @@
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; } .modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
.modal-actions button { padding: 10px 16px; } .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) { @media (max-width: 720px) {
.shell { grid-template-columns: 1fr; } .shell { grid-template-columns: 1fr; }
/* Sidebar shows by default; hide it once a chat is open so the thread is full-width. */ /* Sidebar shows by default; hide it once a chat is open so the thread is full-width. */

39
public/js/accounts.js Normal file
View file

@ -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;
}

View file

@ -2,7 +2,8 @@
import { api, setToken, MailboxSocket } from "./api.js"; import { api, setToken, MailboxSocket } from "./api.js";
import * as C from "./crypto.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 $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, ...children) => { const el = (tag, props = {}, ...children) => {
@ -20,17 +21,27 @@ const state = {
// ---------- boot ---------- // ---------- boot ----------
async function boot() { async function boot() {
const saved = await meta.get("account"); const saved = accounts.getActive();
if (saved?.token) { if (saved?.token) {
state.me = saved; await activateAccount(saved);
state.token = saved.token;
setToken(saved.token);
await enterApp();
} else { } else {
renderAuth(); 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 ---------- // ---------- auth ----------
function renderAuth() { function renderAuth() {
const root = $("#app"); const root = $("#app");
@ -53,9 +64,20 @@ function renderAuth() {
form.onsubmit = async (e) => { form.onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
err.textContent = ""; err.textContent = "";
const uname = username.value.trim().toLowerCase();
try { try {
loginBtn.disabled = true; 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); await onAuthenticated(res);
} catch (ex) { } catch (ex) {
err.textContent = ex.message; err.textContent = ex.message;
@ -66,17 +88,23 @@ function renderAuth() {
registerBtn.onclick = async () => { registerBtn.onclick = async () => {
err.textContent = ""; 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"; err.textContent = "username + 8-char password required";
return; return;
} }
if (accounts.list().some((a) => a.username === uname)) {
err.textContent = "@" + uname + " already signed in on this browser — use Log in / Switch";
return;
}
try { try {
registerBtn.disabled = true; registerBtn.disabled = true;
registerBtn.textContent = "Generating keys…"; registerBtn.textContent = "Generating keys…";
useAccount(uname); // namespace the store before generating/persisting keys
const device = await C.generateRegistration(); const device = await C.generateRegistration();
const res = await api.register({ const res = await api.register({
username: username.value, username: uname,
displayName: displayName.value || username.value, displayName: displayName.value || uname,
password: password.value, password: password.value,
device, device,
}); });
@ -91,11 +119,16 @@ function renderAuth() {
} }
async function onAuthenticated(res) { async function onAuthenticated(res) {
state.me = res; const acct = {
state.token = res.token; userId: res.userId,
setToken(res.token); deviceId: res.deviceId,
await meta.set("account", res); username: res.username,
await enterApp(); displayName: res.displayName || res.username,
token: res.token,
};
accounts.save(acct);
accounts.setActive(acct.username);
await activateAccount(acct);
} }
// ---------- main app ---------- // ---------- main app ----------
@ -114,9 +147,12 @@ function renderShell() {
// Sidebar // Sidebar
const sidebar = el("aside", { className: "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( 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 }) el("div", { className: "handle", textContent: "@" + state.me.username })
); );
const newChat = el("button", { className: "new-chat", textContent: "+ New chat", onclick: startNewChat }); 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 ---------- // ---------- chat view ----------
async function openChat(contact) { async function openChat(contact) {
state.activeContact = contact; state.activeContact = contact;
@ -262,12 +358,25 @@ async function openChat(contact) {
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; if (!text) return;
input.value = ""; 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 () => { fileInput.onchange = async () => {
const file = fileInput.files[0]; 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 = ""; fileInput.value = "";
}; };
} }
@ -332,7 +441,14 @@ function connectSocket() {
async function onEnvelope(envelope) { async function onEnvelope(envelope) {
// Look up / create the sender contact. // Look up / create the sender contact.
let contact = await contactByUserId(envelope.fromUserId); 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; let data;
try { try {
data = JSON.parse(plaintext); data = JSON.parse(plaintext);

View file

@ -1,12 +1,28 @@
// IndexedDB persistence: the Signal protocol store + app data (contacts, chats). // 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. // Private keys live ONLY here, in the browser. They are never sent to the server.
const DB_NAME = "cipher";
const DB_VERSION = 1; 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() { function openDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION); const req = indexedDB.open(dbName(), DB_VERSION);
req.onupgradeneeded = () => { req.onupgradeneeded = () => {
const db = req.result; const db = req.result;
// Signal protocol material (identity keypair, prekeys, sessions, peer identities). // Signal protocol material (identity keypair, prekeys, sessions, peer identities).

View file

@ -1,7 +1,7 @@
// Service worker: offline app shell + Web Push wake-up. // Service worker: offline app shell + Web Push wake-up.
// It never touches message plaintext — decryption happens only in the page. // It never touches message plaintext — decryption happens only in the page.
const CACHE = "cipher-v2"; const CACHE = "cipher-v3";
const SHELL = [ const SHELL = [
"/", "/",
"/index.html", "/index.html",
@ -10,6 +10,7 @@ const SHELL = [
"/js/api.js", "/js/api.js",
"/js/crypto.js", "/js/crypto.js",
"/js/store.js", "/js/store.js",
"/js/accounts.js",
"/js/vendor/libsignal.js", "/js/vendor/libsignal.js",
"/icons/icon.svg", "/icons/icon.svg",
]; ];