gsmol/web/js/app.js

611 lines
23 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";
const $ = (id) => document.getElementById(id);
function status(text, warn = false) {
$("status").textContent = text;
$("status").className = warn ? "status warn" : "status";
}
const fmtTime = (seconds) =>
new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
// 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}`);
}
// --- 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 ------------------------------------------------------------
function showSetup() {
$("setup").hidden = false;
$("app").hidden = true;
$("account").textContent = "";
}
function showApp() {
$("setup").hidden = true;
$("app").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)"} · ${fingerprint(me.publicKey)}`
: "";
}
// --- folders and list ---------------------------------------------------------
let folder = "inbox";
let selectedId = null;
for (const button of $("folders").querySelectorAll("button"))
button.onclick = () => {
folder = button.dataset.folder;
for (const b of $("folders").querySelectorAll("button")) b.classList.toggle("active", b === button);
selectedId = null;
renderFolder();
};
async function renderFolder() {
const list = $("list");
if (folder === "contacts") return renderContacts(list);
const rows = await store.listMessages(folder);
list.replaceChildren();
for (const row of rows) {
const opened = describe(row);
const item = document.createElement("div");
item.className = "item" + (row.id === selectedId ? " selected" : "");
const who = document.createElement("div");
who.className = "who" + (store.isRead(row.id) ? "" : " unread");
who.textContent = folder === "sent" ? row.recipient : (opened.from ?? "?");
const subject = document.createElement("div");
subject.className = "subject";
subject.textContent = opened.error ? `<unreadable: ${opened.error}>`
: (opened.subject || "(no subject)");
const when = document.createElement("span");
when.className = "when";
when.textContent = fmtTime(folder === "sent" ? row.sentAt : opened.time ?? row.receivedAt);
item.append(who, subject, when);
item.onclick = () => openMessage(row.id);
list.append(item);
}
if (!rows.length) list.append(Object.assign(document.createElement("p"),
{ className: "placeholder", textContent: folder === "inbox" ? "no mail — fetch" : "nothing sent" }));
}
// Open an envelope and split its body; anything unreadable shows as such
// rather than throwing, so one bad message cannot blank the list. The wire
// carries no sender name, so an unknown key can only be shown as its
// fingerprint until the user binds it to an address — which a signed Reply-To
// field lets the sender do in-band.
function describe(row) {
try {
const opened = unseal(store.identities(), row.envelope);
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
const known = store.addressForKey(opened.sender);
let replyAddress = null;
if (fields["Reply-To"]) {
try {
const claimed = parseAddress(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,
fields, body,
subject: fields.Subject || "",
from: known ?? `unknown · ${fingerprint(opened.sender)}`,
knownSender: Boolean(known),
replyAddress,
};
} catch (err) {
return { error: err.message };
}
}
// --- 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");
if (opened.error) {
view.innerHTML = `<p class="placeholder warn">${opened.error}</p>`;
return;
}
const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject");
view.innerHTML = `<h2></h2><div class="meta"></div><div class="fields"></div>` +
`<div class="verified">signature verified</div><pre></pre>`;
view.querySelector("h2").textContent = opened.subject || "(no subject)";
view.querySelector(".meta").textContent = folder === "sent"
? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
: `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`;
view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
view.querySelector("pre").textContent = opened.body;
if (folder === "inbox") {
if (opened.knownSender) {
view.append(replyButton(opened, row));
} 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).
const note = document.createElement("p");
note.className = "placeholder";
note.textContent =
`this message carries the sender's signed address: ${opened.replyAddress.short}`;
const save = document.createElement("button");
save.textContent = "save sender & reply";
save.onclick = () => {
try {
saveReplyAddress(opened.replyAddress, opened.sender);
openMessage(row.id);
openCompose({
to: opened.replyAddress.short,
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
} catch (err) {
status(err.message, true);
}
};
view.append(note, save);
} 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 note = document.createElement("p");
note.className = "placeholder";
note.textContent = "The wire carries no sender name, only this signed key. " +
"To reply, give the sender's address — it is checked against this key:";
const form = document.createElement("form");
form.className = "name-sender";
const input = document.createElement("input");
input.placeholder = "alice@example.org or smol://…";
input.spellcheck = false;
const name = document.createElement("button");
name.textContent = "name sender & reply";
form.append(input, name);
form.onsubmit = async (event) => {
event.preventDefault();
name.disabled = true;
try {
await nameSender(input.value, opened.sender);
openMessage(row.id); // the sender is named now; the reply button appears
openCompose({
to: store.addressForKey(opened.sender),
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
} catch (err) {
status(err.message, true);
} finally {
name.disabled = false;
}
};
view.append(note, form);
}
}
}
function replyButton(opened, row) {
const reply = document.createElement("button");
reply.textContent = "reply";
reply.onclick = () => openCompose({
to: store.addressForKey(opened.sender) ?? "",
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
return reply;
}
// 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.querySelector("form").onsubmit = async (event) => {
event.preventDefault();
if (event.submitter?.value !== "send") {
compose.close();
return;
}
const form = event.target;
$("compose-error").textContent = "";
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;
}
};
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);
status("fetching…");
let stored = 0, rejected = 0;
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.
rejected++;
status(`${hex(record.id)}: ${err.message}; left on server`, true);
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} rejected` + (rejected ? " (left on the server)" : ""));
} catch (err) {
status(err.message, true);
}
renderFolder();
};
// --- contacts -------------------------------------------------------------------
function renderContacts(list) {
list.replaceChildren();
const contacts = store.allContacts();
for (const { address, key, verified } of contacts) {
const item = document.createElement("div");
item.className = "item";
const who = document.createElement("div");
who.className = "who";
who.textContent = address;
const fp = document.createElement("div");
fp.className = "subject";
fp.textContent = `${fingerprint(b32decode(key))} · ${verified ? "verified key" : "tofu"}`;
const refresh = document.createElement("button");
refresh.textContent = "refresh";
refresh.onclick = () => refreshContact(address);
item.append(who, fp, refresh);
list.append(item);
}
if (!contacts.length) list.append(Object.assign(document.createElement("p"),
{ className: "placeholder", textContent: "no contacts; import a smol:// address or write to someone" }));
}
// 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);
}
renderFolder();
}
// --- settings ---------------------------------------------------------------------
const settings = $("settings");
function openSettings() {
renderSettings();
settings.showModal();
}
$("settings-open").onclick = openSettings;
$("settings-close").onclick = () => settings.close();
function renderSettings() {
const me = store.identity();
const account = store.account();
const card = $("identity-card");
if (!me) { card.textContent = ""; return; }
const share = account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)";
card.innerHTML = `<div>fingerprint: ${fingerprint(me.publicKey)}</div>` +
`<div>address: ${account ? account.user + "@" + account.host : "(not registered)"}</div>` +
`<div>share: ${share}</div>` +
`<div class="seed">${hex(me.seed)}</div>` +
`<div class="warn">back this seed up; it is the only secret</div>` +
`<div>retired keys: ${store.identities().length - 1} (kept to read old mail)</div>`;
const pins = $("pins");
pins.replaceChildren();
for (const { host, key } of store.allPins()) {
const li = document.createElement("li");
const name = document.createElement("span");
name.textContent = host;
const value = document.createElement("span");
value.className = "key";
value.textContent = key;
const remove = document.createElement("button");
remove.textContent = "remove";
remove.onclick = () => { store.unpinServer(host); renderSettings(); };
li.append(name, value, remove);
pins.append(li);
}
if (!store.allPins().length) pins.append(Object.assign(document.createElement("li"),
{ className: "placeholder", textContent: "no pinned servers" }));
}
$("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)}`);
renderSettings();
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();
form.reset();
} catch (err) {
status(err.message, true);
}
};
$("rotate").onclick = async () => {
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);
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
renderAccount();
renderSettings();
} catch (err) {
status(err.message, true);
}
};
// --- boot --------------------------------------------------------------------------
store.identity() ? showApp() : showSetup();
status(store.identity() ? "" : "create an identity to begin");