// 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_VERSION = 3; // v2 adds "groups"; v3 adds a "mid" index on messages (receipts + disappearing) // 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(dbName(), DB_VERSION); req.onupgradeneeded = () => { const db = req.result; // Signal protocol material (identity keypair, prekeys, sessions, peer identities). if (!db.objectStoreNames.contains("signal")) db.createObjectStore("signal"); // App metadata (my account, settings, per-chat disappearing timers). if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta"); // Known contacts, keyed by userId. if (!db.objectStoreNames.contains("contacts")) db.createObjectStore("contacts", { keyPath: "userId" }); // Known groups, keyed by groupId. if (!db.objectStoreNames.contains("groups")) db.createObjectStore("groups", { keyPath: "groupId" }); // Message history, keyed by autoincrement, indexed by conversation + message id. let msgs; if (!db.objectStoreNames.contains("messages")) { msgs = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true }); msgs.createIndex("convo", "convo"); } else { msgs = req.transaction.objectStore("messages"); } // "mid" is the client-generated message id shared by both parties, used to // match receipts back to the sent message and to expire disappearing messages. if (!msgs.indexNames.contains("mid")) msgs.createIndex("mid", "mid"); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } let _db; async function db() { if (!_db) _db = await openDB(); return _db; } function tx(store, mode = "readonly") { return db().then((d) => d.transaction(store, mode).objectStore(store)); } function reqP(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // ---- generic KV helpers over the "signal" and "meta" stores ---- async function kvGet(store, key) { return reqP((await tx(store)).get(key)); } async function kvPut(store, key, val) { return reqP((await tx(store, "readwrite")).put(val, key)); } async function kvDel(store, key) { return reqP((await tx(store, "readwrite")).delete(key)); } export const meta = { get: (k) => kvGet("meta", k), set: (k, v) => kvPut("meta", k, v), del: (k) => kvDel("meta", k), }; // ---- contacts ---- export const contacts = { async put(contact) { return reqP((await tx("contacts", "readwrite")).put(contact)); }, async get(userId) { return reqP((await tx("contacts")).get(userId)); }, async all() { return reqP((await tx("contacts")).getAll()); }, }; // ---- groups ---- export const groups = { async put(group) { return reqP((await tx("groups", "readwrite")).put(group)); }, async get(groupId) { return reqP((await tx("groups")).get(groupId)); }, async all() { return reqP((await tx("groups")).getAll()); }, async del(groupId) { return reqP((await tx("groups", "readwrite")).delete(groupId)); }, }; // ---- messages ---- export const messages = { async add(msg) { return reqP((await tx("messages", "readwrite")).add(msg)); }, // Overwrite an existing record (must carry its autoincrement `id`). async put(msg) { return reqP((await tx("messages", "readwrite")).put(msg)); }, async forConvo(convo) { const idx = (await tx("messages")).index("convo"); return reqP(idx.getAll(convo)); }, // Look a message up by its shared client id (for applying receipts). async getByMid(mid) { if (!mid) return undefined; const idx = (await tx("messages")).index("mid"); return reqP(idx.get(mid)); }, // Delete every message whose disappearing timer has elapsed; returns them so // the UI can drop their bubbles. async sweepExpired(nowTs) { const all = await reqP((await tx("messages")).getAll()); const expired = all.filter((m) => m.expiresAt && m.expiresAt <= nowTs); if (expired.length) { const s = await tx("messages", "readwrite"); for (const m of expired) s.delete(m.id); } return expired; }, }; // ---- Signal protocol store ---- // Implements the StorageType interface libsignal expects. Keys are strings; // ArrayBuffers and records are stored directly (IndexedDB structured-clones them). export class SignalStore { Direction = { SENDING: 1, RECEIVING: 2 }; async getIdentityKeyPair() { return kvGet("signal", "identityKey"); } async getLocalRegistrationId() { return kvGet("signal", "registrationId"); } async setIdentity(keyPair, registrationId) { await kvPut("signal", "identityKey", keyPair); await kvPut("signal", "registrationId", registrationId); } // Trust-on-first-use: trust a new peer identity; reject if it silently changes. async isTrustedIdentity(identifier, identityKey /*, direction */) { const existing = await kvGet("signal", "identity/" + identifier); if (!existing) return true; return buffersEqual(existing, identityKey); } async saveIdentity(identifier, identityKey) { const addr = identifier.split(".")[0]; const existing = await kvGet("signal", "identity/" + addr); await kvPut("signal", "identity/" + addr, identityKey); return !!existing && !buffersEqual(existing, identityKey); // true = identity changed } async loadIdentityKey(identifier) { return kvGet("signal", "identity/" + identifier.split(".")[0]); } async loadPreKey(keyId) { return kvGet("signal", "prekey/" + keyId); } async storePreKey(keyId, keyPair) { return kvPut("signal", "prekey/" + keyId, keyPair); } async removePreKey(keyId) { return kvDel("signal", "prekey/" + keyId); } async loadSignedPreKey(keyId) { return kvGet("signal", "signedprekey/" + keyId); } async storeSignedPreKey(keyId, keyPair) { return kvPut("signal", "signedprekey/" + keyId, keyPair); } async removeSignedPreKey(keyId) { return kvDel("signal", "signedprekey/" + keyId); } async loadSession(identifier) { return kvGet("signal", "session/" + identifier); } async storeSession(identifier, record) { return kvPut("signal", "session/" + identifier, record); } async removeSession(identifier) { return kvDel("signal", "session/" + identifier); } async removeAllSessions() { /* not needed for MVP */ } } function buffersEqual(a, b) { const ua = new Uint8Array(a); const ub = new Uint8Array(b); if (ua.length !== ub.length) return false; let diff = 0; for (let i = 0; i < ua.length; i++) diff |= ua[i] ^ ub[i]; return diff === 0; }