feat: adopt smolmail protocol 1.1 with accept tokens and leave-mail-on-server

This commit is contained in:
randogoth 2026-09-27 11:14:25 +03:00
parent eef7bf9b1d
commit d6fa097626
11 changed files with 911 additions and 232 deletions

View file

@ -1,11 +1,12 @@
// 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 { hex, timingSafeEqual, unhex, 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,
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";
@ -37,11 +38,25 @@ function icon(name) {
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;
@ -125,7 +140,7 @@ async function connect(addr, { requirePin }) {
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);
`Lookups from this session are UNVERIFIED.`, true);
return opened;
}
@ -151,18 +166,22 @@ function showApp() {
}
$("create-identity").onclick = () => {
store.setIdentity(newIdentity().seed);
store.setIdentity(newMaster());
showApp();
status("identity created. Back up the seed in settings now; it is the only secret.");
status("identity created. Back up the master in settings now; it is the only secret.");
openSettings();
};
$("restore-identity").onclick = async () => {
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));
const text = $("restore-master").value.trim();
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a master is 64 hex characters", true);
const masterBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
try {
store.setIdentity(seedBytes);
// §2: a master alone does not say which rotation index a server has
// bound; assume 0 (a never-rotated identity, the common case) until an
// address lets us confirm it below. Either way the accepted-correspondent
// set is unknown, so it must not overwrite the server's (syncOk: false).
store.restoreMaster(masterBytes, 0);
} catch (err) {
return status(err.message, true);
}
@ -198,7 +217,7 @@ $("restore-identity").onclick = async () => {
if (addressText) {
try {
const addr = await recallAccount(addressText);
const addr = await recallAndDiscoverRotation(masterBytes, addressText);
lines.push(`recalled ${addr.short}.`);
renderAccount();
renderSettings();
@ -260,6 +279,7 @@ function resetView() {
// 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() {
@ -278,7 +298,8 @@ async function renderFolder() {
if (token !== renderToken) return;
list.replaceChildren();
if (!rows.length) {
list.append(placeholder(folder === "inbox" ? "no mail — fetch" : "nothing sent"));
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) {
@ -292,8 +313,9 @@ async function renderFolder() {
// A real button, so every row is reachable and openable from the keyboard.
function messageRow(row) {
const opened = describe(row);
// Only the inbox has a read state; sent copies were never "unread".
const unread = folder === "inbox" && !store.isRead(row.id);
// 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" : ""}`,
@ -302,7 +324,7 @@ function messageRow(row) {
if (row.id === selectedId) item.setAttribute("aria-current", "true");
const who = el("div", { className: "who",
textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") });
if (folder === "inbox" && !opened.error && !opened.knownSender)
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" },
@ -356,7 +378,7 @@ function openEnvelope(row) {
const opened = unseal(store.identities(), row.envelope);
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
entry = { ...opened, fields, body,
subject: fields.Subject || "",
subject: fields.subject || "",
snippet: body.replace(/\s+/g, " ").trim().slice(0, 140) };
} catch (err) {
entry = { error: err.message };
@ -376,9 +398,9 @@ function describe(row) {
if (opened.error) return opened;
const known = store.addressForKey(opened.sender);
let replyAddress = null;
if (opened.fields["Reply-To"]) {
if (opened.fields["reply-to"]) {
try {
const claimed = parseAddress(opened.fields["Reply-To"]);
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))
@ -419,7 +441,8 @@ async function openMessage(id) {
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages");
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject");
// "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,
@ -433,7 +456,32 @@ async function openMessage(id) {
el("pre", { textContent: opened.body }),
actions,
].filter(node => node != null));
if (folder !== "inbox") return;
// 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) },
@ -479,6 +527,28 @@ async function openMessage(id) {
}
}
// 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.
@ -585,7 +655,8 @@ compose.querySelector("form").onsubmit = async (event) => {
async function sendMail(toText, subject, body, replyTo, anonymous) {
const me = store.identity();
if (!me) throw new SmolError("no identity yet");
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();
@ -593,12 +664,19 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
// 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);
await sendOp(opened.session, envelope, mac);
} finally {
opened.session.stream.close();
}
@ -609,7 +687,7 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
envelope: seal(me, me.publicKey, bodyBytes),
sentAt: Math.floor(Date.now() / 1000),
});
status(`sent to ${addr.short}`);
status(`sent to ${addr.short}${mac ? " (accepted)" : ""}`);
if (folder === "sent") renderFolder();
}
@ -635,10 +713,41 @@ async function resolveRecipient(addr) {
// --- 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) return status("no identity yet", true);
if (!me || !master) return status("no identity yet", true);
if (!addr) return status("not registered; register in settings first", true);
const button = $("fetch"), label = button.querySelector(".label");
const resting = label.textContent;
@ -647,15 +756,24 @@ $("fetch").onclick = async () => {
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 {
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
const { sync, tokens } = store.tokenSet(master);
await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
for (;;) {
const records = await fetchOp(opened.session);
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))
@ -668,29 +786,39 @@ $("fetch").onclick = async () => {
rejected.push(`${hex(record.id)}: ${err.message}`);
continue;
}
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt };
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 claim = describe(row).replyAddress;
if (claim) {
const described = describe(row);
if (described.replyAddress) {
try {
bindReplyAddress(claim, unsealed.sender);
bindReplyAddress(described.replyAddress, unsealed.sender);
verified++;
} catch { /* already known under a different key: leave it be */ }
}
learnTokenFrom(unsealed, described);
}
acked.push(record.id);
}
if (!acked.length) break;
await deleteOp(opened.session, acked);
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}`),
@ -710,6 +838,7 @@ $("fetch").onclick = async () => {
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" : ""}`,
@ -722,6 +851,7 @@ function renderContacts(list) {
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 =
@ -753,6 +883,18 @@ function openContact(address) {
}
} }, 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"),
@ -763,10 +905,16 @@ function openContact(address) {
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 (SPEC.md §7). Mail signed with these was sent " +
"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)) }),
@ -796,7 +944,7 @@ async function refreshContact(address) {
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)) {
} 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 {
@ -810,6 +958,59 @@ async function refreshContact(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");
@ -822,6 +1023,8 @@ function openSettings() {
$("settings-open").onclick = openSettings;
$("settings-close").onclick = () => settings.close();
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
// Registering against an unpinned server is refused, so the dialog is ordered
// by what depends on what; the chips say how far along that order you are.
function step(id, done) {
@ -835,6 +1038,8 @@ function renderSettings() {
const account = store.account();
const pins = store.allPins();
$("leave-on-server").checked = store.leaveOnServer();
step("step-server", pins.length > 0);
step("step-address", Boolean(account));
@ -850,16 +1055,18 @@ function renderSettings() {
const card = $("identity-card");
card.replaceChildren();
if (!me) return;
const masterBytes = store.master();
const share = account ? accountAddress().uri(me.publicKey) : null;
const retired = store.identities().length - 1;
const rotations = store.rotations();
const acceptedCount = store.allAccepted().filter(a => a.active).length;
// Settings opens by itself right after an identity is created, which is a poor
// moment to paint the only secret across the screen — so it is asked for.
const masked = "\u2022".repeat(64);
const seed = el("div", { className: "seed", textContent: masked });
const master = el("div", { className: "seed", textContent: masked });
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
const showing = seed.textContent !== masked;
seed.textContent = showing ? masked : hex(me.seed);
const showing = master.textContent !== masked;
master.textContent = showing ? masked : hex(masterBytes);
reveal.textContent = showing ? "reveal" : "hide";
} });
@ -869,27 +1076,30 @@ function renderSettings() {
field("shareable", share ?? "register above to get one", !share,
share ? copyButton(share, "your address") : null),
el("div", { className: "seed-card" },
el("h4", { textContent: "recovery seed" }),
el("div", { className: "row" }, seed, reveal, copyButton(hex(me.seed), "the seed")),
el("h4", { textContent: "recovery master" }),
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the master")),
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " +
"write as you, and losing it loses the address with it — there is no reset. Copy it " +
"somewhere safe now." })),
field("retired keys", String(retired)),
retired ? el("p", { className: "hint",
textContent: "Kept so mail sealed to a superseded key stays readable." }) : null,
field("rotations", String(rotations)),
rotations ? el("p", { className: "hint",
textContent: "Earlier keys are re-derived on demand from the master, so mail sealed to a " +
"superseded key stays readable." }) : null,
field("accepted correspondents", String(acceptedCount) +
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
el("details", {},
el("summary", { textContent: "advanced" }),
el("p", { className: "hint", textContent:
"Rotating issues a fresh key with a signed certificate proving it replaces this one. " +
"Contacts accept the change from that chain, and the old seed is kept, since mail sealed " +
"to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }),
"Rotating issues a fresh key — the next one derivable from the master — with a signed " +
"certificate proving it replaces this one. Contacts accept the change from that chain, and " +
"the old key stays readable with nothing else. Rotation is not revocation." }),
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }),
el("p", { className: "hint", textContent:
"Log out to remove this identity from this browser entirely: the seed, pins, contacts and " +
"every cached message. Without a backup of the seed there is no way back in — same as any " +
"other loss of it. Fetching deletes from the server once acknowledged (SPEC.md §5), so a " +
"message already fetched here has no other copy anywhere; anything still unfetched stays " +
"on the server and comes back on the next fetch after logging back in." }),
"Log out to remove this identity from this browser entirely: the master, pins, contacts and " +
"every cached message. Without a backup of the master there is no way back in — same as any " +
"other loss of it. Fetching deletes from the server once acknowledged unless \u201cleave mail " +
"on server\u201d is on, so a message already fetched here may have no other " +
"copy anywhere; anything still on the server comes back on the next fetch after logging back in." }),
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
onclick: logout })),
].filter(node => node != null));
@ -948,13 +1158,18 @@ $("import-form").onsubmit = (event) => {
$("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, { username: addr.user, identity: me, token: form.token.value.trim() });
await registerOp(opened.session, opened.serverStatic,
{ username: addr.user, identity: me, token: form.token.value.trim() });
} finally {
opened.session.stream.close();
}
@ -965,10 +1180,13 @@ $("register-form").onsubmit = async (event) => {
form.reset();
} catch (err) {
status(err.message, true);
} finally {
submit.disabled = false;
submit.textContent = resting;
}
};
// Restoring a seed brings back the identity, not the memory of what address a
// 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
@ -991,9 +1209,41 @@ async function recallAccount(text) {
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);
@ -1003,6 +1253,9 @@ $("recall-form").onsubmit = async (event) => {
form.reset();
} catch (err) {
status(err.message, true);
} finally {
submit.disabled = false;
submit.textContent = resting;
}
};
@ -1017,7 +1270,7 @@ $("export-data").onclick = async () => {
// 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 seed restored can open it");
status("exported — sealed to this identity; only a browser with this master restored can open it");
} catch (err) {
status(err.message, true);
}
@ -1046,40 +1299,50 @@ $("import-data").onchange = async (event) => {
}
};
async function rotateIdentity() {
// §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 || !addr) return status("rotate needs a registered account", true);
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 fresh = newIdentity();
const cert = makeCert(me, fresh.seed);
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, { username: addr.user, identity: fresh, cert });
await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert });
} finally {
opened.session.stream.close();
}
store.rotateIdentity(fresh.seed);
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();
renderSettings(); // rebuilds the identity card, 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 seed's only local copy, gone.
// 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 seed, pins, contacts and every cached message from this browser. Make sure " +
"the seed is backed up elsewhere first — without it there is no way back in."
"This deletes the master, pins, contacts and every cached message from this browser. Make sure " +
"the master is backed up elsewhere first — without it there is no way back in."
)) return;
await store.clearAll();
location.reload();