diff --git a/web/index.html b/web/index.html index 465a73c..54197c1 100644 --- a/web/index.html +++ b/web/index.html @@ -41,6 +41,8 @@ or restore from a saved seed: + @@ -128,6 +130,19 @@
+
+

backup

+

Mail, once fetched, exists only here — the server deletes it as it's picked up + (SPEC.md §5). This backs up the local inbox, sent mail, contacts and pinned servers into one + file. It never includes the seed: that has its own reveal-and-copy flow above, meant for a + password manager, not a downloadable file.

+
+ + + +
+
+ diff --git a/web/js/app.js b/web/js/app.js index 8e20b03..fd4b72b 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -157,7 +157,7 @@ $("create-identity").onclick = () => { openSettings(); }; -$("restore-identity").onclick = () => { +$("restore-identity").onclick = async () => { const text = $("restore-seed").value.trim(); if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true); const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16)); @@ -167,7 +167,23 @@ $("restore-identity").onclick = () => { return status(err.message, true); } showApp(); - status("identity restored."); + // The address field is optional: restoring must succeed on its own even if + // this fails (server not pinned yet, offline, typo) — recall can always be + // retried from settings afterward. + const addressText = $("restore-address").value.trim(); + let message = "identity restored.", warn = false; + if (addressText) { + try { + const addr = await recallAccount(addressText); + message += `\nrecalled ${addr.short}`; + renderAccount(); + renderSettings(); + } catch (err) { + message += `\ncouldn't recall that address: ${err.message}`; + warn = true; + } + } + status(message, warn); }; // --- header --------------------------------------------------------------------- @@ -824,7 +840,15 @@ function renderSettings() { "Rotating issues a fresh key with a signed certificate proving it replaces this one. " + "Contacts accept the change from that chain, and the old seed is kept, since mail sealed " + "to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }), - el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity })), + el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }), + el("p", { className: "hint", textContent: + "Log out to remove this identity from this browser entirely: the seed, pins, contacts and " + + "every cached message. Without a backup of the seed there is no way back in — same as any " + + "other loss of it. Fetching deletes from the server once acknowledged (SPEC.md §5), so a " + + "message already fetched here has no other copy anywhere; anything still unfetched stays " + + "on the server and comes back on the next fetch after logging back in." }), + el("button", { type: "button", className: "danger", textContent: "log out & erase this browser", + onclick: logout })), ].filter(node => node != null)); } @@ -887,24 +911,31 @@ $("register-form").onsubmit = async (event) => { // *different* browser registered it under — "account" is local-only state, // never asked of the server. This binds it without REGISTER: RESOLVE the // address and require it name this exact key, so a typo or someone else's -// address can't silently misfile outgoing Reply-To / fetch under it. +// address can't silently misfile outgoing Reply-To / fetch under it. Shared +// by the settings recall form and the optional address field on restore. +async function recallAccount(text) { + const me = store.identity(); + if (!me) throw new SmolError("no identity yet"); + const addr = parseAddress(text.trim()); + const opened = await connect(addr, { requirePin: true }); + let current; + try { + ({ identity: current } = await resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + if (!timingSafeEqual(current, me.publicKey)) + throw new SmolError(`${addr.short} resolves to a different key — not this identity`); + store.setAccount(addr); + return addr; +} + $("recall-form").onsubmit = async (event) => { event.preventDefault(); const form = event.target; try { const me = store.identity(); - if (!me) throw new SmolError("no identity yet"); - const addr = parseAddress(form.address.value.trim()); - const opened = await connect(addr, { requirePin: true }); - let current; - try { - ({ identity: current } = await resolveOp(opened.session, addr.user)); - } finally { - opened.session.stream.close(); - } - if (!timingSafeEqual(current, me.publicKey)) - throw new SmolError(`${addr.short} resolves to a different key — not this identity`); - store.setAccount(addr); + const addr = await recallAccount(form.address.value); status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); renderAccount(); renderSettings(); @@ -914,6 +945,40 @@ $("recall-form").onsubmit = async (event) => { } }; +$("export-data").onclick = async () => { + const data = await store.exportData(); + const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const stamp = new Date(data.exportedAt).toISOString().slice(0, 10); + Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click(); + URL.revokeObjectURL(url); + status(`exported ${data.inbox.length + data.sent.length} messages, ` + + `${Object.keys(data.contacts).length} contacts, ${Object.keys(data.servers).length} server keys`); +}; + +$("import-data-trigger").onclick = () => $("import-data").click(); + +$("import-data").onchange = async (event) => { + const file = event.target.files[0]; + event.target.value = ""; // same file re-selected twice must still fire change + if (!file) return; + try { + const data = JSON.parse(await file.text()); + const s = await store.importData(data); + const conflicts = s.pinsConflicted + s.contactsConflicted; + status([ + `imported ${s.mailAdded} message(s), ${s.pinsAdded} server key(s), ${s.contactsAdded} contact(s)`, + conflicts ? `${conflicts} entr${conflicts === 1 ? "y" : "ies"} already known under a ` + + "different key — kept what was already here, skipped the import's version" : null, + s.malformed ? `${s.malformed} malformed entr${s.malformed === 1 ? "y" : "ies"} skipped` : null, + ].filter(Boolean).join("\n"), Boolean(conflicts || s.malformed)); + renderSettings(); + renderFolder(); + } catch (err) { + status(`import failed: ${err.message}`, true); + } +}; + async function rotateIdentity() { const me = store.identity(); const addr = accountAddress(); @@ -942,6 +1007,17 @@ async function rotateIdentity() { } } +// Unlike rotate, nothing is kept: this is the seed's only local copy, gone. +async function logout() { + if (!window.confirm( + "Log out and erase this browser?\n\n" + + "This deletes the seed, pins, contacts and every cached message from this browser. Make sure " + + "the seed is backed up elsewhere first — without it there is no way back in." + )) return; + await store.clearAll(); + location.reload(); +} + // --- boot -------------------------------------------------------------------------- // A deployment MAY bake in a server key (the NixOS module's `presetServer`), diff --git a/web/js/store.js b/web/js/store.js index c2dc813..9ee57cc 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -234,3 +234,108 @@ export async function unreadCount() { const read = load().read || {}; return ids.filter(id => !read[id]).length; } + +// --- 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 + }); +} diff --git a/web/style.css b/web/style.css index d9922cf..8b93509 100644 --- a/web/style.css +++ b/web/style.css @@ -349,6 +349,9 @@ dialog::backdrop { background: var(--backdrop); } .filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; } .filled:hover { background: var(--accent); box-shadow: var(--shadow); } .filled:disabled { background: var(--hover); color: var(--muted); box-shadow: none; } +/* Irreversible, not just consequential — rotate stays plain-text, this doesn't. */ +.danger { background: var(--warn); color: var(--on-warn); font-weight: 500; padding: 8px 20px; } +.danger:hover { background: var(--warn); box-shadow: var(--shadow); } .error { color: var(--warn); font-size: 13px; } #settings section { margin-bottom: 26px; } @@ -407,6 +410,7 @@ dialog::backdrop { background: var(--backdrop); } color: var(--warn); } .seed-card .row { display: flex; align-items: center; gap: 8px; } +.backup-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; } .seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; } .seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); }