2026-09-26 15:59:28 +03:00
|
|
|
// 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
|
2026-09-26 16:27:40 +03:00
|
|
|
// 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 imports the sender's smol:// address.
|
2026-09-26 15:59:28 +03:00
|
|
|
function describe(row) {
|
|
|
|
|
try {
|
|
|
|
|
const opened = unseal(store.identities(), row.envelope);
|
|
|
|
|
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
|
2026-09-26 16:27:40 +03:00
|
|
|
const known = store.addressForKey(opened.sender);
|
2026-09-26 15:59:28 +03:00
|
|
|
return {
|
|
|
|
|
...opened,
|
|
|
|
|
fields, body,
|
|
|
|
|
subject: fields.Subject || "",
|
2026-09-26 16:27:40 +03:00
|
|
|
from: known ?? `unknown · ${fingerprint(opened.sender)}`,
|
|
|
|
|
knownSender: Boolean(known),
|
2026-09-26 15:59:28 +03:00
|
|
|
};
|
|
|
|
|
} 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") {
|
2026-09-26 16:29:38 +03:00
|
|
|
if (opened.knownSender) {
|
|
|
|
|
view.append(replyButton(opened, row));
|
|
|
|
|
} 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);
|
|
|
|
|
}
|
2026-09-26 15:59:28 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 16:29:38 +03:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 15:59:28 +03:00
|
|
|
// --- 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");
|