Groups (E2E via pairwise fan-out — sender encrypts each message separately to every member with existing Signal sessions; server holds only name+membership): - migration 0003: groups + group_members; routes/groups.js (create/list/get/add/leave) - client: unified DM+group chat model, group create modal w/ member chips, fan-out deliver, groupId payload routing on receive, lazy group fetch, sender names in group bubbles, group info + leave UI overhaul — glassmorphism redesign: - animated aurora backdrop, blurred glass panels, violet→cyan gradient brand - color-hashed gradient avatars (per name), group avatars - message bubbles with rise animation + tails, gradient outgoing bubbles - polished auth card w/ gradient border, pill actions, modal pop-in, toast styling - responsive: full-width thread on mobile with back button Verified live: 3-user group message fans out and both members decrypt. SW cache v4->v5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
6.1 KiB
JavaScript
195 lines
6.1 KiB
JavaScript
// 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 = 2; // v2 adds the "groups" object store
|
|
|
|
// 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).
|
|
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.
|
|
if (!db.objectStoreNames.contains("messages")) {
|
|
const s = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true });
|
|
s.createIndex("convo", "convo");
|
|
}
|
|
};
|
|
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));
|
|
},
|
|
async forConvo(convo) {
|
|
const idx = (await tx("messages")).index("convo");
|
|
return reqP(idx.getAll(convo));
|
|
},
|
|
};
|
|
|
|
// ---- 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;
|
|
}
|