diff --git a/web/js/app.js b/web/js/app.js index 813e2a8..8f0d787 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -445,7 +445,9 @@ async function openMessage(id) { `this message carries the sender's signed address: ${opened.replyAddress.short}`)); actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => { try { - saveReplyAddress(opened.replyAddress, opened.sender); + bindReplyAddress(opened.replyAddress, opened.sender); + status(`${opened.replyAddress.short} saved for this sender (verified key)`); + renderFolder(); openMessage(row.id); startReply(row); } catch (err) { @@ -529,17 +531,18 @@ async function nameSender(text, senderKey) { renderFolder(); } -// A signed Reply-To is the sender's own claim, so it saves as verified — but -// never over an address already pinned to a different key (§8: a key change -// without a rotation chain needs out-of-band confirmation). -function saveReplyAddress(addr, senderKey) { +// A signed Reply-To is the sender's own claim, verified the moment unseal() +// verified the message's signature — no further trust step to gate behind a +// click. Never overwrites an address already pinned to a different key (§8: +// a key change without a rotation chain needs out-of-band confirmation). No +// UI side effects: callers (the reader's button, fetch's auto-bind) decide +// how to report the result and whether many need batching into one summary. +function bindReplyAddress(addr, senderKey) { const existing = store.contact(addr.short); if (existing && !timingSafeEqual(existing.key, senderKey)) throw new SmolError(`${addr.short} is already known with a different key — ` + `verify out of band before replying`); store.saveContact(addr.short, senderKey, true); - status(`${addr.short} saved for this sender (verified key)`); - renderFolder(); } // --- compose and send ----------------------------------------------------------- @@ -642,7 +645,7 @@ $("fetch").onclick = async () => { button.disabled = true; // one session at a time; the icon spins while it is label.textContent = "fetching…"; status("fetching…"); - let stored = 0; + let stored = 0, verified = 0; const rejected = []; try { const opened = await connect(addr, { requirePin: true }); @@ -653,10 +656,11 @@ $("fetch").onclick = async () => { if (!records.length) break; const acked = []; for (const record of records) { + let unsealed; try { if (!timingSafeEqual(messageId(record.envelope), record.id)) throw new SmolError("id does not match the envelope"); - unseal(store.identities(), record.envelope); + unsealed = unseal(store.identities(), record.envelope); } catch (err) { // Left on the server rather than destroyed, so a client-side bug // cannot lose mail. Collected rather than announced, because the @@ -664,9 +668,21 @@ $("fetch").onclick = async () => { rejected.push(`${hex(record.id)}: ${err.message}`); continue; } - if (await store.storeIfNew("inbox", { - id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt, - })) stored++; + const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt }; + if (await store.storeIfNew("inbox", row)) { + stored++; + // A signed Reply-To is exactly as verified as the signature + // unseal() just checked — see bindReplyAddress(). describe() + // both parses the same claim the reader would show and warms its + // cache, so opening the message right after costs nothing extra. + const claim = describe(row).replyAddress; + if (claim) { + try { + bindReplyAddress(claim, unsealed.sender); + verified++; + } catch { /* already known under a different key: leave it be */ } + } + } acked.push(record.id); } if (!acked.length) break; @@ -675,7 +691,9 @@ $("fetch").onclick = async () => { } finally { opened.session.stream.close(); } - status([`${stored} new, ${rejected.length} rejected`, ...rejected.map(line => ` ${line}`), + status([`${stored} new, ${rejected.length} rejected`, + verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null, + ...rejected.map(line => ` ${line}`), rejected.length ? "left on the server" : null].filter(Boolean).join("\n"), rejected.length > 0); } catch (err) { @@ -989,14 +1007,20 @@ $("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`); + try { + 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); + // No counts in the message: the file itself is sealed precisely so an + // observer learns nothing from it, and a summary here would just move + // that same leak from the file to the screen right next to it. + status("exported — sealed to this identity; only a browser with this seed restored can open it"); + } catch (err) { + status(err.message, true); + } }; $("import-data-trigger").onclick = () => $("import-data").click(); diff --git a/web/js/store.js b/web/js/store.js index 9ee57cc..03aaf9c 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -2,7 +2,7 @@ // 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 { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js"; import { b32decode, b32encode, identityFromSeed } from "./proto.js"; const KEY = "gsmol"; @@ -243,16 +243,27 @@ export async function unreadCount() { const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes)); const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0)); +// A gsmol-only construction, not the wire protocol's own HKDF namespace +// ("smolmail/1 ..." in proto.js) — export/import has no counterpart in +// SPEC.md, so it gets its own label rather than borrowing the protocol's. +// Sealing to a key derived from the identity's own seed means the file is +// opaque without it — no new passphrase to manage, and it can only ever be +// opened where that seed is already restored, exactly the situation import +// already assumes. +const EXPORT_LABEL = utf8Bytes("gsmol/1 export"); +const exportKey = (seed) => hkdfSha256(seed, new Uint8Array(0), EXPORT_LABEL, 32); + // 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. +// in settings, meant for a password manager, not a downloadable file. Beyond +// that, everything else in here was worth hiding too — contacts and pins are +// a social graph and a list of which mail servers you use, not just the mail +// SPEC.md §5 makes irreplaceable once fetched — so the whole payload is +// sealed, not just the parts that were already ciphertext at rest. export async function exportData() { + const me = identity(); + if (!me) throw new Error("no identity yet"); const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]); - return { - gsmolExport: 1, - exportedAt: Date.now(), + const payload = { servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])), contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) => [address, { key, verified, history }])), @@ -260,6 +271,19 @@ export async function exportData() { sent: sent.map(row => ({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })), }; + // Random per export: the key is the same every time (deterministic from the + // seed), so nonce reuse has to be ruled out the way it always is under a + // fixed key — a fresh 96-bit nonce per seal, not a fixed one like seal()'s + // in proto.js gets away with (there, a fresh ephemeral key each message + // makes the derived key itself unique, so a zero nonce is safe). + const nonce = randomBytes(12); + const ciphertext = aeadEncrypt(exportKey(me.seed), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0)); + return { + gsmolExport: 2, + exportedAt: Date.now(), + nonce: toBase64(nonce), + ciphertext: toBase64(ciphertext), + }; } // Never overwrites a trust binding that already differs locally — the same @@ -274,14 +298,30 @@ export async function exportData() { 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"); + let payload; + if (data?.gsmolExport === 2) { + const me = identity(); + if (!me) throw new Error("no identity yet — restore it before importing"); + try { + const plaintext = aeadDecrypt( + exportKey(me.seed), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0)); + payload = JSON.parse(new TextDecoder().decode(plaintext)); + } catch { + throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted"); + } + } else if (data?.gsmolExport === 1) { + payload = data; // pre-encryption shape: the fields already sit at the top level + } else { + 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 : {})) { + if (payload.servers !== undefined && !isRecord(payload.servers)) summary.malformed++; + for (const [host, key] of Object.entries(isRecord(payload.servers) ? payload.servers : {})) { try { if (b32decode(key).length !== 32) throw new Error("bad length"); } catch { summary.malformed++; continue; } @@ -291,8 +331,8 @@ export async function importData(data) { 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 : {})) { + if (payload.contacts !== undefined && !isRecord(payload.contacts)) summary.malformed++; + for (const [address, c] of Object.entries(isRecord(payload.contacts) ? payload.contacts : {})) { try { if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key"); } catch { summary.malformed++; continue; } @@ -307,7 +347,7 @@ export async function importData(data) { state.contacts = contacts; }); - for (const [box, rows] of [["inbox", data.inbox], ["sent", data.sent]]) { + for (const [box, rows] of [["inbox", payload.inbox], ["sent", payload.sent]]) { if (rows !== undefined && !Array.isArray(rows)) summary.malformed++; for (const row of Array.isArray(rows) ? rows : []) { try {