// UI wiring for the smol mail client: identity setup, folders, reading, // composing, fetching with verify-before-ack, and the §4/§7 trust surfaces. import { hex, timingSafeEqual, utf8Bytes } from "./crypto.js"; import { DEFAULT_PORT, SmolError, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, makeCert, messageId, newIdentity, openSession, parseAddress, parseFrontmatter, registerOp, resolveOp, seal, sendOp, unseal, walkChain, } from "./proto.js"; import * as store from "./store.js"; import { connectStream } from "./transport.js"; import { PRESET_SERVER } from "./config.js"; const $ = (id) => document.getElementById(id); // Everything is built as nodes rather than markup, so no message content can // ever reach innerHTML. Null children are dropped, which keeps conditional // pieces inline at the call site. function el(tag, props = {}, ...children) { const node = Object.assign(document.createElement(tag), props); node.append(...children.filter(child => child != null)); return node; } // SVG lives in its own namespace, so el()'s createElement cannot build these; // each is a reference into the sprite in index.html. function icon(name) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("class", "icon"); svg.setAttribute("aria-hidden", "true"); const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); use.setAttribute("href", `#i-${name}`); svg.append(use); return svg; } const placeholder = (text, warn = false) => el("p", { className: warn ? "placeholder warn" : "placeholder", textContent: text }); // The status line is a toast: click to dismiss, empty means hidden. Plain // notices fade; warnings stay until dismissed, since those carry the §4/§8 // trust messages the user must actually read. let toastTimer = null; function status(text, warn = false) { const toast = $("status"); toast.textContent = text; toast.hidden = !text; toast.className = warn ? "toast warn" : "toast"; clearTimeout(toastTimer); if (text && !warn) toastTimer = setTimeout(() => status(""), 6000); } $("status").onclick = () => status(""); const fmtTime = (seconds) => new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); // Gmail-style row stamp: time for today, date within this year, full date older. const fmtRowTime = (seconds) => { const date = new Date(seconds * 1000), today = new Date(); if (date.toDateString() === today.toDateString()) return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); if (date.getFullYear() === today.getFullYear()) return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }; // A copy button beats asking the user to select 64 hex characters by hand. function copyButton(text, what) { return el("button", { type: "button", className: "copy", textContent: "copy", onclick: async () => { try { await navigator.clipboard.writeText(text); status(`${what} copied to the clipboard`); } catch { status(`the clipboard is unavailable; select and copy ${what} by hand`, true); } } }); } // The stored account as an address object, default port included when custom. function accountAddress() { const account = store.account(); if (!account) return null; const suffix = account.port === DEFAULT_PORT ? "" : `:${account.port}`; return parseAddress(`${account.user}@${account.host}${suffix}`); } // --- appearance ---------------------------------------------------------------- // Three states, because "follow the system" is a real preference and not the // absence of one. The CSS does the rest: color-scheme decides which half of // every light-dark() token applies, so nothing here touches a colour. const THEMES = [ { value: null, icon: "auto", label: "theme: following the system" }, { value: "light", icon: "sun", label: "theme: light" }, { value: "dark", icon: "moon", label: "theme: dark" }, ]; function applyTheme() { const mode = THEMES.find(t => t.value === store.theme()) ?? THEMES[0]; if (mode.value) document.documentElement.dataset.theme = mode.value; else delete document.documentElement.dataset.theme; const button = $("theme"); button.querySelector("use").setAttribute("href", `#i-${mode.icon}`); button.title = mode.label; button.setAttribute("aria-label", mode.label); } $("theme").onclick = () => { const current = THEMES.findIndex(t => t.value === store.theme()); store.setTheme(THEMES[(current + 1) % THEMES.length].value); applyTheme(); status(THEMES.find(t => t.value === store.theme()).label); }; // --- connection -------------------------------------------------------------- // §4: registration and fetching demand a pinned key; sending to a recipient // whose key we already hold tolerates an unpinned server. async function connect(addr, { requirePin }) { const pinned = store.serverPin(addr.host); if (requirePin && !pinned) throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` + `trusted channel, then pin it in settings.`); const stream = await connectStream(addr.host, addr.port); const opened = await openSession(stream, addr.host, pinned); if (!pinned) status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` + `RESOLVE results from this session are UNVERIFIED (SPEC.md §8).`, true); return opened; } // --- identity setup ------------------------------------------------------------ // Nothing in the top bar works before an identity exists, so it stays hidden // rather than offering buttons that can only answer "no identity yet". function showSetup() { $("setup").hidden = false; $("app").hidden = true; $("search").hidden = true; $("top-actions").hidden = true; $("account").textContent = ""; } function showApp() { $("setup").hidden = true; $("app").hidden = false; $("search").hidden = false; $("top-actions").hidden = false; renderAccount(); renderFolder(); } $("create-identity").onclick = () => { store.setIdentity(newIdentity().seed); showApp(); status("identity created. Back up the seed in settings now; it is the only secret."); openSettings(); }; $("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)); try { store.setIdentity(seedBytes); } catch (err) { return status(err.message, true); } showApp(); // The key/address fields are both optional, and neither can block the // identity restore that already succeeded above — a typo, an offline server // or an unregistered address just leaves that one step to retry from // settings afterward. No separate host field: it's already the part of the // address after the @, so asking for it twice would just be one more place // to enter it wrong (exactly how "pinned randogoth@smol.place" happened — // a whole address typed into what was meant to be a bare host). Pinning has // to happen before recall is attempted, since recall requires a pin. const keyText = $("restore-key").value.trim(); const addressText = $("restore-address").value.trim(); const lines = ["identity restored."]; let warn = false; if (keyText) { if (!addressText) { lines.push("enter the address above too — its server is derived from the part after the @."); warn = true; } else { try { const host = parseAddress(addressText).host; pinServerFromText(host, keyText); lines.push(`pinned ${host}.`); } catch (err) { lines.push(err.message); warn = true; } } } if (addressText) { try { const addr = await recallAccount(addressText); lines.push(`recalled ${addr.short}.`); renderAccount(); renderSettings(); } catch (err) { lines.push(`couldn't recall that address: ${err.message}`); warn = true; } } status(lines.join("\n"), warn); }; // --- header --------------------------------------------------------------------- function renderAccount() { const me = store.identity(); const account = store.account(); $("account").textContent = me ? (account ? `${account.user}@${account.host}` : "not registered") : ""; $("avatar").textContent = account ? account.user[0].toUpperCase() : "·"; } // --- folders and list --------------------------------------------------------- let folder = "inbox"; let selectedId = null; let query = ""; // Bumped per render, so a superseded pass stops appending rows mid-flight. let renderToken = 0; // Rows opened per turn: a cold folder yields between batches rather than // blocking the tab for an X25519 and an Ed25519 verification per message. const BATCH = 20; for (const button of $("folders").querySelectorAll("button")) button.onclick = () => { folder = button.dataset.folder; for (const b of $("folders").querySelectorAll("button")) { const active = b === button; b.classList.toggle("active", active); if (active) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current"); } selectedId = null; resetView(); renderFolder(); }; // The reader empties when a folder changes, so a stale message cannot linger // under a different list. function resetView() { $("app").classList.remove("reading"); $("view").replaceChildren(placeholder(folder === "contacts" ? "contacts and their keys are listed alongside" : "select a message")); } // Counted from ids and read markers alone, independent of the visible folder — // leaving the inbox used to blank the badge. async function updateUnreadBadge() { $("count-inbox").textContent = (await store.unreadCount()) || ""; } async function renderFolder() { const list = $("list"); updateUnreadBadge(); // The toolbar carries the current folder's action, not a fixed one. const contacts = folder === "contacts"; $("mail-tools").hidden = contacts; $("import-form").hidden = !contacts; // The filter never reaches past the open folder, so the field says which one. $("search").placeholder = `search ${folder}`; $("search").setAttribute("aria-label", `search ${folder}`); if (contacts) return renderContacts(list); const token = ++renderToken; const rows = await store.listMessages(folder); // newest first if (token !== renderToken) return; list.replaceChildren(); if (!rows.length) { list.append(placeholder(folder === "inbox" ? "no mail — fetch" : "nothing sent")); return void updateListStatus(); } for (let offset = 0; offset < rows.length; offset += BATCH) { if (offset) await new Promise(resolve => setTimeout(resolve)); if (token !== renderToken) return; list.append(...rows.slice(offset, offset + BATCH).map(messageRow)); applyQuery(); } } // A real button, so every row is reachable and openable from the keyboard. function messageRow(row) { const opened = describe(row); // Only the inbox has a read state; sent copies were never "unread". const unread = folder === "inbox" && !store.isRead(row.id); const item = el("button", { type: "button", className: `item${row.id === selectedId ? " selected" : ""}${unread ? " unread" : ""}`, onclick: () => openMessage(row.id), }); if (row.id === selectedId) item.setAttribute("aria-current", "true"); const who = el("div", { className: "who", textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") }); if (folder === "inbox" && !opened.error && !opened.knownSender) who.append(el("span", { className: "dot", title: "sender key not bound to an address" })); item.append(who, el("div", { className: "mid" }, el("span", { className: "subject", textContent: opened.error ? `` : (opened.subject || "(no subject)") }), el("span", { className: "snippet", textContent: opened.error ? "" : ` — ${opened.snippet}` })), el("div", { className: "when", textContent: fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)) })); // Sender or recipient, their key, the subject and the whole body — not the // snippet, and not the timestamp. item.dataset.search = (opened.error ? `${row.recipient ?? ""} ${opened.error}` : [row.recipient ?? "", opened.from, b32encode(opened.sender), opened.subject, opened.body].join(" ") ).toLowerCase(); return item; } // Filters the current folder client-side; nothing leaves the page. Each row // carries its own haystack, built from the fields worth matching rather than // from whatever the row happens to render. Re-applied after every render, so a // redraw cannot silently drop it. function applyQuery() { for (const item of $("list").querySelectorAll(".item")) item.hidden = Boolean(query) && !(item.dataset.search ?? "").includes(query); updateListStatus(); } // The toolbar's right side, which also makes a narrowed search visible. function updateListStatus() { const items = [...$("list").querySelectorAll(".item")]; const shown = items.filter(item => !item.hidden).length; $("list-status").textContent = !items.length ? "" : shown === items.length ? `${items.length} message${items.length === 1 ? "" : "s"}` : `${shown} of ${items.length}`; } $("search").oninput = () => { query = $("search").value.trim().toLowerCase(); applyQuery(); }; // Opening an envelope costs an X25519 agreement and an Ed25519 verification, and // an envelope's plaintext never changes — so the result is kept. Failures are // cached too, so one bad message is not retried on every render. const openedCache = new Map(); function openEnvelope(row) { let entry = openedCache.get(row.id); if (!entry) { try { const opened = unseal(store.identities(), row.envelope); const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body)); entry = { ...opened, fields, body, subject: fields.Subject || "", snippet: body.replace(/\s+/g, " ").trim().slice(0, 140) }; } catch (err) { entry = { error: err.message }; } openedCache.set(row.id, entry); } return entry; } // What is known about a sender changes as the user binds addresses to keys, so // this layer sits over the cached envelope and is recomputed per render — it is // a map lookup, not crypto. The wire carries no sender name, so an unknown key // can only be shown as its fingerprint until bound, which a signed Reply-To // field lets the sender do in-band. function describe(row) { const opened = openEnvelope(row); if (opened.error) return opened; const known = store.addressForKey(opened.sender); let replyAddress = null; if (opened.fields["Reply-To"]) { try { const claimed = parseAddress(opened.fields["Reply-To"]); // Only a full smol:// address whose key matches the signer counts; // anything else is ordinary text. if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender)) replyAddress = claimed; } catch { /* malformed claim: display, never bind */ } } return { ...opened, from: known ?? "unknown sender", // rows stay narrow: the full fingerprint belongs to the reader fromShort: known ?? `unknown · ${b32encode(opened.sender).slice(0, 4)}`, knownSender: Boolean(known), verifiedSender: Boolean(known && store.contact(known)?.verified), replyAddress, }; } // A label/value row; `empty` marks a value that is guidance rather than data. const field = (label, value, empty = false, extra = null) => el("div", { className: "field" }, el("span", { className: "field-label", textContent: label }), el("span", { className: empty ? "field-value empty" : "field-value", textContent: value }), extra); // --- reading ------------------------------------------------------------------ async function openMessage(id) { const row = await store.getMessage(folder, id); if (!row) return; selectedId = id; store.markRead(id); renderFolder(); const opened = describe(row); const view = $("view"); // Under the narrow breakpoint the reader covers the list, so it needs a way back. $("app").classList.add("reading"); const back = el("button", { type: "button", className: "back", onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages"); if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true)); const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject"); const actions = el("div", { className: "actions" }); view.replaceChildren(...[ back, el("h2", { textContent: opened.subject || "(no subject)" }), el("div", { className: "meta", textContent: folder === "sent" ? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}` : `from: ${opened.from}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}` }), trustRow(opened), fields.length ? el("div", { className: "fields", textContent: fields.map(([k, v]) => `${k}: ${v}`).join("\n") }) : null, el("pre", { textContent: opened.body }), actions, ].filter(node => node != null)); if (folder !== "inbox") return; if (opened.knownSender) { actions.append(el("button", { type: "button", onclick: () => startReply(row) }, icon("reply"), "reply")); } else if (opened.replyAddress) { // The sender signed their own smol:// address into the payload, so binding // it is one click. A pinned conflicting key is refused (§8). actions.before(placeholder( `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); openMessage(row.id); startReply(row); } catch (err) { status(err.message, true); } } })); } else { // The wire gives only a signed key; a reply needs the sender's address, // which the user supplies once and we bind to that exact key. const input = el("input", { placeholder: "alice@example.org or smol://…", spellcheck: false }); const submit = el("button", { textContent: "name sender & reply" }); const form = el("form", { className: "name-sender" }, input, submit); form.onsubmit = async (event) => { event.preventDefault(); submit.disabled = true; try { await nameSender(input.value, opened.sender); openMessage(row.id); // the sender is named now; the reply button appears startReply(row); } catch (err) { status(err.message, true); } finally { submit.disabled = false; } }; actions.before(placeholder("The wire carries no sender name, only this signed key. " + "To reply, give the sender's address — it is checked against this key:")); actions.append(form); } } // §4/§8: a signature always verifies here, because unseal() throws otherwise. // What can be weak is the binding between that key and an address, so that is // what the chip reports. function trustRow(opened) { if (folder === "sent") return el("div", { className: "trust" }, el("span", { className: "badge", textContent: "your own sealed copy" })); const chip = opened.verifiedSender ? ["badge ok", "verified sender key"] : opened.knownSender ? ["badge", "key pinned on first use"] : ["badge alert", "key bound to no address"]; return el("div", { className: "trust" }, el("span", { className: chip[0], textContent: chip[1] }), el("div", { className: "keyblock" }, el("div", { textContent: fingerprint(opened.sender) }), el("div", { className: "full", textContent: b32encode(opened.sender) }))); } const replySubject = (subject) => !subject ? "" : subject.startsWith("Re:") ? subject : `Re: ${subject}`; // One path for all three ways a reply can start, so the Re: rule lives once. function startReply(row) { const opened = describe(row); openCompose({ to: store.addressForKey(opened.sender) ?? "", subject: replySubject(opened.subject), replyTo: row.id, }); } // Bind a user-supplied address to the key that signed a message. A smol:// // address carries its own key (verified); a short address is resolved and the // result kept on first use. Anything that binds a different key is refused. async function nameSender(text, senderKey) { const addr = parseAddress(text.trim()); let key = addr.identity, verified = true; if (!key) { const opened = await connect(addr, { requirePin: false }); try { ({ identity: key } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } verified = false; // trust on first use, as with any RESOLVE } if (!timingSafeEqual(key, senderKey)) throw new SmolError("that address carries a different key than this message's sender"); store.saveContact(addr.short, senderKey, verified); status(`${addr.short} saved for this sender${verified ? " (verified key)" : ""}`); 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) { 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 ----------------------------------------------------------- const compose = $("compose"); function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) { const form = compose.querySelector("form"); form.to.value = to; form.subject.value = subject; form.body.value = body; form.dataset.replyTo = replyTo ?? ""; $("compose-error").textContent = ""; compose.showModal(); } $("write").onclick = () => openCompose(); $("compose-cancel").onclick = () => compose.close(); // Every submission is a send: cancel is a plain button, and Enter in a field // submits with no submitter — which the old check read as cancel, discarding // the draft. compose.querySelector("form").onsubmit = async (event) => { event.preventDefault(); const form = event.target; const send = $("send"); $("compose-error").textContent = ""; send.disabled = true; // one click, one envelope: a second seals a second message try { await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo, form.elements.anonymous.checked); compose.close(); } catch (err) { $("compose-error").textContent = err.message; } finally { send.disabled = false; } }; async function sendMail(toText, subject, body, replyTo, anonymous) { const me = store.identity(); if (!me) throw new SmolError("no identity yet"); const addr = parseAddress(toText); const recipient = await resolveRecipient(addr); const account = accountAddress(); const fields = [["Subject", subject], ["In-Reply-To", replyTo]]; // A signed Reply-To lets a first-time recipient name and answer us (§5.5 // allows unknown keys); "anonymous" omits it. if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]); const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v), body.replace(/\s+$/, "") + "\n")); const envelope = seal(me, recipient, bodyBytes); const opened = await connect(addr, { requirePin: false }); try { await sendOp(opened.session, envelope); } finally { opened.session.stream.close(); } // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. await store.storeMessage("sent", { id: hex(messageId(envelope)), recipient: addr.short, envelope: seal(me, me.publicKey, bodyBytes), sentAt: Math.floor(Date.now() / 1000), }); status(`sent to ${addr.short}`); if (folder === "sent") renderFolder(); } // Prefer a key we already trust; fall back to RESOLVE with trust on first use. async function resolveRecipient(addr) { if (addr.identity) { store.saveContact(addr.short, addr.identity, true); return addr.identity; } const known = store.contact(addr.short); if (known) return known.key; const opened = await connect(addr, { requirePin: false }); let current; try { ({ identity: current } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } store.saveContact(addr.short, current, false); status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`); return current; } // --- fetch ---------------------------------------------------------------------- $("fetch").onclick = async () => { const me = store.identity(); const addr = accountAddress(); if (!me) return status("no identity yet", true); if (!addr) return status("not registered; register in settings first", true); const button = $("fetch"), label = button.querySelector(".label"); const resting = label.textContent; button.disabled = true; // one session at a time; the icon spins while it is label.textContent = "fetching…"; status("fetching…"); let stored = 0; const rejected = []; try { const opened = await connect(addr, { requirePin: true }); try { await authenticate(opened.session, opened.handshakeHash, addr.user, me); for (;;) { const records = await fetchOp(opened.session); if (!records.length) break; const acked = []; for (const record of records) { try { if (!timingSafeEqual(messageId(record.envelope), record.id)) throw new SmolError("id does not match the envelope"); 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 // closing summary would overwrite each one in turn. rejected.push(`${hex(record.id)}: ${err.message}`); continue; } if (await store.storeIfNew("inbox", { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt, })) stored++; acked.push(record.id); } if (!acked.length) break; await deleteOp(opened.session, acked); } } finally { opened.session.stream.close(); } status([`${stored} new, ${rejected.length} rejected`, ...rejected.map(line => ` ${line}`), rejected.length ? "left on the server" : null].filter(Boolean).join("\n"), rejected.length > 0); } catch (err) { status(err.message, true); } finally { button.disabled = false; label.textContent = resting; } renderFolder(); }; // --- contacts ------------------------------------------------------------------- function renderContacts(list) { const contacts = store.allContacts(); list.replaceChildren(...contacts.map(({ address, key, verified, history }) => { const item = el("button", { type: "button", className: `item contact${address === selectedId ? " selected" : ""}`, onclick: () => openContact(address), }); if (address === selectedId) item.setAttribute("aria-current", "true"); item.append( el("div", { className: "who", textContent: address }), el("div", { className: "mid" }, el("div", { textContent: fingerprint(b32decode(key)) }), el("span", { className: verified ? "badge ok" : "badge", textContent: verified ? "verified key" : "tofu" }), history.length ? el("span", { className: "badge", textContent: `${history.length} previous` }) : null)); item.dataset.search = `${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase(); return item; })); if (!contacts.length) list.append(placeholder("no contacts yet — import a smol:// address above, or write to someone")); } // The full key, the address to hand out, and any key this one replaced — the // row can only afford a fingerprint, and §8 turns on the user being able to // inspect exactly what changed. function openContact(address) { const known = store.contact(address); if (!known) return; selectedId = address; renderFolder(); const addr = parseAddress(address); const view = $("view"); $("app").classList.add("reading"); const refresh = el("button", { type: "button", onclick: async (event) => { event.target.disabled = true; try { await refreshContact(address); } finally { event.target.disabled = false; } } }, icon("refresh"), "re-resolve"); view.replaceChildren(...[ el("button", { type: "button", className: "back", onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"), el("h2", { textContent: address }), el("div", { className: "trust" }, el("span", { className: known.verified ? "badge ok" : "badge", textContent: known.verified ? "verified key" : "key pinned on first use" })), field("fingerprint", fingerprint(known.key)), field("public key", b32encode(known.key)), field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "the address")), known.history.length ? el("section", { className: "history" }, el("h3", { textContent: `previous keys (${known.history.length})` }), el("p", { className: "hint", textContent: "Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these was sent " + "before the change; rotation is not revocation, so a stolen key can still rotate onward." }), ...known.history.map(({ key, until }) => el("div", { className: "keyblock" }, el("div", { textContent: fingerprint(b32decode(key)) }), el("div", { className: "full", textContent: key }), el("div", { className: "replaced", textContent: `replaced ${fmtTime(Math.floor(until / 1000))}` })))) : null, el("div", { className: "actions" }, refresh), ].filter(node => node != null)); } // Re-resolve a contact and apply §8: a valid rotation chain is accepted and // surfaced; anything else requires out-of-band verification. async function refreshContact(address) { try { const addr = parseAddress(address); if (addr.identity) throw new SmolError("that address already carries a key; use import instead"); const known = store.contact(addr.short); const opened = await connect(addr, { requirePin: false }); let current, chain; try { ({ identity: current, chain } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } if (!known) { store.saveContact(addr.short, current, false); status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`); } else if (timingSafeEqual(known.key, current)) { status(`${addr.short}: key unchanged`); } else if (walkChain(known.key, current, chain)) { store.saveContact(addr.short, current, known.verified); status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true); } else { status(`${addr.short} presents a different key with no valid rotation chain.\n` + `Verify out of band, then import the new smol:// address.`, true); } } catch (err) { status(err.message, true); } if (folder === "contacts" && selectedId === address) openContact(address); else renderFolder(); } // --- settings --------------------------------------------------------------------- const settings = $("settings"); function openSettings() { renderSettings(); settings.showModal(); } $("settings-open").onclick = openSettings; $("settings-close").onclick = () => settings.close(); // Registering against an unpinned server is refused, so the dialog is ordered // by what depends on what; the chips say how far along that order you are. function step(id, done) { const chip = $(id); chip.classList.toggle("done", done); chip.textContent = done ? "\u2713" : chip.dataset.n; } function renderSettings() { const me = store.identity(); const account = store.account(); const pins = store.allPins(); step("step-server", pins.length > 0); step("step-address", Boolean(account)); $("pins").replaceChildren(...pins.map(({ host, key }) => el("li", {}, el("span", { textContent: host }), el("span", { className: "key", textContent: key }), el("button", { type: "button", textContent: "remove", onclick: () => { store.unpinServer(host); renderSettings(); } })))); if (!pins.length) $("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" })); const card = $("identity-card"); card.replaceChildren(); if (!me) return; const share = account ? accountAddress().uri(me.publicKey) : null; const retired = store.identities().length - 1; // Settings opens by itself right after an identity is created, which is a poor // moment to paint the only secret across the screen — so it is asked for. const masked = "\u2022".repeat(64); const seed = el("div", { className: "seed", textContent: masked }); const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => { const showing = seed.textContent !== masked; seed.textContent = showing ? masked : hex(me.seed); reveal.textContent = showing ? "reveal" : "hide"; } }); card.append(...[ field("fingerprint", fingerprint(me.publicKey)), field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account), field("shareable", share ?? "register above to get one", !share, share ? copyButton(share, "your address") : null), el("div", { className: "seed-card" }, el("h4", { textContent: "recovery seed" }), el("div", { className: "row" }, seed, reveal, copyButton(hex(me.seed), "the seed")), el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " + "write as you, and losing it loses the address with it — there is no reset. Copy it " + "somewhere safe now." })), field("retired keys", String(retired)), retired ? el("p", { className: "hint", textContent: "Kept so mail sealed to a superseded key stays readable." }) : null, el("details", {}, el("summary", { textContent: "advanced" }), el("p", { className: "hint", textContent: "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("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)); } // Shared by the settings pin form and the optional host/key fields on setup — // same validation, same unconditional pin. Both are direct, deliberate entry // of a specific key (unlike importData's conflict-averse merge), so overwrite // is the expected behavior here, not something to guard against. function pinServerFromText(hostText, keyText) { const host = hostText.trim().toLowerCase(); // A pin is keyed by bare host — connect() strips the port before looking one // up (parseAddress splits user@host:port into three), so "user@host" or // "host:port" here would silently pin a string nothing ever looks up again, // exactly the mistake that produced "pinned
" followed by a // confusing "no pinned key" from recall right after. if (host.includes("@")) throw new SmolError(`"${host}" looks like an address, not a server — the part after the @ goes here`); if (host.includes(":")) throw new SmolError(`"${host}": a pin is by host only, no port — just the part before the colon`); const key = b32decode(keyText.trim()); if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`); store.pinServer(host, key); return host; } $("pin-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; try { const host = pinServerFromText(form.host.value, form.key.value); status(`pinned ${host}`); renderSettings(); form.reset(); } catch (err) { status(err.message, true); } }; $("import-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; try { const addr = parseAddress(form.uri.value.trim()); if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key"); store.saveContact(addr.short, addr.identity, true); status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` + `fingerprint: ${fingerprint(addr.identity)}`); renderFolder(); form.reset(); } catch (err) { status(err.message, true); } }; $("register-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 }); try { await registerOp(opened.session, { username: addr.user, identity: me, token: form.token.value.trim() }); } finally { opened.session.stream.close(); } store.setAccount(addr); status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); renderAccount(); renderSettings(); form.reset(); } catch (err) { status(err.message, true); } }; // Restoring a seed brings back the identity, not the memory of what address a // *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. 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(); const addr = await recallAccount(form.address.value); status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); renderAccount(); renderSettings(); form.reset(); } catch (err) { status(err.message, true); } }; $("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(); if (!me || !addr) return status("rotate needs a registered account", true); if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " + "chain. The old key is kept, since mail sealed to it stays readable with nothing else.")) return; try { const fresh = newIdentity(); const cert = makeCert(me, fresh.seed); const opened = await connect(addr, { requirePin: true }); try { await registerOp(opened.session, { username: addr.user, identity: fresh, cert }); } finally { opened.session.stream.close(); } store.rotateIdentity(fresh.seed); // The key set grew, so a cached "not one of our keys" failure may be stale. openedCache.clear(); status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`); renderAccount(); renderSettings(); renderFolder(); } catch (err) { status(err.message, true); } } // 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`), // which only makes sense when whoever serves this page and whoever runs that // smolmaild are the same trusted party: the value arrives over this // deployment's own TLS, which is the trusted channel SPEC.md §4 asks a pin to // come from. This only seeds the first run — once written it is an ordinary // pin, editable and removable in settings like any other, and never // overwrites a host the user (or a previous boot) already pinned. function applyPresetServer() { if (!PRESET_SERVER || store.serverPin(PRESET_SERVER.host)) return; try { store.pinServer(PRESET_SERVER.host, b32decode(PRESET_SERVER.publicKey)); } catch { /* malformed preset: leave unpinned rather than block boot */ } } // A preset deployment already knows its own server, so asking again on the // setup screen would be redundant — and confusing in the other direction // ("why do I need to type this if it's supposed to be automatic?"). A generic // deployment has no preset, so this stays the only way to pin from onboarding. if (PRESET_SERVER) $("restore-key-label").hidden = true; applyPresetServer(); applyTheme(); store.identity() ? showApp() : showSetup(); status(store.identity() ? "" : "create an identity to begin");