192 lines
5.4 KiB
JavaScript
192 lines
5.4 KiB
JavaScript
|
|
// 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;
|
||
|
|
}
|