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="subject" spellcheck="false" placeholder="subject">
<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>
<label class="anon"><input type="checkbox" name="anonymous"> anonymous — omit my address</label>
<span id="compose-error" class="error"></span>
@ -211,6 +226,17 @@
</section>
<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>
<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

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

View file

@ -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

View file

@ -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; }