Generate-or-recover an identity, establish server trust, then bind an address — each its own screen with no bleed-through, replacing the old intro/restore cards plus the post-hoc setup checklist banner.
1582 lines
63 KiB
JavaScript
1582 lines
63 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, unhex, utf8Bytes } from "./crypto.js";
|
|
import {
|
|
DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode,
|
|
b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed,
|
|
makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp,
|
|
resolveOp, seal, sendOp, tokenFor, 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 });
|
|
|
|
// A native <dialog> shown with showModal() renders in the browser's top
|
|
// layer, above the rest of the page — a toast anchored to <body> sits
|
|
// visually behind an open dialog's backdrop no matter its z-index. Keeping
|
|
// the toast inside whichever dialog is open puts it in that same top-layer
|
|
// subtree, so a status raised while settings or compose is open (e.g. after
|
|
// "register") actually gets seen instead of landing, barely visible, behind it.
|
|
function reparentStatus() {
|
|
const toast = $("status");
|
|
const target = document.querySelector("dialog[open]") ?? document.body;
|
|
if (toast.parentElement !== target) target.append(toast);
|
|
}
|
|
for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("close", reparentStatus);
|
|
|
|
// 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) {
|
|
reparentStatus();
|
|
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 under settings → server.`);
|
|
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` +
|
|
`Lookups from this session are UNVERIFIED.`, 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();
|
|
}
|
|
|
|
// Generate-or-recover an identity, establish trust in a server, then bind a
|
|
// name to it — three concerns, three screens, no bleed-through between them.
|
|
const SETUP_SECTIONS = {
|
|
intro: "setup-intro",
|
|
"seed-shown": "setup-seed",
|
|
"seed-restore": "setup-restore",
|
|
"server-setup": "setup-server",
|
|
"account-setup": "setup-account",
|
|
};
|
|
|
|
function showSetupStage(stage) {
|
|
for (const [key, id] of Object.entries(SETUP_SECTIONS)) $(id).hidden = key !== stage;
|
|
if (stage === "server-setup") renderServerScreen();
|
|
}
|
|
|
|
// Discards the identity screens 2 and 4 can still walk away from — nothing is
|
|
// bound to an account yet, so nothing is lost: the same seed typed again on
|
|
// screen 3 regenerates the identical identity.
|
|
function discardSetupIdentity() {
|
|
store.discardIdentity();
|
|
serverFetchedKey = null;
|
|
$("server-host").value = "";
|
|
showSetupStage("intro");
|
|
}
|
|
|
|
// A deployment that baked in a server key already knows its own host, so
|
|
// screen 4 starts from it rather than asking again.
|
|
function enterServerSetup() {
|
|
if (!$("server-host").value && PRESET_SERVER) $("server-host").value = PRESET_SERVER.host;
|
|
showSetupStage("server-setup");
|
|
}
|
|
|
|
$("create-identity").onclick = () => {
|
|
const seed = newMaster();
|
|
store.setIdentity(seed);
|
|
$("setup-seed-value").textContent = hex(seed);
|
|
showSetupStage("seed-shown");
|
|
};
|
|
|
|
$("show-restore").onclick = () => showSetupStage("seed-restore");
|
|
|
|
$("seed-saved").onclick = () => {
|
|
store.setMasterSaved(true);
|
|
enterServerSetup();
|
|
};
|
|
$("seed-back").onclick = () => discardSetupIdentity();
|
|
|
|
// A master secret in either of its two interchangeable encodings: 64 hex
|
|
// characters, or the base32 form used everywhere else a key is shown.
|
|
function parseSeedText(text) {
|
|
text = text.trim();
|
|
if (/^[0-9a-fA-F]{64}$/.test(text)) return unhex(text);
|
|
try {
|
|
const bytes = b32decode(text);
|
|
if (bytes.length === 32) return bytes;
|
|
} catch { /* fall through to the error below */ }
|
|
throw new SmolError("not a valid secret — paste the 64 hex characters or the base32 form");
|
|
}
|
|
|
|
$("restore-form").onsubmit = (event) => {
|
|
event.preventDefault();
|
|
$("restore-error").textContent = "";
|
|
let seed;
|
|
try {
|
|
seed = parseSeedText($("restore-master").value);
|
|
} catch (err) {
|
|
$("restore-error").textContent = err.message;
|
|
return;
|
|
}
|
|
// §2: a master alone does not say which rotation index a server has bound;
|
|
// account-setup's "existing address" mode discovers it once a server can be
|
|
// asked. Either way the accepted-correspondent set is unknown, so it must
|
|
// not overwrite the server's (syncOk: false).
|
|
store.restoreMaster(seed, 0);
|
|
enterServerSetup();
|
|
};
|
|
|
|
$("restore-back").onclick = () => {
|
|
$("restore-master").value = "";
|
|
$("restore-error").textContent = "";
|
|
showSetupStage("intro");
|
|
};
|
|
|
|
// Holds a key fetched this screen visit but not yet pinned, tied to the host
|
|
// it came from — editing the host field makes renderServerScreen() stop
|
|
// showing it, since it only ever matches the host currently typed.
|
|
let serverFetchedKey = null;
|
|
// The host screen 4 ends on, carried into screen 5 to build the full address.
|
|
let setupHost = null;
|
|
|
|
function renderServerScreen() {
|
|
const host = $("server-host").value.trim().toLowerCase();
|
|
const pinned = host ? store.serverPin(host) : null;
|
|
const fetched = host && serverFetchedKey?.host === host ? serverFetchedKey.key : null;
|
|
const key = pinned ?? fetched;
|
|
const area = $("server-key-area");
|
|
area.hidden = !key;
|
|
area.replaceChildren();
|
|
if (key) {
|
|
area.append(
|
|
field("fingerprint", fingerprint(key)),
|
|
field("key", b32encode(key)),
|
|
el("p", { className: "hint", textContent: "If the server operator published a key or " +
|
|
"fingerprint, make sure it matches before trusting it." }));
|
|
}
|
|
$("server-action").textContent = key ? "continue" : "fetch the server's key";
|
|
$("server-error").textContent = "";
|
|
}
|
|
|
|
$("server-host").oninput = renderServerScreen;
|
|
|
|
function setServerBusy(busy) {
|
|
for (const node of [$("server-host"), $("server-action"), $("server-back")]) node.disabled = busy;
|
|
}
|
|
|
|
$("server-form").onsubmit = async (event) => {
|
|
event.preventDefault();
|
|
$("server-error").textContent = "";
|
|
let host;
|
|
try {
|
|
host = cleanHost($("server-host").value);
|
|
} catch (err) {
|
|
$("server-error").textContent = err.message;
|
|
return;
|
|
}
|
|
const pinned = store.serverPin(host);
|
|
const fetched = serverFetchedKey?.host === host ? serverFetchedKey.key : null;
|
|
if (pinned || fetched) {
|
|
if (!pinned) store.pinServer(host, fetched);
|
|
serverFetchedKey = null;
|
|
setupHost = host;
|
|
showSetupStage("account-setup");
|
|
return;
|
|
}
|
|
setServerBusy(true);
|
|
try {
|
|
// Bypassed connect(): its "UNVERIFIED" status toast is right everywhere
|
|
// else a session proceeds unpinned, but here the whole point of the
|
|
// fetch is that nothing is pinned yet — the toast would just sit over
|
|
// the button that is about to pin it.
|
|
const stream = await connectStream(host, DEFAULT_PORT);
|
|
const opened = await openSession(stream, host, null);
|
|
opened.session.stream.close();
|
|
serverFetchedKey = { host, key: opened.serverStatic };
|
|
renderServerScreen();
|
|
} catch (err) {
|
|
$("server-error").textContent = err.message;
|
|
} finally {
|
|
setServerBusy(false);
|
|
}
|
|
};
|
|
|
|
$("server-back").onclick = () => discardSetupIdentity();
|
|
|
|
let accountMode = "new";
|
|
|
|
function setAccountMode(mode) {
|
|
accountMode = mode;
|
|
for (const button of document.querySelectorAll("#account-mode button"))
|
|
button.classList.toggle("active", button.dataset.mode === mode);
|
|
$("account-token-label").hidden = mode !== "new";
|
|
$("account-action").textContent = mode === "new" ? "register" : "restore access";
|
|
$("account-error").textContent = "";
|
|
}
|
|
|
|
for (const button of document.querySelectorAll("#account-mode button"))
|
|
button.onclick = () => setAccountMode(button.dataset.mode);
|
|
|
|
function setAccountBusy(busy) {
|
|
for (const node of [$("account-user"), $("account-token"), $("account-action"), $("account-back"),
|
|
...document.querySelectorAll("#account-mode button")])
|
|
node.disabled = busy;
|
|
}
|
|
|
|
$("account-form").onsubmit = async (event) => {
|
|
event.preventDefault();
|
|
$("account-error").textContent = "";
|
|
const me = store.identity();
|
|
const button = $("account-action");
|
|
const resting = button.textContent;
|
|
let addr;
|
|
try {
|
|
addr = parseAddress(`${$("account-user").value.trim()}@${setupHost}`);
|
|
} catch (err) {
|
|
$("account-error").textContent = err.message;
|
|
return;
|
|
}
|
|
setAccountBusy(true);
|
|
button.textContent = accountMode === "new" ? "registering…" : "restoring…";
|
|
try {
|
|
if (accountMode === "new") {
|
|
const opened = await connect(addr, { requirePin: true });
|
|
try {
|
|
await registerOp(opened.session, opened.serverStatic,
|
|
{ username: addr.user, identity: me, token: $("account-token").value.trim() });
|
|
} finally {
|
|
opened.session.stream.close();
|
|
}
|
|
store.setAccount(addr);
|
|
} else {
|
|
await recallAndDiscoverRotation(store.master(), addr.short);
|
|
}
|
|
showApp();
|
|
status(`welcome, ${addr.short}`);
|
|
} catch (err) {
|
|
$("account-error").textContent = err.message;
|
|
} finally {
|
|
setAccountBusy(false);
|
|
button.textContent = resting;
|
|
}
|
|
};
|
|
|
|
$("account-back").onclick = () => showSetupStage("server-setup");
|
|
|
|
// --- 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()) || "";
|
|
$("count-requests").textContent = (await store.requestsUnreadCount()) || "";
|
|
}
|
|
|
|
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"
|
|
: folder === "requests" ? "no unsolicited mail" : "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);
|
|
// Both mail tiers have a read state; sent copies were never "unread".
|
|
const inbound = folder === "inbox" || folder === "requests";
|
|
const unread = inbound && !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 (inbound && !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));
|
|
|
|
// "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content.
|
|
const fields = Object.entries(opened.fields).filter(([k]) => k !== "subject" && k !== "accept");
|
|
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));
|
|
|
|
// Deleting removes the local copy, and the server's too if "leave mail on
|
|
// server" left one there to remove (settings; SPEC.md §10).
|
|
actions.append(el("button", { type: "button", onclick: async () => {
|
|
try {
|
|
await deleteMessage(folder, row);
|
|
status("message deleted");
|
|
selectedId = null;
|
|
resetView();
|
|
renderFolder();
|
|
} catch (err) {
|
|
status(err.message, true); // the local copy is kept if a needed server delete failed
|
|
}
|
|
} }, "delete"));
|
|
|
|
if (folder !== "inbox" && folder !== "requests") return;
|
|
|
|
if (folder === "requests" && opened.knownSender) {
|
|
const known = store.addressForKey(opened.sender);
|
|
if (!store.accepted(known)?.active) {
|
|
actions.before(placeholder(
|
|
`unsolicited mail from ${known}, not yet accepted into your main tier`));
|
|
actions.append(el("button", { type: "button", onclick: () => acceptContact(known) },
|
|
"accept — future mail lands in your main tier"));
|
|
}
|
|
}
|
|
|
|
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 {
|
|
bindReplyAddress(opened.replyAddress, opened.sender);
|
|
status(`${opened.replyAddress.short} saved for this sender (verified key)`);
|
|
renderFolder();
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Deletes a message locally, and from the server too if it might still be
|
|
// sitting there (only possible when "leave mail on server" was on when it was
|
|
// fetched — SPEC.md §10). Sent copies are local-only; there is nothing
|
|
// server-side to remove for them (§5.6). Throws, leaving the local copy in
|
|
// place, if a needed server-side delete fails — otherwise a message could
|
|
// look gone locally while silently persisting on the server.
|
|
async function deleteMessage(folder, row) {
|
|
if (folder !== "sent" && row.keptOnServer) {
|
|
const me = store.identity();
|
|
const addr = accountAddress();
|
|
if (!me || !addr) throw new SmolError("not registered; cannot reach the server to delete this message");
|
|
const opened = await connect(addr, { requirePin: true });
|
|
try {
|
|
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
|
await deleteOp(opened.session, [unhex(row.id)]);
|
|
} finally {
|
|
opened.session.stream.close();
|
|
}
|
|
}
|
|
await store.removeMessage(folder, row.id);
|
|
}
|
|
|
|
// §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, 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);
|
|
}
|
|
|
|
// --- 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();
|
|
const master = store.master();
|
|
if (!me || !master) 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)]);
|
|
// §5.8: hand an accepted correspondent the token for our own mailbox, so a
|
|
// first reply from them reaches our main tier.
|
|
const accepted = store.accepted(addr.short);
|
|
if (accepted?.active) fields.push(["Accept", b32encode(tokenFor(master, accepted.identity))]);
|
|
const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v),
|
|
body.replace(/\s+$/, "") + "\n"));
|
|
const envelope = seal(me, recipient, bodyBytes);
|
|
// §5.8: our token for their mailbox, if they have given us one.
|
|
const held = store.tokenFrom(addr.short);
|
|
const mac = held ? acceptMac(held, messageId(envelope)) : null;
|
|
const opened = await connect(addr, { requirePin: false });
|
|
try {
|
|
await sendOp(opened.session, envelope, mac);
|
|
} 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}${mac ? " (accepted)" : ""}`);
|
|
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 ----------------------------------------------------------------------
|
|
|
|
// The address we know a signer by: a contact, or the Reply-To it signed for
|
|
// itself. Naming a mailbox is not trusting a key, so nothing is pinned here
|
|
// (§5.7, §8).
|
|
function addressOfSigner(sender, replyToField) {
|
|
const known = store.addressForKey(sender);
|
|
if (known) return known;
|
|
if (!replyToField) return null;
|
|
try {
|
|
const parsed = parseAddress(replyToField);
|
|
if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short;
|
|
} catch { /* malformed claim: no address to learn a token under */ }
|
|
return null;
|
|
}
|
|
|
|
// §5.8: an Accept field is bound to the signer of the message that carried
|
|
// it, which unseal() has already verified.
|
|
function learnTokenFrom(unsealed, opened) {
|
|
const raw = opened.fields["accept"];
|
|
if (!raw) return;
|
|
let token;
|
|
try {
|
|
token = b32decode(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (token.length !== TOKEN_LEN) return;
|
|
const address = addressOfSigner(unsealed.sender, opened.fields["reply-to"]);
|
|
if (address) store.learnToken(address, token);
|
|
}
|
|
|
|
$("fetch").onclick = async () => {
|
|
const me = store.identity();
|
|
const master = store.master();
|
|
const addr = accountAddress();
|
|
if (!me || !master) return status("no identity yet", true);
|
|
if (!addr) return status("not registered yet — claim an address in settings", 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, verified = 0;
|
|
const rejected = [];
|
|
// §10: acknowledging (deleting) is the default; "leave mail on server" pages
|
|
// forward by cursor instead, so already-fetched mail is never re-downloaded
|
|
// even though it isn't deleted (storeIfNew also dedupes, as a second line
|
|
// of defense).
|
|
const leaveOnServer = store.leaveOnServer();
|
|
let [afterTime, afterId] = store.cursor();
|
|
try {
|
|
const opened = await connect(addr, { requirePin: true });
|
|
try {
|
|
const { sync, tokens } = store.tokenSet(master);
|
|
await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
|
for (;;) {
|
|
const records = await fetchOp(opened.session, afterTime, afterId);
|
|
if (!records.length) break;
|
|
const acked = [];
|
|
for (const record of records) {
|
|
afterTime = record.receivedAt;
|
|
afterId = record.id;
|
|
let unsealed;
|
|
try {
|
|
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
|
throw new SmolError("id does not match the 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
|
|
// closing summary would overwrite each one in turn.
|
|
rejected.push(`${hex(record.id)}: ${err.message}`);
|
|
continue;
|
|
}
|
|
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
|
|
tier: record.isRequest ? store.TIER_REQUESTS : store.TIER_MAIN, keptOnServer: leaveOnServer };
|
|
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 described = describe(row);
|
|
if (described.replyAddress) {
|
|
try {
|
|
bindReplyAddress(described.replyAddress, unsealed.sender);
|
|
verified++;
|
|
} catch { /* already known under a different key: leave it be */ }
|
|
}
|
|
learnTokenFrom(unsealed, described);
|
|
}
|
|
acked.push(record.id);
|
|
}
|
|
if (leaveOnServer) {
|
|
// Persisted per batch, so an interrupted fetch resumes here rather
|
|
// than re-paging from the start next time.
|
|
store.setCursor(afterTime, afterId);
|
|
} else if (acked.length) {
|
|
await deleteOp(opened.session, acked);
|
|
}
|
|
}
|
|
} finally {
|
|
opened.session.stream.close();
|
|
}
|
|
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
|
// record left on the server (rejected above) simply resurfaces then.
|
|
if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN));
|
|
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) {
|
|
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 accepted = store.accepted(address);
|
|
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" }),
|
|
accepted?.active ? el("span", { className: "badge ok", textContent: "accepted" }) : null,
|
|
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");
|
|
|
|
const isAccepted = Boolean(store.accepted(address)?.active);
|
|
const tokenButton = el("button", { type: "button",
|
|
textContent: isAccepted ? "block" : "accept",
|
|
onclick: async (event) => {
|
|
event.target.disabled = true;
|
|
try {
|
|
if (isAccepted) await blockContact(address); else await acceptContact(address);
|
|
} finally {
|
|
event.target.disabled = false;
|
|
}
|
|
} });
|
|
|
|
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")),
|
|
el("div", { className: "seed-card" },
|
|
el("h4", { textContent: "accept token" }),
|
|
el("p", { className: "hint", textContent: isAccepted
|
|
? "This contact's mail lands in your main tier."
|
|
: "This contact's mail lands in requests until accepted." }),
|
|
el("div", { className: "actions" }, tokenButton)),
|
|
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. 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(addr.user, 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();
|
|
}
|
|
|
|
// --- accept tokens (§5.8) --------------------------------------------------------
|
|
|
|
// An accept or a block only takes effect once the server holds the changed
|
|
// set, so it is pushed right away rather than at the next fetch.
|
|
async function pushTokens() {
|
|
const me = store.identity();
|
|
const master = store.master();
|
|
const addr = accountAddress();
|
|
if (!me || !master) throw new SmolError("no identity yet");
|
|
if (!addr) {
|
|
status("not registered; the set will be pushed with your first fetch", true);
|
|
return 0;
|
|
}
|
|
const opened = await connect(addr, { requirePin: true });
|
|
try {
|
|
const { sync, tokens } = store.tokenSet(master);
|
|
return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
|
} finally {
|
|
opened.session.stream.close();
|
|
}
|
|
}
|
|
|
|
// Admit a contact to the main tier; their token travels in our next message
|
|
// to them.
|
|
async function acceptContact(address) {
|
|
try {
|
|
const key = store.contact(address)?.key;
|
|
if (!key) throw new SmolError(`no key for ${address} yet`);
|
|
store.accept(address, key);
|
|
store.setSyncOk(true);
|
|
const held = await pushTokens();
|
|
status(`${address} accepted; server now holds ${held} accept token(s)`);
|
|
renderFolder();
|
|
if (folder === "contacts" && selectedId === address) openContact(address);
|
|
} catch (err) {
|
|
status(err.message, true);
|
|
}
|
|
}
|
|
|
|
// Withdraw a contact's accept token; their mail lands in requests from their
|
|
// next message on.
|
|
async function blockContact(address) {
|
|
try {
|
|
store.block(address);
|
|
const held = await pushTokens();
|
|
status(`${address} blocked; server now holds ${held} accept token(s)`);
|
|
renderFolder();
|
|
if (folder === "contacts" && selectedId === address) openContact(address);
|
|
} catch (err) {
|
|
status(err.message, true);
|
|
}
|
|
}
|
|
|
|
// --- settings ---------------------------------------------------------------------
|
|
|
|
const settings = $("settings");
|
|
const settingsTabs = [...$("settings-tabs").querySelectorAll("button")];
|
|
const settingsPanels = [...$("settings-panels").querySelectorAll(".panel")];
|
|
|
|
function showTab(name) {
|
|
for (const button of settingsTabs) {
|
|
const active = button.dataset.tab === name;
|
|
button.classList.toggle("active", active);
|
|
if (active) button.setAttribute("aria-current", "true");
|
|
else button.removeAttribute("aria-current");
|
|
}
|
|
for (const panel of settingsPanels) panel.hidden = panel.dataset.panel !== name;
|
|
$("settings-panels").scrollTop = 0;
|
|
}
|
|
|
|
for (const button of settingsTabs) button.onclick = () => showTab(button.dataset.tab);
|
|
|
|
function openSettings() {
|
|
renderSettings();
|
|
showTab("account");
|
|
if (!settings.open) settings.showModal();
|
|
// showModal() focuses the first focusable node, which is the close button;
|
|
// the current tab says where you are instead.
|
|
$("settings-tabs").querySelector("button.active").focus();
|
|
}
|
|
|
|
$("settings-open").onclick = () => openSettings();
|
|
$("settings-close").onclick = () => settings.close();
|
|
|
|
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
|
|
|
|
// --- settings panels --------------------------------------------------------------
|
|
|
|
// One renderer per panel, all of them cheap, so every mutation below can just
|
|
// call this rather than reason about which panel it touched.
|
|
function renderSettings() {
|
|
$("leave-on-server").checked = store.leaveOnServer();
|
|
renderAccountPanel();
|
|
renderServerPanel();
|
|
renderAdvancedPanel();
|
|
}
|
|
|
|
function renderServerPanel() {
|
|
const pins = store.allPins();
|
|
$("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" }));
|
|
}
|
|
|
|
function renderAccountPanel() {
|
|
const me = store.identity();
|
|
const account = store.account();
|
|
|
|
// Unregistered, claiming an address is the whole point of this panel;
|
|
// registered, it is a rarely wanted escape hatch, so it folds away.
|
|
$("account-claim").open = !account;
|
|
$("account-claim-summary").textContent = account ? "use a different address" : "claim your address";
|
|
|
|
const card = $("account-card");
|
|
card.replaceChildren();
|
|
const secret = $("identity-card");
|
|
secret.replaceChildren();
|
|
if (!me) return;
|
|
|
|
const share = account ? accountAddress().uri(me.publicKey) : null;
|
|
card.append(
|
|
field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account),
|
|
field("fingerprint", fingerprint(me.publicKey)),
|
|
field("share", share ?? "claim an address to get one", !share,
|
|
share ? copyButton(share, "your address") : null));
|
|
|
|
const masterBytes = store.master();
|
|
if (!masterBytes) return;
|
|
// Revealed on request rather than on open: the panel a new user is sent to
|
|
// first is a poor place to paint the only secret there is across the screen.
|
|
const masked = "\u2022".repeat(64);
|
|
const master = el("div", { className: "seed", textContent: masked });
|
|
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
|
|
const showing = master.textContent !== masked;
|
|
master.textContent = showing ? masked : hex(masterBytes);
|
|
reveal.textContent = showing ? "reveal" : "hide";
|
|
} });
|
|
|
|
secret.append(
|
|
el("div", { className: "seed-card" },
|
|
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the secret")),
|
|
el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " +
|
|
"it loses the address with it — there is no reset. Keep it in a password manager." })),
|
|
el("label", { className: "check-row" },
|
|
el("input", { type: "checkbox", checked: store.masterSaved(), onchange: (event) => {
|
|
store.setMasterSaved(event.target.checked);
|
|
} }),
|
|
el("span", {},
|
|
el("span", { className: "check-title", textContent: "I have saved it somewhere safe" }),
|
|
el("span", { className: "hint", textContent: "Only ticks the setup checklist off — " +
|
|
"nothing here can check, so say so once it is true." }))));
|
|
}
|
|
|
|
function renderAdvancedPanel() {
|
|
const card = $("advanced-card");
|
|
card.replaceChildren();
|
|
const me = store.identity();
|
|
if (!me) return;
|
|
const rotations = store.rotations();
|
|
const acceptedCount = store.allAccepted().filter(a => a.active).length;
|
|
|
|
card.append(...[
|
|
el("h3", { textContent: "this identity" }),
|
|
field("rotations", String(rotations)),
|
|
field("accepted", String(acceptedCount) +
|
|
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
|
|
rotations ? el("p", { className: "hint", textContent:
|
|
"Earlier keys are re-derived on demand from your secret, so mail sealed to a superseded key " +
|
|
"stays readable." }) : null,
|
|
|
|
el("h3", { textContent: "rotate" }),
|
|
el("p", { className: "hint", textContent:
|
|
"Issues a fresh key — the next one derivable from your secret — with a signed " +
|
|
"certificate proving it replaces this one. Contacts accept the change from that chain, and " +
|
|
"the old key stays readable. Rotation is not revocation." }),
|
|
el("button", { type: "button", className: "outline", textContent: "rotate identity",
|
|
onclick: rotateIdentity }),
|
|
|
|
el("h3", { className: "danger-head", textContent: "log out" }),
|
|
el("p", { className: "hint", textContent:
|
|
"Removes this identity from this browser entirely: the secret, pins, contacts and every " +
|
|
"cached message. Without a backup of the secret there is no way back in. Mail already " +
|
|
"fetched here may have no other copy anywhere, unless \u201cleave mail on server\u201d was on; " +
|
|
"whatever is still on the server comes back on the first fetch after logging back in." }),
|
|
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
|
|
onclick: logout }),
|
|
].filter(node => node != null));
|
|
}
|
|
|
|
// 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 <address>" followed by a
|
|
// confusing "no pinned key" from recall right after. Shared by the settings
|
|
// pin form and the onboarding server screen.
|
|
function cleanHost(hostText) {
|
|
const host = hostText.trim().toLowerCase();
|
|
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`);
|
|
return host;
|
|
}
|
|
|
|
// 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 = cleanHost(hostText);
|
|
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;
|
|
const submit = form.querySelector("button");
|
|
const resting = submit.textContent;
|
|
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
|
submit.textContent = "registering…";
|
|
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, opened.serverStatic,
|
|
{ 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);
|
|
} finally {
|
|
submit.disabled = false;
|
|
submit.textContent = resting;
|
|
}
|
|
};
|
|
|
|
// Restoring a master 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;
|
|
}
|
|
|
|
// §2: a master alone does not say which rotation index a server has bound,
|
|
// so restoring resolves the address and walks indices 0..MAX_CHAIN until one
|
|
// derives the key RESOLVE returned, correcting restore-identity's default
|
|
// guess of index 0. Also binds "account", like recallAccount().
|
|
async function recallAndDiscoverRotation(masterBytes, text) {
|
|
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();
|
|
}
|
|
let found = null;
|
|
for (let n = 0; n <= MAX_CHAIN; n++) {
|
|
if (timingSafeEqual(identityFromSeed(identitySeed(masterBytes, n)).publicKey, current)) {
|
|
found = n;
|
|
break;
|
|
}
|
|
}
|
|
if (found === null)
|
|
throw new SmolError(`the key bound to ${addr.short} is not derived from this master ` +
|
|
`within ${MAX_CHAIN} rotations`);
|
|
store.setRotationIndex(found);
|
|
store.setAccount(addr);
|
|
return addr;
|
|
}
|
|
|
|
$("recall-form").onsubmit = async (event) => {
|
|
event.preventDefault();
|
|
const form = event.target;
|
|
const submit = form.querySelector("button");
|
|
const resting = submit.textContent;
|
|
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
|
submit.textContent = "recalling…";
|
|
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);
|
|
} finally {
|
|
submit.disabled = false;
|
|
submit.textContent = resting;
|
|
}
|
|
};
|
|
|
|
$("export-data").onclick = async () => {
|
|
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 master restored can open it");
|
|
} catch (err) {
|
|
status(err.message, true);
|
|
}
|
|
};
|
|
|
|
$("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);
|
|
}
|
|
};
|
|
|
|
// §7: rotate to the next index's derived key and rebind the account with a
|
|
// signed certificate. The superseded key stays derivable from the master,
|
|
// since mail sealed to it stays readable with nothing else.
|
|
async function rotateIdentity(event) {
|
|
const me = store.identity();
|
|
const master = store.master();
|
|
const addr = accountAddress();
|
|
if (!me || !master || !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;
|
|
const button = event.target;
|
|
button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
|
button.textContent = "rotating…";
|
|
try {
|
|
const freshSeed = identitySeed(master, store.rotations() + 1);
|
|
const fresh = identityFromSeed(freshSeed);
|
|
const cert = makeCert(addr.user, me, freshSeed);
|
|
const opened = await connect(addr, { requirePin: true });
|
|
try {
|
|
await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert });
|
|
} finally {
|
|
opened.session.stream.close();
|
|
}
|
|
store.advanceRotation();
|
|
// 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(); // rebuilds the advanced panel, this button included
|
|
renderFolder();
|
|
} catch (err) {
|
|
button.disabled = false;
|
|
button.textContent = "rotate identity";
|
|
status(err.message, true);
|
|
}
|
|
}
|
|
|
|
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
|
async function logout() {
|
|
if (!window.confirm(
|
|
"Log out and erase this browser?\n\n" +
|
|
"This deletes the secret, pins, contacts and every cached message from this browser. Make sure " +
|
|
"the secret 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 */ }
|
|
}
|
|
|
|
applyPresetServer();
|
|
applyTheme();
|
|
|
|
// An identity not yet bound to an account always resumes at the server
|
|
// screen, however it was reached — there is nothing a banner needs to remind
|
|
// the user of afterward, since showApp() is unreachable until both are done.
|
|
if (!store.identity()) {
|
|
showSetupStage("intro");
|
|
showSetup();
|
|
} else if (!store.account()) {
|
|
enterServerSetup();
|
|
showSetup();
|
|
} else {
|
|
showApp();
|
|
}
|