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:
parent
fc3f79379e
commit
bd112d07fe
7 changed files with 225 additions and 23 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
39
public/js/accounts.js
Normal file
39
public/js/accounts.js
Normal 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;
|
||||
}
|
||||
156
public/js/app.js
156
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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue