diff --git a/nix/bridge.nix b/nix/bridge.nix index ff50036..b0dbfd3 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.3.0"; + version = "0.2.1"; # 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 b578ef0..13130bd 100644 --- a/test/vectors.test.mjs +++ b/test/vectors.test.mjs @@ -155,42 +155,6 @@ 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 8263636..df0d582 100644 --- a/web/index.html +++ b/web/index.html @@ -144,8 +144,6 @@
-
- diff --git a/web/js/app.js b/web/js/app.js index 19a24e7..383bfdd 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, 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, + 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, } from "./proto.js"; import * as store from "./store.js"; import { connectStream } from "./transport.js"; @@ -736,29 +736,12 @@ 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. -// 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) { +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, }); } @@ -825,11 +808,10 @@ function bindReplyAddress(addr, senderKey) { const compose = $("compose"); -function openCompose({ to = "", subject = "", body = "", replyTo = null, headers = [] } = {}) { +function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) { 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 = ""; @@ -839,45 +821,6 @@ function openCompose({ to = "", subject = "", body = "", replyTo = null, headers $("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, @@ -919,7 +862,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, composeHeaders()); + form.elements.anonymous.checked); compose.close(); } catch (err) { if (err instanceof UnpinnedServerError) { @@ -992,7 +935,7 @@ $("compose-unpinned-pin").onclick = async () => { } }; -async function sendMail(toText, subject, body, replyTo, anonymous, headers = []) { +async function sendMail(toText, subject, body, replyTo, anonymous) { const me = store.identity(); const master = store.master(); if (!me || !master) throw new SmolError(t("identity-missing-error")); @@ -1008,9 +951,6 @@ async function sendMail(toText, subject, body, replyTo, anonymous, headers = []) // 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 @@ -1430,16 +1370,10 @@ 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. -// 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 }) { +function confirmDialog({ title, body, confirmLabel, danger = false }) { 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"; @@ -1447,7 +1381,6 @@ function confirmDialog({ title, body, confirmLabel, danger = false, cancelLabel 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 1d88ce2..7499268 100644 --- a/web/js/proto.js +++ b/web/js/proto.js @@ -239,64 +239,6 @@ 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 7e4ad0d..8d24a1d 100644 --- a/web/style.css +++ b/web/style.css @@ -437,11 +437,6 @@ 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; }