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",
|
"@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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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 { 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);
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue