cipher/public/js/store.js
maverick c48f9f4006 Encrypted group chats + major UI redesign
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>
2026-07-22 13:16:16 +10:00

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