// Browser state: identity, pins, contacts and read markers in localStorage; // sealed envelopes in IndexedDB, opened only on demand, so nothing at rest // is plaintext (the seed excepted — it is the user's browser profile). import { hex, unhex } from "./crypto.js"; import { b32decode, b32encode, identityFromSeed } from "./proto.js"; const KEY = "gsmol"; function load() { try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch { return {}; } } function save(state) { localStorage.setItem(KEY, JSON.stringify(state)); } function update(fn) { const state = load(); const next = fn(state); save(next ?? state); } // --- identity ---------------------------------------------------------------- export function seed() { const raw = load().seed; return raw ? unhex(raw) : null; } export function identity() { const raw = seed(); return raw ? identityFromSeed(raw) : null; } export function setIdentity(newSeed) { if (seed()) throw new Error("an identity already exists; rotate it instead"); update(state => { state.seed = hex(newSeed); }); } // Rotation (§7): the old seed is retained, since mail sealed to a superseded // key is readable with nothing else. export function rotateIdentity(newSeed) { const old = seed(); if (!old) throw new Error("no identity to rotate"); update(state => { state.retired = [...(state.retired || []), { seed: hex(old), at: Date.now() }]; state.seed = hex(newSeed); }); } // §7: seeds rotated away from are retained, since mail sealed to a superseded // key is readable with nothing else. export function identities() { const me = identity(); if (!me) return []; const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s))); return [me, ...retired]; } // --- account and server pins --------------------------------------------------- export function account() { const a = load().account; return a ? { ...a } : null; } export function setAccount(addr) { update(state => { state.account = { user: addr.user, host: addr.host, port: addr.port }; }); } export function serverPin(host) { const raw = (load().servers || {})[host]; return raw ? b32decode(raw) : null; } export function pinServer(host, keyBytes) { update(state => { state.servers = { ...(state.servers || {}), [host]: b32encode(keyBytes) }; }); } export function unpinServer(host) { update(state => { delete (state.servers || {})[host]; }); } export function allPins() { return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key })); } // --- contacts ------------------------------------------------------------------ export function contact(address) { const c = (load().contacts || {})[address]; return c ? { key: b32decode(c.key), verified: c.verified } : null; } export function saveContact(address, keyBytes, verified) { update(state => { state.contacts = { ...(state.contacts || {}), [address]: { key: b32encode(keyBytes), verified, seenAt: Date.now() }, }; }); } export function addressForKey(keyBytes) { return Object.entries(load().contacts || {}) .find(([, c]) => c.key === b32encode(keyBytes))?.[0] ?? null; } export function allContacts() { return Object.entries(load().contacts || {}) .map(([address, c]) => ({ address, key: c.key, verified: c.verified })); } // --- read markers --------------------------------------------------------------- export function markRead(idHex) { update(state => { state.read = { ...(state.read || {}), [idHex]: true }; }); } export function isRead(idHex) { return Boolean((load().read || {})[idHex]); } // --- sealed mail (IndexedDB) ------------------------------------------------------ const DB_NAME = "gsmol", DB_VERSION = 1; function openDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; for (const box of ["inbox", "sent"]) { if (!db.objectStoreNames.contains(box)) db.createObjectStore(box, { keyPath: "id" }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function tx(db, box, mode, fn) { return new Promise((resolve, reject) => { const request = fn(db.transaction(box, mode).objectStore(box)); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } export async function storeMessage(box, record) { const db = await openDb(); await tx(db, box, "readwrite", store => store.put(record)); db.close(); } // Returns null when the id already exists, so fetch can leave server state alone. export async function storeIfNew(box, record) { const db = await openDb(); const existing = await tx(db, box, "readonly", store => store.get(record.id)); if (existing) { db.close(); return null; } await tx(db, box, "readwrite", store => store.put(record)); db.close(); return record; } export async function listMessages(box) { const db = await openDb(); const rows = await tx(db, box, "readonly", store => store.getAll()); db.close(); return rows.sort((a, b) => (a.receivedAt ?? a.sentAt) - (b.receivedAt ?? b.sentAt)); } export async function getMessage(box, id) { const db = await openDb(); const row = await tx(db, box, "readonly", store => store.get(id)); db.close(); return row ?? null; }