feat: browser client for smol mail
This commit is contained in:
commit
9798d38040
19 changed files with 3331 additions and 0 deletions
492
web/js/app.js
Normal file
492
web/js/app.js
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
// 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.
|
||||
function describe(row) {
|
||||
try {
|
||||
const opened = unseal(store.identities(), row.envelope);
|
||||
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
|
||||
return {
|
||||
...opened,
|
||||
fields, body,
|
||||
subject: fields.Subject || "",
|
||||
from: store.addressForKey(opened.sender) ?? `<${b32encode(opened.sender).slice(0, 16)}…>`,
|
||||
};
|
||||
} 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") {
|
||||
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,
|
||||
});
|
||||
view.append(reply);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
compose.close();
|
||||
} catch (err) {
|
||||
$("compose-error").textContent = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
async function sendMail(toText, subject, body, replyTo) {
|
||||
const me = store.identity();
|
||||
if (!me) throw new SmolError("no identity yet");
|
||||
const addr = parseAddress(toText);
|
||||
const recipient = await resolveRecipient(addr);
|
||||
const fields = [["Subject", subject], ["In-Reply-To", replyTo]].filter(([, v]) => v);
|
||||
const bodyBytes = utf8Bytes(buildFrontmatter(fields, 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");
|
||||
Loading…
Add table
Add a link
Reference in a new issue