cipher/public/js/store.js
maverick 360bfeb5ab Cipher: E2E encrypted messenger scaffold (Signal Protocol + Cloudflare)
- Hono Worker: auth, key directory (X3DH prekey bundles), message relay, R2 media
- Mailbox Durable Object: WebSocket delivery + offline ciphertext queue
- PWA client: libsignal (vendored), IndexedDB key store, chat UI, media E2E
- Server only ever holds public keys + ciphertext; private keys stay on device

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:56:59 +10:00

161 lines
5 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_NAME = "cipher";
const DB_VERSION = 1;
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 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" });
// 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());
},
};
// ---- 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;
}