gsmol/web/js/store.js

342 lines
12 KiB
JavaScript
Raw Normal View History

2026-09-26 15:59:28 +03:00
// 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";
// Every getter reads the whole blob and some are called per message row, so the
// parse is cached. `derived` holds the identities that follow from it, which
// cost an Ed25519 scalar multiplication each.
let cached = null, derived = null;
2026-09-26 15:59:28 +03:00
function load() {
if (cached) return cached;
2026-09-26 15:59:28 +03:00
try {
cached = JSON.parse(localStorage.getItem(KEY) || "{}");
2026-09-26 15:59:28 +03:00
} catch {
cached = {};
2026-09-26 15:59:28 +03:00
}
return cached;
2026-09-26 15:59:28 +03:00
}
function save(state) {
localStorage.setItem(KEY, JSON.stringify(state));
cached = state;
derived = null;
2026-09-26 15:59:28 +03:00
}
// Another tab writing this key leaves our copy stale.
window.addEventListener("storage", (event) => {
if (event.key === KEY || event.key === null) cached = derived = null;
});
2026-09-26 15:59:28 +03:00
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() {
return identities()[0] ?? null;
2026-09-26 15:59:28 +03:00
}
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() {
if (derived) return derived;
const raw = seed();
if (!raw) return [];
2026-09-26 15:59:28 +03:00
const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s)));
derived = [identityFromSeed(raw), ...retired];
return derived;
2026-09-26 15:59:28 +03:00
}
// --- 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, history: c.history ?? [] } : null;
2026-09-26 15:59:28 +03:00
}
// A key that displaces another is kept: it is the only local record that the
// contact rotated, and §8 turns on the user being able to notice such changes.
// Re-saving the same key is not a rotation and must not add an entry.
2026-09-26 15:59:28 +03:00
export function saveContact(address, keyBytes, verified) {
const key = b32encode(keyBytes);
2026-09-26 15:59:28 +03:00
update(state => {
const contacts = { ...(state.contacts || {}) };
const previous = contacts[address];
const history = previous && previous.key !== key
? [...(previous.history ?? []), { key: previous.key, until: Date.now() }]
: previous?.history ?? [];
contacts[address] = { key, verified, seenAt: Date.now(), ...(history.length && { history }) };
state.contacts = contacts;
2026-09-26 15:59:28 +03:00
});
}
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, history: c.history ?? [] }));
2026-09-26 15:59:28 +03:00
}
// --- read markers ---------------------------------------------------------------
export function markRead(idHex) {
update(state => {
state.read = { ...(state.read || {}), [idHex]: true };
});
}
export function isRead(idHex) {
return Boolean((load().read || {})[idHex]);
}
// --- appearance ------------------------------------------------------------
// "light" or "dark" to pin a side; null to follow the system.
export function theme() {
return load().theme ?? null;
}
export function setTheme(value) {
update(state => {
if (value) state.theme = value;
else delete state.theme;
});
}
2026-09-26 15:59:28 +03:00
// --- sealed mail (IndexedDB) ------------------------------------------------------
const DB_NAME = "gsmol", DB_VERSION = 1;
// Held open: reopening per read cost more than the reads themselves.
let dbPromise = null;
2026-09-26 15:59:28 +03:00
function openDb() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
2026-09-26 15:59:28 +03:00
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);
}).catch(error => { dbPromise = null; throw error; });
return dbPromise;
2026-09-26 15:59:28 +03:00
}
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));
}
// 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) return null;
2026-09-26 15:59:28 +03:00
await tx(db, box, "readwrite", store => store.put(record));
return record;
}
export async function listMessages(box) {
const db = await openDb();
const rows = await tx(db, box, "readonly", store => store.getAll());
return rows.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
2026-09-26 15:59:28 +03:00
}
export async function getMessage(box, id) {
const db = await openDb();
return await tx(db, box, "readonly", store => store.get(id)) ?? null;
}
// The unread badge must survive leaving the inbox, so it is counted from ids
// and read markers alone — no envelope is opened.
export async function unreadCount() {
const db = await openDb();
const ids = await tx(db, "inbox", "readonly", store => store.getAllKeys());
const read = load().read || {};
return ids.filter(id => !read[id]).length;
2026-09-26 15:59:28 +03:00
}
// --- export / import: mail, contacts, pins — never the seed ---------------------
// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
// this file — no Node test exercises store.js, unlike crypto.js/proto.js.
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
// Deliberately excludes the seed: it already has its own reveal-and-copy flow
// in settings, meant for a password manager, not a downloadable file. This
// covers what fetching makes irreplaceable (SPEC.md §5 deletes mail from the
// server once acknowledged) plus the trust state that took real verification
// (RESOLVE, key comparison) to build up — pins and contacts.
export async function exportData() {
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
return {
gsmolExport: 1,
exportedAt: Date.now(),
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
[address, { key, verified, history }])),
inbox: inbox.map(row => ({ id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope) })),
sent: sent.map(row =>
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
};
}
// Never overwrites a trust binding that already differs locally — the same
// rule refreshContact()/saveReplyAddress() apply elsewhere: an existing pin
// or contact key changes only by explicit user action, never silently. A
// malformed entry (hand-edited file, corruption) is skipped, not fatal — one
// bad record cannot abort the rest of the import, matching describe()'s
// per-message fail-open elsewhere in the app.
// A plain object, as JSON.parse would produce for `{...}`; Object.entries()
// on a string iterates its characters rather than failing, which is exactly
// the kind of malformed input this rejects as one unit instead of one per char.
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
export async function importData(data) {
if (!data || data.gsmolExport !== 1) throw new Error("not a gsmol export file");
const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0,
mailAdded: 0, malformed: 0 };
update(state => {
const servers = { ...(state.servers || {}) };
if (data.servers !== undefined && !isRecord(data.servers)) summary.malformed++;
for (const [host, key] of Object.entries(isRecord(data.servers) ? data.servers : {})) {
try {
if (b32decode(key).length !== 32) throw new Error("bad length");
} catch { summary.malformed++; continue; }
if (!(host in servers)) { servers[host] = key; summary.pinsAdded++; }
else if (servers[host] !== key) summary.pinsConflicted++;
}
state.servers = servers;
const contacts = { ...(state.contacts || {}) };
if (data.contacts !== undefined && !isRecord(data.contacts)) summary.malformed++;
for (const [address, c] of Object.entries(isRecord(data.contacts) ? data.contacts : {})) {
try {
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
} catch { summary.malformed++; continue; }
if (!(address in contacts)) {
contacts[address] = { key: c.key, verified: Boolean(c.verified), seenAt: Date.now(),
...(Array.isArray(c.history) && c.history.length && { history: c.history }) };
summary.contactsAdded++;
} else if (contacts[address].key !== c.key) {
summary.contactsConflicted++;
}
}
state.contacts = contacts;
});
for (const [box, rows] of [["inbox", data.inbox], ["sent", data.sent]]) {
if (rows !== undefined && !Array.isArray(rows)) summary.malformed++;
for (const row of Array.isArray(rows) ? rows : []) {
try {
const record = box === "inbox"
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope) }
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
if (await storeIfNew(box, record)) summary.mailAdded++;
} catch { summary.malformed++; }
}
}
return summary;
}
// Logout: erases the seed, pins, contacts and every cached message from this
// browser. Closes the held connection first so the delete isn't left
// "blocked" waiting for a handle that never closes on its own.
export async function clearAll() {
localStorage.removeItem(KEY);
cached = derived = null;
if (dbPromise) {
(await dbPromise).close();
dbPromise = null;
}
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
request.onblocked = () => resolve(); // still completes once the reload closes every handle
});
}