feat: adopt smolmail protocol 1.1 with accept tokens and leave-mail-on-server
This commit is contained in:
parent
eef7bf9b1d
commit
d6fa097626
11 changed files with 911 additions and 232 deletions
393
web/js/app.js
393
web/js/app.js
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue