Compare commits
2 commits
d19568c1c0
...
5c4e472b19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4e472b19 | ||
|
|
0ee4a7a65f |
6 changed files with 179 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) -----------------------------------------------------------
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -144,6 +144,8 @@
|
|||
<form id="compose-form" method="dialog">
|
||||
<input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…">
|
||||
<input name="subject" spellcheck="false" data-l10n-placeholder="compose-subject-label" placeholder="subject">
|
||||
<div id="compose-headers"></div>
|
||||
<button type="button" id="compose-add-header" class="add-header" data-l10n-id="compose-add-header-button">+ header</button>
|
||||
<textarea name="body" rows="12" data-l10n-placeholder="compose-body-placeholder" placeholder="message"></textarea>
|
||||
<div id="compose-unpinned" class="seed-card" hidden>
|
||||
<h4 id="compose-unpinned-title"></h4>
|
||||
|
|
@ -264,6 +266,7 @@
|
|||
</div>
|
||||
<footer>
|
||||
<button type="button" id="confirm-cancel" autofocus data-l10n-id="action-cancel">Cancel</button>
|
||||
<button type="button" id="confirm-alt" hidden></button>
|
||||
<button type="button" id="confirm-ok"></button>
|
||||
</footer>
|
||||
</dialog>
|
||||
|
|
|
|||
|
|
@ -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. <dialog>'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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue