diff --git a/nix/bridge.nix b/nix/bridge.nix index b0dbfd3..ff50036 100644 --- a/nix/bridge.nix +++ b/nix/bridge.nix @@ -21,7 +21,7 @@ assert lib.assertMsg ((serverHost == null) == (serverPublicKey == null)) "gsmol-bridge: serverHost and serverPublicKey must be set together, or not at all"; stdenvNoCC.mkDerivation { pname = "gsmol-bridge"; - version = "0.2.1"; + version = "0.3.0"; # Respects the repo's own .gitignore, so .venv/.devbox/.jj never enter the # store path even when building from an unclean local checkout. diff --git a/test/vectors.test.mjs b/test/vectors.test.mjs index 13130bd..b578ef0 100644 --- a/test/vectors.test.mjs +++ b/test/vectors.test.mjs @@ -155,6 +155,42 @@ for (const v of vectors.ed_to_x25519) { eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.subject ?? "none", "none"); } +// --- compose headers ---------------------------------------------------------- + +{ + const refused = (text) => { + try { proto.headerRow(text); return null; } catch (err) { return err instanceof proto.FieldError ? err.id : err; } + }; + eq("hdr-pair", proto.headerRow("Urgency: high").join("|"), "Urgency|high"); + eq("hdr-trimmed", proto.headerRow(" spaced-key : trimmed value ").join("|"), "spaced-key|trimmed value"); + eq("hdr-empty", proto.headerRow(" "), null); + eq("hdr-no-colon", refused("no colon"), "compose-header-colon-missing"); + eq("hdr-empty-key", refused(": value"), "compose-header-invalid-key"); + eq("hdr-bad-key", refused("bad key: v"), "compose-header-invalid-key"); + eq("hdr-long-key", refused(`${"k".repeat(65)}: v`), "compose-header-invalid-key"); + eq("hdr-inner-newline", refused("multi\nline: v"), "compose-header-invalid-key"); + eq("hdr-multiline-value", refused("k: a\nb"), "compose-header-value-multiline"); + // the client's own fields would be shadowed by a copy + for (const reserved of ["Subject: x", "subject: x", "Reply-To: x", "In-Reply-To: x", "accept: x"]) + eq(`hdr-reserved[${reserved}]`, refused(reserved), "compose-header-reserved-key"); + + const rejects = (pairs) => { + try { proto.validateHeaders(pairs); return null; } catch (err) { return err.id; } + }; + eq("hdr-valid", rejects([["Urgency", "high"]]), null); + eq("hdr-duplicate", rejects([["Urgency", "high"], ["urgency", "low"]]), "compose-header-duplicate-key"); + eq("hdr-too-large", rejects([0, 1, 2, 3, 4].map(i => [`X-Key${i}`, "v".repeat(1024)])), "compose-headers-too-large"); + eq("hdr-too-many", rejects(Array.from({ length: 61 }, (_, i) => [`K${i}`, "v"])), "compose-headers-too-many"); + + // only the sender's own fields carry into a reply + const rows = proto.replyRows({ priority: "high", "in-reply-to": "ab".repeat(32), subject: "shadow", accept: "token" }); + eq("hdr-reply-rows", rows.join("|"), "priority: high"); + + // user rows round-trip through the block the reader parses + const built = proto.buildFrontmatter([["Subject", "hi"], ["X-Mood", "fine"]], "body"); + eq("hdr-roundtrip", proto.parseFrontmatter(built).fields["x-mood"], "fine"); +} + // --- addresses (§3) ----------------------------------------------------------- { diff --git a/web/index.html b/web/index.html index df0d582..8263636 100644 --- a/web/index.html +++ b/web/index.html @@ -144,6 +144,8 @@
+
+ diff --git a/web/js/app.js b/web/js/app.js index 383bfdd..19a24e7 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -3,10 +3,10 @@ import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js"; import { - DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode, - b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed, - makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp, - resolveOp, seal, sendOp, tokenFor, unseal, walkChain, + DEFAULT_PORT, FieldError, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, + b32decode, b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, headerRow, identitySeed, + identityFromSeed, makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, + registerOp, replyRows, resolveOp, seal, sendOp, tokenFor, unseal, validateHeaders, walkChain, } from "./proto.js"; import * as store from "./store.js"; import { connectStream } from "./transport.js"; @@ -736,12 +736,29 @@ const replySubject = (subject) => !subject ? "" : subject.startsWith("Re:") ? subject : t("reply-subject-prefix", { subject }); // One path for all three ways a reply can start, so the Re: rule lives once. -function startReply(row) { +// The sender's own fields are the one thing a reply has to decide about: +// carry copies, or start clean. Only rows that would pass send-time +// validation ask, so a mail with nothing but threading replies straight on. +async function startReply(row) { const opened = describe(row); + const rows = replyRows(opened.fields); + let headers = []; + if (rows.length) { + const choice = await confirmDialog({ + title: t("reply-dialog-title"), + body: t("reply-dialog-body", { list: rows.join("\n") }), + cancelLabel: t("reply-dialog-cancel-button"), + altLabel: t("reply-dialog-without-button"), + confirmLabel: t("reply-dialog-copy-button"), + }); + if (!choice) return; + if (choice === true) headers = rows; + } openCompose({ to: store.addressForKey(opened.sender) ?? "", subject: replySubject(opened.subject), replyTo: row.id, + headers, }); } @@ -808,10 +825,11 @@ function bindReplyAddress(addr, senderKey) { const compose = $("compose"); -function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) { +function openCompose({ to = "", subject = "", body = "", replyTo = null, headers = [] } = {}) { const form = compose.querySelector("form"); form.to.value = to; form.subject.value = subject; + $("compose-headers").replaceChildren(...headers.map(headerRowEl)); form.body.value = body; form.dataset.replyTo = replyTo ?? ""; $("compose-error").textContent = ""; @@ -821,6 +839,45 @@ function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) $("write").onclick = () => openCompose(); +// One `Key: value` frontmatter row, parsed only at send so typing is never +// interrupted by a half-written key. +function headerRowEl(value = "") { + const input = el("input", { value, spellcheck: false, placeholder: t("compose-header-placeholder") }); + const row = el("div", { className: "header-row" }, input); + row.append(el("button", { type: "button", className: "icon-only", + title: t("compose-header-remove-tooltip"), onclick: () => row.remove() }, icon("close"))); + row.lastChild.setAttribute("aria-label", t("compose-header-remove-tooltip")); + return row; +} + +$("compose-add-header").onclick = () => { + const row = headerRowEl(); + $("compose-headers").append(row); + row.querySelector("input").focus(); +}; + +// The typed rows as pairs, refused with a translated reason when the reader +// would drop the block for them. +function composeHeaders() { + const pairs = []; + try { + [...$("compose-headers").querySelectorAll("input")].forEach((input, index) => { + try { + const pair = headerRow(input.value); + if (pair) pairs.push(pair); + } catch (err) { + if (!(err instanceof FieldError)) throw err; + throw new SmolError(t("compose-header-row-error", { index: index + 1, reason: t(err.id, err.args) })); + } + }); + validateHeaders(pairs); + } catch (err) { + if (err instanceof FieldError) throw new SmolError(t(err.id, err.args)); + throw err; + } + return pairs; +} + $("compose-cancel").onclick = () => compose.close(); // Holds the address a refused send is waiting on — "fetch their key, pin it, @@ -862,7 +919,7 @@ async function attemptSend(form) { send.disabled = true; // one click, one envelope: a second seals a second message try { await sendMail(toText, form.subject.value, form.body.value, form.dataset.replyTo, - form.elements.anonymous.checked); + form.elements.anonymous.checked, composeHeaders()); compose.close(); } catch (err) { if (err instanceof UnpinnedServerError) { @@ -935,7 +992,7 @@ $("compose-unpinned-pin").onclick = async () => { } }; -async function sendMail(toText, subject, body, replyTo, anonymous) { +async function sendMail(toText, subject, body, replyTo, anonymous, headers = []) { const me = store.identity(); const master = store.master(); if (!me || !master) throw new SmolError(t("identity-missing-error")); @@ -951,6 +1008,9 @@ async function sendMail(toText, subject, body, replyTo, anonymous) { // why this isn't on every message. const owed = store.tokenToDeliver(addr.short); if (owed) fields.push(["Accept", b32encode(tokenFor(master, owed))]); + // The user's own rows go last; headerRow already refused any that would + // shadow the fields above. + fields.push(...headers); // Trimmed before the payload is signed, so the signature covers exactly the // text the author meant. A trailing newline is not content — but it becomes // content the moment it is signed, and nothing downstream can strip it @@ -1370,10 +1430,16 @@ function renderSettings() { // A reusable Cancel/confirm modal for the advanced panel's two irreversible // actions. 's own cancel path (Escape, backdrop click) counts as // Cancel, same as clicking the button. -function confirmDialog({ title, body, confirmLabel, danger = false }) { +// Resolves true on confirm, false on cancel or dismiss, and "alt" for the +// optional middle button. +function confirmDialog({ title, body, confirmLabel, danger = false, cancelLabel = null, altLabel = null }) { return new Promise((resolve) => { $("confirm-title").textContent = title; $("confirm-text").textContent = body; + $("confirm-cancel").textContent = cancelLabel ?? t("action-cancel"); + const alt = $("confirm-alt"); + alt.hidden = !altLabel; + alt.textContent = altLabel ?? ""; const ok = $("confirm-ok"); ok.textContent = confirmLabel; ok.className = danger ? "danger" : "filled"; @@ -1381,6 +1447,7 @@ function confirmDialog({ title, body, confirmLabel, danger = false }) { let decided = false; const finish = (result) => { decided = true; resolve(result); dialog.close(); }; $("confirm-cancel").onclick = () => finish(false); + alt.onclick = () => finish("alt"); ok.onclick = () => finish(true); dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true }); dialog.showModal(); diff --git a/web/js/proto.js b/web/js/proto.js index 7499268..1d88ce2 100644 --- a/web/js/proto.js +++ b/web/js/proto.js @@ -239,6 +239,64 @@ export function buildFrontmatter(fields, body) { return `---\n${fields.map(([k, v]) => `${k}: ${v}\n`).join("")}---\n${body}`; } +// A compose header the reader would reject the whole block for. Carries a +// message id and its arguments rather than English, so the UI translates it. +export class FieldError extends SmolError { + constructor(id, args = {}) { + super(id); + this.id = id; + this.args = args; + } +} + +// Fields the client attaches itself (§5.5, §5.8); a user copy of any of them +// would shadow the client's own, since the reader keeps the first occurrence. +const RESERVED_FIELDS = ["subject", "in-reply-to", "reply-to", "accept"]; + +// One compose header row as typed — `Key: value`. An empty row is skipped +// (null); anything that breaks the block is refused, because a single +// malformed line makes the reader drop every field. +export function headerRow(text) { + text = text.trim(); + if (!text) return null; + const colon = text.indexOf(":"); + if (colon < 0) throw new FieldError("compose-header-colon-missing"); + const key = text.slice(0, colon).trim(), value = text.slice(colon + 1); + if (RESERVED_FIELDS.includes(key.toLowerCase())) throw new FieldError("compose-header-reserved-key", { key }); + if (!FM_KEY.test(key)) throw new FieldError("compose-header-invalid-key"); + if (/[\r\n]/.test(value)) throw new FieldError("compose-header-value-multiline"); + return [key, value.trim()]; +} + +// The rules across all rows: keys are unique case-insensitively, and the +// whole block — the client's own fields included — stays inside the reader's +// budget (§5.5). +export function validateHeaders(pairs) { + const seen = new Set(); + for (const [key] of pairs) { + if (seen.has(key.toLowerCase())) throw new FieldError("compose-header-duplicate-key", { key }); + seen.add(key.toLowerCase()); + } + const budget = pairs.reduce((sum, [k, v]) => sum + utf8Bytes(`${k}: ${v}`).length + 1, 0); + // Subject, In-Reply-To, a Reply-To URI and maybe an Accept token go on top + // of the user's rows; 512 bytes covers them all. + if (budget + 512 > FRONTMATTER_MAX) throw new FieldError("compose-headers-too-large"); + if (pairs.length + RESERVED_FIELDS.length > FRONTMATTER_KEYS) throw new FieldError("compose-headers-too-many"); +} + +// The rows a reply could carry over from the fields it answers: only the +// sender's own, writable ones — the reserved fields are the reply's context. +export function replyRows(fields) { + return Object.entries(fields).flatMap(([key, value]) => { + try { + const pair = headerRow(`${key}: ${value}`); + return pair ? [`${pair[0]}: ${pair[1]}`] : []; + } catch { + return []; + } + }); +} + // --- key rotation (§7) ------------------------------------------------------- // old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys diff --git a/web/style.css b/web/style.css index 8d24a1d..7e4ad0d 100644 --- a/web/style.css +++ b/web/style.css @@ -437,6 +437,11 @@ dialog::backdrop { background: var(--backdrop); } #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-headers { display: flex; flex-direction: column; gap: 6px; } +#compose-headers:empty { display: none; } +#compose .header-row { display: flex; gap: 4px; align-items: center; } +#compose .header-row input { font-family: var(--mono); font-size: 13px; } +#compose .add-header { align-self: flex-start; padding: 4px 10px; font-size: 13px; } #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; }