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="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
|
||||||
|
|
|
||||||
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://
|
// 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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue