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:
parent
84397a3fdd
commit
80aca8a29f
4 changed files with 192 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
159
web/js/app.js
159
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue