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

@ -36,13 +36,13 @@
<main id="setup" class="setup" hidden>
<h2>welcome</h2>
<p>Identity is one 32-byte seed. Back it up: it is the only secret, and losing it means losing the address.</p>
<p>Identity is one 32-byte master secret. Back it up: it is the only secret, and losing it means losing the address.</p>
<div class="row">
<button id="create-identity">create a new identity</button>
</div>
<p class="hint">or restore from a saved seed:</p>
<p class="hint">or restore from a saved master:</p>
<div class="row restore-row">
<label>seed<input id="restore-seed" placeholder="64 hex characters" spellcheck="false"></label>
<label>master<input id="restore-master" placeholder="64 hex characters" spellcheck="false"></label>
<label>address (optional)<input id="restore-address" placeholder="alice@example.org, if already registered"
spellcheck="false"></label>
<label id="restore-key-label">server's public key
@ -59,6 +59,10 @@
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">inbox</span>
<span id="count-inbox" class="count"></span>
</button>
<button data-folder="requests" aria-label="requests">
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">requests</span>
<span id="count-requests" class="count"></span>
</button>
<button data-folder="sent" aria-label="sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label">sent</span></button>
<button data-folder="contacts" aria-label="contacts"><svg class="icon"><use href="#i-contacts"/></svg><span class="label">contacts</span></button>
</div>
@ -100,7 +104,7 @@
<section>
<h3><span id="step-server" class="step" data-n="1">1</span> your server</h3>
<p class="hint">Pin the key of the server that will hold your mail. Get it from its operator over a
channel you already trust — registering, recalling and fetching all refuse an unpinned server (SPEC.md §4).</p>
channel you already trust — registering, recalling and fetching all refuse an unpinned server.</p>
<form id="pin-form">
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
@ -119,7 +123,7 @@
</form>
<details>
<summary>already registered elsewhere?</summary>
<p class="hint">Restoring a seed brings back the identity, not the memory of what address it was
<p class="hint">Restoring a master brings back the identity, not the memory of what address it was
registered under in a different browser — the server was never asked. Recall it here: this
looks the address up and checks it resolves to this identity's key, without registering anything.</p>
<form id="recall-form">
@ -134,11 +138,23 @@
<div id="identity-card"></div>
</section>
<section>
<h3>fetch</h3>
<label class="check-row">
<input type="checkbox" id="leave-on-server">
<span>
<span class="check-title">leave mail on server</span>
<span class="hint">Fetch keeps a copy on the server instead of deleting it; delete a message
here to remove it from both.</span>
</span>
</label>
</section>
<section>
<h3>backup</h3>
<p class="hint">Mail, once fetched, exists only here — the server deletes it as it's picked up
(SPEC.md §5). This backs up the local inbox, sent mail, contacts and pinned servers into one
file. It never includes the seed: that has its own reveal-and-copy flow above, meant for a
<p class="hint">Mail, once fetched, exists only here unless "leave mail on server" is on above.
This backs up the local inbox, requests, sent mail, contacts and pinned servers into one file.
It never includes the master: that has its own reveal-and-copy flow above, meant for a
password manager, not a downloadable file.</p>
<div class="backup-row">
<button type="button" id="export-data">export</button>

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();

View file

@ -1,18 +1,20 @@
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
// signed envelopes, body frontmatter, key rotation, and the framed request
// and response bodies of the five operations.
// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed and
// signed envelopes, body frontmatter, key rotation, accept tokens, and the
// framed request and response bodies of the five operations.
import {
aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519,
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, randomBytes, sha256, timingSafeEqual,
utf8Bytes, x25519, x25519Base,
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, hmacSha256, randomBytes, sha256,
timingSafeEqual, utf8Bytes, x25519, x25519Base,
} from "./crypto.js";
import { NxInitiator } from "./noise.js";
export const DEFAULT_PORT = 1961;
export const KEY_LEN = 32, SIG_LEN = 64, CERT_LEN = 136, ID_LEN = 16;
export const KEY_LEN = 32, SIG_LEN = 64, CERT_LEN = 200, ID_LEN = 32, TOKEN_LEN = 32;
export const MAX_FRAME = 1 << 20, NOISE_PAYLOAD = 65535 - 16, PAD_TO = 1024;
export const ENVELOPE_HEADER = 69, PAYLOAD_HEADER = 45, MAX_CHAIN = 16;
export const MAX_SKEW = 86400; // §5.3: how far ahead of our clock a payload may be dated
export const FLAG_REQUESTS = 0x01; // §6.1: set when a FETCH record missed an accept token
const FRONTMATTER_MAX = 4096, FRONTMATTER_KEYS = 64;
export const OP = { AUTH: 0x00, RESOLVE: 0x01, SEND: 0x02, FETCH: 0x03, DELETE: 0x04, REGISTER: 0x05 };
@ -25,7 +27,9 @@ export class SmolError extends Error {}
const LABEL = Object.freeze({
auth: utf8Bytes("smolmail/1 auth"), seal: utf8Bytes("smolmail/1 seal"),
msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"),
rotate: utf8Bytes("smolmail/1 rotate"),
rotate: utf8Bytes("smolmail/1 rotate"), identity: utf8Bytes("smolmail/1 identity"),
accept: utf8Bytes("smolmail/1 accept"), mac: utf8Bytes("smolmail/1 mac"),
register: utf8Bytes("smolmail/1 register"),
});
// --- encoding helpers -------------------------------------------------------
@ -89,16 +93,37 @@ export class Reader {
get done() { return this.pos === this.buf.length; }
}
// --- identity ---------------------------------------------------------------
// --- identity (§2) ------------------------------------------------------------
// §2: an Ed25519 keypair with the X25519 agreement keys derived from it.
// An Ed25519 keypair with the X25519 agreement keys derived from it.
export function identityFromSeed(seed) {
if (seed.length !== KEY_LEN) throw new SmolError(`identity seed must be ${KEY_LEN} bytes`);
return { seed, publicKey: ed25519PublicKey(seed) };
}
export function newIdentity() {
return identityFromSeed(randomBytes(KEY_LEN));
export function newMaster() {
return randomBytes(KEY_LEN);
}
// §2: the only secret a user holds. Every rotation index's signing seed, and
// the accept key, are derived from it with HKDF.
export function identitySeed(master, index) {
return hkdfSha256(master, new Uint8Array(0), concat(LABEL.identity, u32BE(index)));
}
export function acceptKeyFor(master) {
return hkdfSha256(master, new Uint8Array(0), LABEL.accept);
}
// §5.8: the token this account issues to one correspondent, independent of
// the rotation index so it survives the owner's key rotation.
export function tokenFor(master, correspondentIdentity) {
return hmacSha256(acceptKeyFor(master), correspondentIdentity);
}
// §5.8: what a sender attaches to SEND to reach the recipient's main tier.
export function acceptMac(token, id) {
return hmacSha256(token, concat(LABEL.mac, id));
}
// --- addressing (§3) --------------------------------------------------------
@ -132,8 +157,10 @@ export function parseAddress(text) {
// --- message format (§5) ----------------------------------------------------
// §5.4: derived from the envelope so no sender can choose it; used whole,
// nothing truncates it.
export function messageId(envelope) {
return sha256(concat(LABEL.id, envelope)).slice(0, ID_LEN);
return sha256(concat(LABEL.id, envelope));
}
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
@ -174,6 +201,7 @@ export function unseal(identities, envelope) {
const body = r.take(bodyLen), signature = r.take(SIG_LEN); // trailing bytes are padding
if (!ed25519Verify(sender, concat(LABEL.msg, to, epk, plaintext.slice(0, PAYLOAD_HEADER), body), signature))
throw new SmolError("signature does not verify");
if (when > BigInt(nowSeconds() + MAX_SKEW)) throw new SmolError("payload is dated in the future");
return { sender, time: Number(when), body, id: messageId(envelope) };
}
@ -183,7 +211,8 @@ const FM_KEY = /^[A-Za-z0-9-]{1,64}$/;
// A flat `Key: value` block, deliberately not YAML. Any malformed line
// invalidates the whole block, which is then returned as ordinary body text:
// frontmatter fails closed toward display, never toward silent discard.
// frontmatter fails closed toward display, never toward silent discard. Keys
// are compared case-insensitively (§5.5), so they are returned lowercased.
export function parseFrontmatter(text) {
if (!text.startsWith("---\n")) return { fields: {}, body: text };
const lines = text.split("\n");
@ -197,7 +226,7 @@ export function parseFrontmatter(text) {
for (let i = 0; i < block.length; i++) {
const line = block[i], colon = line.indexOf(":");
if (colon < 0 || !FM_KEY.test(line.slice(0, colon))) return { fields: {}, body: text };
const key = line.slice(0, colon);
const key = line.slice(0, colon).toLowerCase();
if (!(key in fields)) fields[key] = line.slice(colon + 1).trim(); // first occurrence wins
}
return { fields, body: rest };
@ -212,28 +241,35 @@ export function buildFrontmatter(fields, body) {
// --- key rotation (§7) -------------------------------------------------------
export function makeCert(oldIdentity, newSeed, when = nowSeconds()) {
// old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys
// sign, so the old key alone cannot hand the username to a key nobody
// controls; the username is covered but not carried, so a verifier always
// supplies the one it is checking.
export function makeCert(username, oldIdentity, newSeed, when = nowSeconds()) {
const newPub = ed25519PublicKey(newSeed), time = i64BE(when);
const signed = concat(LABEL.rotate, utf8Bytes(username), oldIdentity.publicKey, newPub, time);
return concat(oldIdentity.publicKey, newPub, time,
ed25519Sign(oldIdentity.seed, concat(LABEL.rotate, oldIdentity.publicKey, newPub, time)));
ed25519Sign(oldIdentity.seed, signed), ed25519Sign(newSeed, signed));
}
// Accept a key change only when a signed chain leads from the key we hold to
// the one the server now returns (§7).
export function walkChain(pinned, current, chain) {
// the one the server now returns, both keys signing each link (§7).
export function walkChain(username, pinned, current, chain) {
const same = (a, b) => timingSafeEqual(a, b);
if (same(pinned, current)) return true;
if (!chain.length || chain.length > MAX_CHAIN) return false;
let key = pinned, started = false;
for (const cert of chain) {
const old = cert.slice(0, 32), next = cert.slice(32, 64), when = cert.slice(64, 72), sig = cert.slice(72);
const old = cert.slice(0, 32), next = cert.slice(32, 64), when = cert.slice(64, 72);
const sigOld = cert.slice(72, 136), sigNew = cert.slice(136, 200);
if (!started) {
if (!same(old, key)) continue; // a link predating the key we hold
started = true;
} else if (!same(old, key)) {
return false; // the chain is not continuous
}
if (!ed25519Verify(old, concat(LABEL.rotate, old, next, when), sig)) return false;
const signed = concat(LABEL.rotate, utf8Bytes(username), old, next, when);
if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) return false;
key = next;
}
return started && same(key, current);
@ -356,13 +392,20 @@ export function expectOk(status, what) {
}
// §4 session authentication: sign the handshake hash, which binds the
// signature to this session's server ephemeral and cannot be replayed.
export async function authenticate(session, handshakeHash, username, identity) {
// signature to this session's server ephemeral and cannot be replayed, and
// push the accept token set (§5.8). `sync = 0` leaves the server's stored set
// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly.
// Returns the number of accept tokens the server now holds.
export async function authenticate(session, handshakeHash, username, identity, { sync = 0, tokens = [] } = {}) {
const name = utf8Bytes(username);
if (name.length > 255) throw new SmolError("username too long");
if (tokens.length > 0xffff) throw new SmolError("too many accept tokens for one AUTH");
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)));
expectOk((await session.call(OP.AUTH, body)).status, "authentication");
ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)),
Uint8Array.of(sync), u16BE(tokens.length), ...tokens);
const { status, body: reply } = await session.call(OP.AUTH, body);
expectOk(status, "authentication");
return reply.length >= 2 ? new Reader(reply).u16() : 0;
}
// RESOLVE, returning the current key and its rotation chain (§6.1).
@ -375,19 +418,26 @@ export async function resolveOp(session, user) {
return { identity: r.take(KEY_LEN), chain: Array.from({ length: r.u8() }, () => r.take(CERT_LEN)) };
}
export async function sendOp(session, envelope) {
const { status, body } = await session.call(OP.SEND, envelope);
// §5.8: `mac` is the sender's proof of an accept token, absent or TOKEN_LEN bytes.
export async function sendOp(session, envelope, mac = null) {
const macBytes = mac ?? new Uint8Array(0);
if (macBytes.length && macBytes.length !== TOKEN_LEN)
throw new SmolError(`accept MAC must be ${TOKEN_LEN} bytes`);
const body = concat(Uint8Array.of(macBytes.length), macBytes, envelope);
const { status, body: reply } = await session.call(OP.SEND, body);
expectOk(status, "sending");
return body.length === ID_LEN ? body : messageId(envelope);
return reply.length === ID_LEN ? reply : messageId(envelope);
}
export async function fetchOp(session) {
const { status, body } = await session.call(OP.FETCH);
// §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
export async function fetchOp(session, afterReceivedAt = 0, afterId = new Uint8Array(ID_LEN)) {
const body = concat(i64BE(afterReceivedAt), afterId);
const { status, body: reply } = await session.call(OP.FETCH, body);
expectOk(status, "fetching");
const r = new Reader(body);
const r = new Reader(reply);
return Array.from({ length: r.u16() }, () => {
const id = r.take(ID_LEN), receivedAt = Number(r.i64());
return { id, receivedAt, envelope: r.take(r.u32()) };
const id = r.take(ID_LEN), receivedAt = Number(r.i64()), flags = r.u8();
return { id, receivedAt, flags, envelope: r.take(r.u32()), isRequest: Boolean(flags & FLAG_REQUESTS) };
});
}
@ -399,12 +449,19 @@ export async function deleteOp(session, ids) {
return new Reader(reply).u16();
}
export async function registerOp(session, { username, identity, token = "", cert = null }) {
// §6.1: the signature is proof of possession, bound to the server that will
// store the binding so it cannot be replayed to another server.
export function registerSigned(serverStatic, username, identity) {
return concat(LABEL.register, serverStatic, utf8Bytes(username), identity);
}
export async function registerOp(session, serverStatic, { username, identity, token = "", cert = null }) {
const name = utf8Bytes(username);
const tokenBytes = utf8Bytes(token);
if (name.length > 255 || tokenBytes.length > 255 || cert && cert.length > 255)
throw new SmolError("REGISTER field too long");
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)),
Uint8Array.of(tokenBytes.length), tokenBytes,
Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0));
const { status } = await session.call(OP.REGISTER, body);

View file

@ -1,12 +1,14 @@
// Browser state: identity, pins, contacts and read markers in localStorage;
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
// is plaintext (the seed excepted — it is the user's browser profile).
// is plaintext (the master secret excepted — it is the user's browser profile).
import { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js";
import { b32decode, b32encode, identityFromSeed } from "./proto.js";
import { ID_LEN, MAX_CHAIN, b32decode, b32encode, identityFromSeed, identitySeed, tokenFor } from "./proto.js";
const KEY = "gsmol";
export const TIER_MAIN = 0, TIER_REQUESTS = 1;
// Every getter reads the whole blob and some are called per message row, so the
// parse is cached. `derived` holds the identities that follow from it, which
// cost an Ed25519 scalar multiplication each.
@ -39,44 +41,82 @@ function update(fn) {
save(next ?? state);
}
// --- identity ----------------------------------------------------------------
// --- identity (§2) -------------------------------------------------------------
export function seed() {
const raw = load().seed;
export function master() {
const raw = load().master;
return raw ? unhex(raw) : null;
}
// The rotation index (§7) of the identity currently in use.
export function rotations() {
return load().rotations ?? 0;
}
export function identity() {
return identities()[0] ?? null;
}
export function setIdentity(newSeed) {
if (seed()) throw new Error("an identity already exists; rotate it instead");
update(state => { state.seed = hex(newSeed); });
}
// Rotation (§7): the old seed is retained, since mail sealed to a superseded
// key is readable with nothing else.
export function rotateIdentity(newSeed) {
const old = seed();
if (!old) throw new Error("no identity to rotate");
update(state => {
state.retired = [...(state.retired || []), { seed: hex(old), at: Date.now() }];
state.seed = hex(newSeed);
});
}
// §7: seeds rotated away from are retained, since mail sealed to a superseded
// key is readable with nothing else.
// §7: every key rotated away from is re-derivable from the master, since mail
// sealed to a superseded key is readable with nothing else.
export function identities() {
if (derived) return derived;
const raw = seed();
if (!raw) return [];
const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s)));
derived = [identityFromSeed(raw), ...retired];
const m = master();
if (!m) return [];
derived = [];
for (let n = rotations(); n >= 0; n--) derived.push(identityFromSeed(identitySeed(m, n)));
return derived;
}
function bindMaster(newMaster, rotationIndex, syncOk) {
if (master()) throw new Error("an identity already exists; rotate it instead");
update(state => {
state.master = hex(newMaster);
state.rotations = rotationIndex;
state.syncOk = syncOk;
});
setCursor(0, new Uint8Array(ID_LEN));
}
// A fresh identity: rotation index 0, and an empty accepted set is already
// complete, so it may sync.
export function setIdentity(newMaster) {
bindMaster(newMaster, 0, true);
}
// §2: recovering a master alone does not recover which correspondents were
// accepted, so that set must not overwrite the server's until rebuilt.
export function restoreMaster(newMaster, rotationIndex = 0) {
bindMaster(newMaster, rotationIndex, false);
}
// Corrects the rotation index once a RESOLVE against the account's address
// reveals which one the server actually has bound (see restoreMaster()'s
// default of 0, the common case of a never-rotated identity).
export function setRotationIndex(n) {
update(state => { state.rotations = n; });
}
// Whether the accepted-correspondent set held here may replace the server's
// on the next AUTH — false right after a restore from the master alone, whose
// empty set must not erase the server's (§4).
export function syncOk() {
return load().syncOk ?? true;
}
export function setSyncOk(ok) {
update(state => { state.syncOk = ok; });
}
// Rotation (§7): only the index advances; the superseded key stays derivable
// from the master, so nothing has to be archived.
export function advanceRotation() {
const current = rotations();
if (!master()) throw new Error("no identity to rotate");
if (current >= MAX_CHAIN) throw new Error(`the rotation chain is full at ${MAX_CHAIN} links`);
update(state => { state.rotations = current + 1; });
}
// --- account and server pins ---------------------------------------------------
export function account() {
@ -111,6 +151,32 @@ export function allPins() {
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
}
// --- FETCH behavior and cursor (§6.1) -------------------------------------------
// When true, fetch does not acknowledge (delete) what it retrieves — mail
// stays on the server until explicitly deleted. Defaults to the original
// behavior: fetched mail is acknowledged immediately.
export function leaveOnServer() {
return load().leaveOnServer ?? false;
}
export function setLeaveOnServer(value) {
update(state => { state.leaveOnServer = value; });
}
export function cursor() {
const state = load();
const afterId = state.afterId;
return [state.afterTime ?? 0, afterId ? unhex(afterId) : new Uint8Array(ID_LEN)];
}
export function setCursor(afterTime, afterId) {
update(state => {
state.afterTime = afterTime;
state.afterId = hex(afterId);
});
}
// --- contacts ------------------------------------------------------------------
export function contact(address) {
@ -144,6 +210,70 @@ export function allContacts() {
.map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] }));
}
// --- accept tokens (§5.8) --------------------------------------------------------
export function accepted(address) {
const a = (load().accepted || {})[address];
return a ? { identity: b32decode(a.identity), active: a.active } : null;
}
// Admit a contact to the main tier. The identity is frozen at acceptance — a
// re-accept after a block must not change which key the token is derived
// from (§5.8).
export function accept(address, identityBytes) {
update(state => {
const table = { ...(state.accepted || {}) };
const previous = table[address];
table[address] = {
identity: previous?.identity ?? b32encode(identityBytes),
active: true,
addedAt: previous?.addedAt ?? Date.now(),
};
state.accepted = table;
});
}
// Withdraw a contact's accept token; their mail lands in the requests tier
// from their next message on. Throws if the contact was never accepted.
export function block(address) {
if (!(load().accepted || {})[address]) throw new Error(`${address} was never accepted`);
update(state => {
const table = { ...(state.accepted || {}) };
table[address] = { ...table[address], active: false };
state.accepted = table;
});
}
export function allAccepted() {
return Object.entries(load().accepted || {})
.map(([address, a]) => ({ address, identity: b32decode(a.identity), active: a.active }));
}
// §4: the tokens to push with AUTH, and whether to push at all. A client that
// cannot vouch for its own set — one restored from the master alone — must
// not replace the server's with an incomplete one.
export function tokenSet(masterBytes) {
if (!syncOk()) return { sync: 0, tokens: [] };
const active = allAccepted().filter(a => a.active).sort((a, b) => a.address.localeCompare(b.address));
return { sync: 1, tokens: active.map(a => tokenFor(masterBytes, a.identity)) };
}
// A token received from a correspondent, filed under the address that issued
// it: an address outlives the keys behind it, so the token keeps working
// across the issuer's rotations (§5.8).
export function tokenFrom(address) {
const raw = (load().tokens || {})[address];
return raw ? b32decode(raw.token) : null;
}
export function learnToken(address, tokenBytes) {
update(state => {
const tokens = { ...(state.tokens || {}) };
tokens[address] = { token: b32encode(tokenBytes), seenAt: Date.now() };
state.tokens = tokens;
});
}
// --- read markers ---------------------------------------------------------------
export function markRead(idHex) {
@ -201,6 +331,11 @@ function tx(db, box, mode, fn) {
});
}
// "requests" is a view over the same physical "inbox" records, filtered by
// tier (§5.8) — not a separate folder, so a message keeps one identity
// regardless of which tier it arrived in.
const physicalFolder = (folder) => folder === "requests" ? "inbox" : folder;
export async function storeMessage(box, record) {
const db = await openDb();
await tx(db, box, "readwrite", store => store.put(record));
@ -215,27 +350,37 @@ export async function storeIfNew(box, record) {
return record;
}
export async function listMessages(box) {
export async function listMessages(folder) {
const physical = physicalFolder(folder);
const db = await openDb();
const rows = await tx(db, box, "readonly", store => store.getAll());
return rows.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
const rows = await tx(db, physical, "readonly", store => store.getAll());
const wantTier = folder === "requests" ? TIER_REQUESTS : TIER_MAIN;
const filtered = physical === "inbox" ? rows.filter(r => (r.tier ?? TIER_MAIN) === wantTier) : rows;
return filtered.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
}
export async function getMessage(box, id) {
export async function getMessage(folder, id) {
const db = await openDb();
return await tx(db, box, "readonly", store => store.get(id)) ?? null;
return await tx(db, physicalFolder(folder), "readonly", store => store.get(id)) ?? null;
}
export async function removeMessage(folder, id) {
const db = await openDb();
await tx(db, physicalFolder(folder), "readwrite", store => store.delete(id));
}
// The unread badge must survive leaving the inbox, so it is counted from ids
// and read markers alone — no envelope is opened.
export async function unreadCount() {
const db = await openDb();
const ids = await tx(db, "inbox", "readonly", store => store.getAllKeys());
async function unreadIn(folder) {
const rows = await listMessages(folder);
const read = load().read || {};
return ids.filter(id => !read[id]).length;
return rows.filter(row => !read[row.id]).length;
}
// --- export / import: mail, contacts, pins — never the seed ---------------------
export const unreadCount = () => unreadIn("inbox");
export const requestsUnreadCount = () => unreadIn("requests");
// --- export / import: mail, contacts, pins — never the master --------------------
// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
@ -246,38 +391,42 @@ const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
// A gsmol-only construction, not the wire protocol's own HKDF namespace
// ("smolmail/1 ..." in proto.js) — export/import has no counterpart in
// SPEC.md, so it gets its own label rather than borrowing the protocol's.
// Sealing to a key derived from the identity's own seed means the file is
// Sealing to a key derived from the identity's own master means the file is
// opaque without it — no new passphrase to manage, and it can only ever be
// opened where that seed is already restored, exactly the situation import
// opened where that master is already restored, exactly the situation import
// already assumes.
const EXPORT_LABEL = utf8Bytes("gsmol/1 export");
const exportKey = (seed) => hkdfSha256(seed, new Uint8Array(0), EXPORT_LABEL, 32);
const exportKey = (masterBytes) => hkdfSha256(masterBytes, new Uint8Array(0), EXPORT_LABEL, 32);
// Deliberately excludes the seed: it already has its own reveal-and-copy flow
// in settings, meant for a password manager, not a downloadable file. Beyond
// that, everything else in here was worth hiding too — contacts and pins are
// a social graph and a list of which mail servers you use, not just the mail
// SPEC.md §5 makes irreplaceable once fetched — so the whole payload is
// sealed, not just the parts that were already ciphertext at rest.
// Deliberately excludes the master: it already has its own reveal-and-copy
// flow in settings, meant for a password manager, not a downloadable file.
// Beyond that, everything else in here was worth hiding too — contacts and
// pins are a social graph and a list of which mail servers you use, not just
// the mail SPEC.md §5 makes irreplaceable once fetched — so the whole payload
// is sealed, not just the parts that were already ciphertext at rest.
export async function exportData() {
const me = identity();
if (!me) throw new Error("no identity yet");
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
const m = master();
if (!m) throw new Error("no identity yet");
const [inbox, requests, sent] = await Promise.all(
[listMessages("inbox"), listMessages("requests"), listMessages("sent")]);
const payload = {
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
[address, { key, verified, history }])),
inbox: inbox.map(row => ({ id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope) })),
inbox: [...inbox, ...requests].map(row => ({
id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope),
tier: row.tier ?? TIER_MAIN, keptOnServer: row.keptOnServer ?? false,
})),
sent: sent.map(row =>
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
};
// Random per export: the key is the same every time (deterministic from the
// seed), so nonce reuse has to be ruled out the way it always is under a
// master), so nonce reuse has to be ruled out the way it always is under a
// fixed key — a fresh 96-bit nonce per seal, not a fixed one like seal()'s
// in proto.js gets away with (there, a fresh ephemeral key each message
// makes the derived key itself unique, so a zero nonce is safe).
const nonce = randomBytes(12);
const ciphertext = aeadEncrypt(exportKey(me.seed), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
const ciphertext = aeadEncrypt(exportKey(m), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
return {
gsmolExport: 2,
exportedAt: Date.now(),
@ -300,11 +449,11 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
export async function importData(data) {
let payload;
if (data?.gsmolExport === 2) {
const me = identity();
if (!me) throw new Error("no identity yet — restore it before importing");
const m = master();
if (!m) throw new Error("no identity yet — restore it before importing");
try {
const plaintext = aeadDecrypt(
exportKey(me.seed), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0));
exportKey(m), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0));
payload = JSON.parse(new TextDecoder().decode(plaintext));
} catch {
throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted");
@ -352,7 +501,8 @@ export async function importData(data) {
for (const row of Array.isArray(rows) ? rows : []) {
try {
const record = box === "inbox"
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope) }
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope),
tier: row.tier ?? TIER_MAIN, keptOnServer: Boolean(row.keptOnServer) }
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
if (await storeIfNew(box, record)) summary.mailAdded++;
} catch { summary.malformed++; }
@ -362,8 +512,8 @@ export async function importData(data) {
return summary;
}
// Logout: erases the seed, pins, contacts and every cached message from this
// browser. Closes the held connection first so the delete isn't left
// Logout: erases the master, pins, contacts and every cached message from
// this browser. Closes the held connection first so the delete isn't left
// "blocked" waiting for a handle that never closes on its own.
export async function clearAll() {
localStorage.removeItem(KEY);

View file

@ -356,6 +356,14 @@ dialog::backdrop { background: var(--backdrop); }
#compose textarea { font-family: inherit; resize: vertical; }
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
/* A checkbox with a multi-line label, e.g. settings' "leave mail on server" —
the checkbox sits at the label's cap height rather than centered against
the full wrapped block, which is what made it look adrift. */
#settings label.check-row { flex-direction: row; align-items: flex-start; gap: 10px; flex: none; }
#settings label.check-row input { flex: none; margin: 3px 0 0; }
.check-row .check-title { display: block; color: var(--fg); font-size: 14px; }
.check-row .hint { display: block; margin: 2px 0 0; }
/* Without this every action read exactly like "cancel" and "close". */
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }