feat: refuse to learn a first-contact key from an unpinned server

Adds an opt-in "require a pinned server" setting that gates the one
RESOLVE path that currently trusts whatever key a server hands back
for a brand-new, hand-typed address — self-certifying addresses and
already-known contacts are structurally exempt. A refusal shows an
inline recovery card in compose to fetch, compare, and pin the key
without losing the draft.

Also closes two adjacent gaps: a self-certifying address could
silently overwrite an already-trusted contact's key (now asks,
showing both fingerprints), and a contact's detail view now names its
server and pin state.
This commit is contained in:
randogoth 2026-10-08 22:12:36 +03:00
parent 84397a3fdd
commit 80aca8a29f
4 changed files with 192 additions and 10 deletions

View file

@ -143,6 +143,21 @@
<input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…"> <input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…">
<input name="subject" spellcheck="false" placeholder="subject"> <input name="subject" spellcheck="false" placeholder="subject">
<textarea name="body" rows="12" placeholder="message"></textarea> <textarea name="body" rows="12" placeholder="message"></textarea>
<div id="compose-unpinned" class="seed-card" hidden>
<h4>unpinned server</h4>
<p class="hint">
<strong id="compose-unpinned-host"></strong> is not pinned, and "require a pinned
server" refuses to trust a key fetched from it for a new recipient. Fetch its key,
compare the fingerprint against what the operator published through a trusted
channel, then pin it to send.
</p>
<div id="compose-unpinned-key-area" hidden></div>
<div class="actions">
<button type="button" id="compose-unpinned-fetch">Fetch their server's key</button>
<button type="button" id="compose-unpinned-pin" class="filled" hidden>Pin and send</button>
</div>
<span id="compose-unpinned-error" class="error"></span>
</div>
<footer> <footer>
<label class="anon"><input type="checkbox" name="anonymous"> anonymous — omit my address</label> <label class="anon"><input type="checkbox" name="anonymous"> anonymous — omit my address</label>
<span id="compose-error" class="error"></span> <span id="compose-error" class="error"></span>
@ -211,6 +226,17 @@
</section> </section>
<section class="panel" data-panel="advanced" hidden> <section class="panel" data-panel="advanced" hidden>
<h3>trust</h3>
<label class="check-row">
<input type="checkbox" id="require-pinned-server">
<span>
<span class="check-title">Require a pinned server</span>
<span class="hint">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.</span>
</span>
</label>
<h3>rotations</h3> <h3>rotations</h3>
<p id="rotations-description" class="hint">Issues a fresh key from your secret with a signed <p id="rotations-description" class="hint">Issues a fresh key from your secret with a signed
certificate proving it replaces this one. Contacts accept the change from that chain, and certificate proving it replaces this one. Contacts accept the change from that chain, and

View file

@ -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:// // 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 // 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) { async function nameSender(text, senderKey) {
const addr = parseAddress(text.trim()); const addr = parseAddress(text.trim());
let key = addr.identity, verified = true; let key = addr.identity, verified = true;
@ -753,7 +777,7 @@ async function nameSender(text, senderKey) {
} }
if (!timingSafeEqual(key, senderKey)) if (!timingSafeEqual(key, senderKey))
throw new SmolError("that address carries a different key than this message's sender"); 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)" : ""}`); status(`${addr.short} saved for this sender${verified ? " (verified key)" : ""}`);
renderFolder(); renderFolder();
} }
@ -783,6 +807,7 @@ function openCompose({ to = "", subject = "", body = "", replyTo = null } = {})
form.body.value = body; form.body.value = body;
form.dataset.replyTo = replyTo ?? ""; form.dataset.replyTo = replyTo ?? "";
$("compose-error").textContent = ""; $("compose-error").textContent = "";
resetUnpinnedPrompt();
compose.showModal(); compose.showModal();
} }
@ -790,24 +815,116 @@ $("write").onclick = () => openCompose();
$("compose-cancel").onclick = () => compose.close(); $("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 // 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 // submits with no submitter — which the old check read as cancel, discarding
// the draft. // the draft. Factored out from the submit handler so "Pin and send" can
compose.querySelector("form").onsubmit = async (event) => { // retry the exact same attempt inline, rather than synthesizing a submit.
event.preventDefault(); async function attemptSend(form) {
const form = event.target; const toText = form.to.value;
const send = $("send"); const send = $("send");
$("compose-error").textContent = ""; $("compose-error").textContent = "";
send.disabled = true; // one click, one envelope: a second seals a second message send.disabled = true; // one click, one envelope: a second seals a second message
try { 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); form.elements.anonymous.checked);
compose.close(); compose.close();
} catch (err) { } 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; $("compose-error").textContent = err.message;
resetUnpinnedPrompt();
} finally { } finally {
send.disabled = false; 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) { async function sendMail(toText, subject, body, replyTo, anonymous) {
@ -848,14 +965,31 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
if (folder === "sent") renderFolder(); 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. // 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) { async function resolveRecipient(addr) {
if (addr.identity) { if (addr.identity) {
store.saveContact(addr.short, addr.identity, true); await saveContactChecked(addr.short, addr.identity, true);
return addr.identity; return addr.identity;
} }
const known = store.contact(addr.short); const known = store.contact(addr.short);
if (known) return known.key; if (known) return known.key;
if (store.requirePinnedServer() && !store.serverPin(addr.host))
throw new UnpinnedServerError(addr.host);
const opened = await connect(addr, { requirePin: false }); const opened = await connect(addr, { requirePin: false });
let current; let current;
try { try {
@ -1062,6 +1196,9 @@ function openContact(address) {
field("fingerprint", fingerprint(known.key)), field("fingerprint", fingerprint(known.key)),
field("public key", b32encode(known.key)), field("public key", b32encode(known.key)),
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")), 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("div", { className: "seed-card" },
el("h4", { textContent: "accept token" }), el("h4", { textContent: "accept token" }),
el("p", { className: "hint", textContent: isAccepted el("p", { className: "hint", textContent: isAccepted
@ -1200,6 +1337,7 @@ $("settings-open").onclick = () => openSettings();
$("settings-close").onclick = () => settings.close(); $("settings-close").onclick = () => settings.close();
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked); $("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
$("require-pinned-server").onchange = (event) => store.setRequirePinnedServer(event.target.checked);
// --- settings panels -------------------------------------------------------------- // --- 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. // call this rather than reason about which panel it touched.
function renderSettings() { function renderSettings() {
$("leave-on-server").checked = store.leaveOnServer(); $("leave-on-server").checked = store.leaveOnServer();
$("require-pinned-server").checked = store.requirePinnedServer();
$("backup-error").textContent = ""; $("backup-error").textContent = "";
renderIdentityPanel(); renderIdentityPanel();
renderServerPanel(); renderServerPanel();
@ -1312,13 +1451,13 @@ function cleanHost(hostText) {
return host; return host;
} }
$("import-form").onsubmit = (event) => { $("import-form").onsubmit = async (event) => {
event.preventDefault(); event.preventDefault();
const form = event.target; const form = event.target;
try { try {
const addr = parseAddress(form.uri.value.trim()); const addr = parseAddress(form.uri.value.trim());
if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key"); 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` + status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` +
`fingerprint: ${fingerprint(addr.identity)}`); `fingerprint: ${fingerprint(addr.identity)}`);
renderFolder(); renderFolder();

View file

@ -145,6 +145,18 @@ export function allPins() {
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key })); 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) ------------------------------------------- // --- FETCH behavior and cursor (§6.1) -------------------------------------------
// When true, fetch does not acknowledge (delete) what it retrieves — mail // When true, fetch does not acknowledge (delete) what it retrieves — mail

View file

@ -431,9 +431,14 @@ dialog::backdrop { background: var(--backdrop); }
.dialog-head .icon-only { color: var(--muted); } .dialog-head .icon-only { color: var(--muted); }
.dialog-head .icon-only:hover { color: var(--fg); } .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 form { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
#compose input, #compose textarea { width: 100%; } #compose input, #compose textarea { width: 100%; }
#compose textarea { font-family: inherit; resize: vertical; } #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 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; } #compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }