+ trust
+
+
+
+ Require a pinned server
+ Refuse to look up a new recipient's key from a server you haven't
+ pinned. An address that already carries a key, or a recipient you already know, is
+ unaffected — this only applies to a new, hand-typed address.
+
+
+
rotations
Issues a fresh key from your secret with a signed
certificate proving it replaces this one. Contacts accept the change from that chain, and
diff --git a/web/js/app.js b/web/js/app.js
index 6017a0c..5a5889c 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -736,9 +736,33 @@ function startReply(row) {
});
}
+// Never silently overwrites a different key already held for the same
+// address — a self-certifying key is still just a claim until compared
+// against what's already trusted. Re-saving an identical key proceeds
+// unasked, matching saveContact()'s own "not a rotation" rule. Throws on
+// decline, so every call site's existing catch already reports it correctly
+// with no further changes needed there.
+async function saveContactChecked(address, keyBytes, verified) {
+ const existing = store.contact(address);
+ if (existing && !timingSafeEqual(existing.key, keyBytes)) {
+ const ok = await confirmDialog({
+ title: `${address} already has a different key`,
+ body: `existing fingerprint: ${fingerprint(existing.key)}\n` +
+ `new fingerprint: ${fingerprint(keyBytes)}\n\n` +
+ `Overwriting trusts the new key for this address from now on.`,
+ confirmLabel: "use the new key",
+ danger: true,
+ });
+ if (!ok) throw new SmolError(`kept the existing key for ${address}`);
+ }
+ store.saveContact(address, keyBytes, verified);
+}
+
// Bind a user-supplied address to the key that signed a message. A smol://
// address carries its own key (verified); a short address is resolved and the
-// result kept on first use. Anything that binds a different key is refused.
+// result kept on first use. A key that disagrees with this message's sender
+// is refused outright; one that disagrees with an address already saved
+// under a different key is asked about instead (saveContactChecked).
async function nameSender(text, senderKey) {
const addr = parseAddress(text.trim());
let key = addr.identity, verified = true;
@@ -753,7 +777,7 @@ async function nameSender(text, senderKey) {
}
if (!timingSafeEqual(key, senderKey))
throw new SmolError("that address carries a different key than this message's sender");
- store.saveContact(addr.short, senderKey, verified);
+ await saveContactChecked(addr.short, senderKey, verified);
status(`${addr.short} saved for this sender${verified ? " (verified key)" : ""}`);
renderFolder();
}
@@ -783,6 +807,7 @@ function openCompose({ to = "", subject = "", body = "", replyTo = null } = {})
form.body.value = body;
form.dataset.replyTo = replyTo ?? "";
$("compose-error").textContent = "";
+ resetUnpinnedPrompt();
compose.showModal();
}
@@ -790,24 +815,116 @@ $("write").onclick = () => openCompose();
$("compose-cancel").onclick = () => compose.close();
+// Holds the address a refused send is waiting on — "fetch their key, pin it,
+// resend" without losing the draft. Tied to one address at a time: editing
+// the recipient away from it retires the card (the oninput handler below).
+let unpinnedPrompt = null; // { addressShort, host, port, fetchedKey }
+
+function resetUnpinnedPrompt() {
+ unpinnedPrompt = null;
+ $("compose-unpinned").hidden = true;
+ $("compose-unpinned-key-area").hidden = true;
+ $("compose-unpinned-key-area").replaceChildren();
+ $("compose-unpinned-fetch").hidden = false;
+ $("compose-unpinned-pin").hidden = true;
+ $("compose-unpinned-error").textContent = "";
+}
+
+function showUnpinnedPrompt(addr) {
+ // Only #send is disabled mid-flight, not the inputs — if the field no
+ // longer names this address by the time the refusal comes back, there's
+ // nothing to recover for.
+ let live;
+ try { live = parseAddress(compose.querySelector("form").to.value).short; } catch { live = null; }
+ if (live !== addr.short) return;
+ unpinnedPrompt = { addressShort: addr.short, host: addr.host, port: addr.port, fetchedKey: null };
+ $("compose-unpinned-host").textContent = addr.host;
+ $("compose-unpinned").hidden = false;
+}
+
// Every submission is a send: cancel is a plain button, and Enter in a field
// submits with no submitter — which the old check read as cancel, discarding
-// the draft.
-compose.querySelector("form").onsubmit = async (event) => {
- event.preventDefault();
- const form = event.target;
+// the draft. Factored out from the submit handler so "Pin and send" can
+// retry the exact same attempt inline, rather than synthesizing a submit.
+async function attemptSend(form) {
+ const toText = form.to.value;
const send = $("send");
$("compose-error").textContent = "";
send.disabled = true; // one click, one envelope: a second seals a second message
try {
- await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo,
+ await sendMail(toText, form.subject.value, form.body.value, form.dataset.replyTo,
form.elements.anonymous.checked);
compose.close();
} catch (err) {
+ if (err instanceof UnpinnedServerError) {
+ let addr;
+ try { addr = parseAddress(toText); } catch { addr = null; }
+ if (addr) { showUnpinnedPrompt(addr); return; }
+ }
$("compose-error").textContent = err.message;
+ resetUnpinnedPrompt();
} finally {
send.disabled = false;
}
+}
+
+compose.querySelector("form").onsubmit = (event) => {
+ event.preventDefault();
+ attemptSend(event.target);
+};
+
+compose.querySelector("form").elements.to.oninput = () => {
+ if (!unpinnedPrompt) return;
+ let short;
+ try { short = parseAddress(compose.querySelector("form").to.value).short; } catch { short = null; }
+ if (short !== unpinnedPrompt.addressShort) resetUnpinnedPrompt();
+};
+
+$("compose-unpinned-fetch").onclick = async () => {
+ if (!unpinnedPrompt) return;
+ const { host, port, addressShort } = unpinnedPrompt;
+ const button = $("compose-unpinned-fetch");
+ button.disabled = true;
+ $("compose-unpinned-error").textContent = "";
+ try {
+ // Bypasses connect() deliberately, same reasoning as onboarding's server
+ // screen: its "UNVERIFIED" toast would sit over the button that's about
+ // to pin the result.
+ const timeoutMs = store.timeoutSeconds() * 1000;
+ const stream = await connectStream(host, port, timeoutMs);
+ const opened = await openSession(stream, host, null, timeoutMs);
+ opened.session.stream.close();
+ if (!unpinnedPrompt || unpinnedPrompt.addressShort !== addressShort) return; // retired mid-fetch
+ unpinnedPrompt.fetchedKey = opened.serverStatic;
+ $("compose-unpinned-key-area").hidden = false;
+ $("compose-unpinned-key-area").replaceChildren(
+ field("fingerprint", fingerprint(opened.serverStatic)),
+ field("key", b32encode(opened.serverStatic)),
+ el("p", { className: "hint", textContent:
+ "Compare this against what the operator published through a trusted channel." }));
+ $("compose-unpinned-fetch").hidden = true;
+ $("compose-unpinned-pin").hidden = false;
+ } catch (err) {
+ $("compose-unpinned-error").textContent = err.message;
+ } finally {
+ button.disabled = false;
+ }
+};
+
+$("compose-unpinned-pin").onclick = async () => {
+ if (!unpinnedPrompt?.fetchedKey) return;
+ const { host, fetchedKey } = unpinnedPrompt;
+ const button = $("compose-unpinned-pin");
+ button.disabled = true;
+ try {
+ store.pinServer(host, fetchedKey); // a local write, done inline before the retry
+ resetUnpinnedPrompt();
+ await attemptSend(compose.querySelector("form"));
+ } catch (err) {
+ $("compose-unpinned-error").textContent = err.message;
+ } finally {
+ button.disabled = false;
+ }
};
async function sendMail(toText, subject, body, replyTo, anonymous) {
@@ -848,14 +965,31 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
if (folder === "sent") renderFolder();
}
+// Thrown only when the key would be learned from a server the user hasn't
+// pinned: a bare address, not yet a contact, host unpinned. A distinct class,
+// not a SmolError with a particular message, so compose's catch can show the
+// recovery card instead of plain text.
+class UnpinnedServerError extends SmolError {
+ constructor(host) {
+ super(`${host} is not pinned`);
+ this.host = host;
+ }
+}
+
// Prefer a key we already trust; fall back to RESOLVE with trust on first use.
+// A smol:// address and an already-known contact are exempt from "require a
+// pinned server" below by construction — both return before that check is
+// ever reached, which is what keeps the gate from blocking sends it was
+// never meant to touch.
async function resolveRecipient(addr) {
if (addr.identity) {
- store.saveContact(addr.short, addr.identity, true);
+ await saveContactChecked(addr.short, addr.identity, true);
return addr.identity;
}
const known = store.contact(addr.short);
if (known) return known.key;
+ if (store.requirePinnedServer() && !store.serverPin(addr.host))
+ throw new UnpinnedServerError(addr.host);
const opened = await connect(addr, { requirePin: false });
let current;
try {
@@ -1062,6 +1196,9 @@ 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), "address")),
+ field("server", addr.host, false,
+ el("span", { className: store.serverPin(addr.host) ? "badge ok" : "badge",
+ textContent: store.serverPin(addr.host) ? "pinned" : "not pinned" })),
el("div", { className: "seed-card" },
el("h4", { textContent: "accept token" }),
el("p", { className: "hint", textContent: isAccepted
@@ -1200,6 +1337,7 @@ $("settings-open").onclick = () => openSettings();
$("settings-close").onclick = () => settings.close();
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
+$("require-pinned-server").onchange = (event) => store.setRequirePinnedServer(event.target.checked);
// --- settings panels --------------------------------------------------------------
@@ -1207,6 +1345,7 @@ $("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.c
// call this rather than reason about which panel it touched.
function renderSettings() {
$("leave-on-server").checked = store.leaveOnServer();
+ $("require-pinned-server").checked = store.requirePinnedServer();
$("backup-error").textContent = "";
renderIdentityPanel();
renderServerPanel();
@@ -1312,13 +1451,13 @@ function cleanHost(hostText) {
return host;
}
-$("import-form").onsubmit = (event) => {
+$("import-form").onsubmit = async (event) => {
event.preventDefault();
const form = event.target;
try {
const addr = parseAddress(form.uri.value.trim());
if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key");
- store.saveContact(addr.short, addr.identity, true);
+ await saveContactChecked(addr.short, addr.identity, true);
status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` +
`fingerprint: ${fingerprint(addr.identity)}`);
renderFolder();
diff --git a/web/js/store.js b/web/js/store.js
index 25d43b9..e2924b8 100644
--- a/web/js/store.js
+++ b/web/js/store.js
@@ -145,6 +145,18 @@ export function allPins() {
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
}
+// §4/first contact: when true, resolving a never-before-seen recipient's key
+// from a server that isn't pinned is refused instead of merely toasted after
+// the envelope is already sealed. Persisted, not in-memory — an in-memory
+// toggle fails open on every restart, worth little as a security setting.
+export function requirePinnedServer() {
+ return load().requirePinnedServer ?? false;
+}
+
+export function setRequirePinnedServer(value) {
+ update(state => { state.requirePinnedServer = value; });
+}
+
// --- FETCH behavior and cursor (§6.1) -------------------------------------------
// When true, fetch does not acknowledge (delete) what it retrieves — mail
diff --git a/web/style.css b/web/style.css
index 485bcd3..8d24a1d 100644
--- a/web/style.css
+++ b/web/style.css
@@ -431,9 +431,14 @@ dialog::backdrop { background: var(--backdrop); }
.dialog-head .icon-only { color: var(--muted); }
.dialog-head .icon-only:hover { color: var(--fg); }
+/* The recovery card can push content past the viewport on a short window;
+ dialog's own overflow:hidden would just clip it with no way back. */
+#compose { overflow-y: auto; }
#compose form { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
#compose input, #compose textarea { width: 100%; }
#compose textarea { font-family: inherit; resize: vertical; }
+#compose .seed-card { margin: 0; }
+#compose .actions { display: flex; gap: 8px; margin: 4px 0; }
#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; }