gsmol/web/js/app.js

964 lines
38 KiB
JavaScript

// 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 = () => {
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();
status("identity restored.");
};
// --- 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
? `<unreadable: ${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 })),
].filter(node => node != null));
}
$("pin-form").onsubmit = (event) => {
event.preventDefault();
const form = event.target;
try {
const host = form.host.value.trim().toLowerCase();
const key = b32decode(form.key.value.trim());
if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`);
store.pinServer(host, key);
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.
$("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);
status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
renderAccount();
renderSettings();
form.reset();
} catch (err) {
status(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);
}
}
// --- 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 */ }
}
applyPresetServer();
applyTheme();
store.identity() ? showApp() : showSetup();
status(store.identity() ? "" : "create an identity to begin");