feat: carry a signed Reply-To address for first contact

This commit is contained in:
randogoth 2026-09-26 16:36:12 +03:00
parent fd2d9298f8
commit aab98ff598
6 changed files with 69 additions and 6 deletions

View file

@ -27,7 +27,7 @@ For a server on a non-default port, start the bridge with `uv run bridge.py --al
- **Identity** (§2): one 32-byte seed; create or restore; the X25519 agreement keys are derived from the Ed25519 keypair. The seed lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary. - **Identity** (§2): one 32-byte seed; create or restore; the X25519 agreement keys are derived from the Ed25519 keypair. The seed lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary.
- **Addressing** (§3): short `user@host` and self-certifying `smol://` addresses, base32 fingerprints. - **Addressing** (§3): short `user@host` and self-certifying `smol://` addresses, base32 fingerprints.
- **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server. - **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server.
- **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch verifies id and signature before acknowledging — anything unreadable stays on the server. - **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key.
- **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain; superseded seeds are kept, since mail sealed to them is readable with nothing else; key changes without a chain are surfaced, never applied silently. - **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain; superseded seeds are kept, since mail sealed to them is readable with nothing else; key changes without a chain are surfaced, never applied silently.
## Layout ## Layout

View file

@ -201,6 +201,9 @@ const commands = {
const fields = [["Subject", parseOption("--subject")], ["In-Reply-To", parseOption("--reply-to")]] const fields = [["Subject", parseOption("--subject")], ["In-Reply-To", parseOption("--reply-to")]]
.filter(([, v]) => v); .filter(([, v]) => v);
if (state.account && !rest.includes("--anonymous"))
fields.push(["Reply-To", proto.parseAddress(`${state.account.user}@${state.account.host}` +
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)).uri(me.publicKey)]);
const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n")); const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n"));
const envelope = proto.seal(me, recipient, body); const envelope = proto.seal(me, recipient, body);
const opened = await open(addr.host, addr.port, { requirePin: false }); const opened = await open(addr.host, addr.port, { requirePin: false });

View file

@ -107,6 +107,10 @@ def main() -> int:
alice_read = run(alice + ["read", reply_id[:8]]) alice_read = run(alice + ["read", reply_id[:8]])
check("alice reads bob's reply", "hello alice" in alice_read, alice_read) check("alice reads bob's reply", "hello alice" in alice_read, alice_read)
check("reference client verifies the JS signature", "signature verified" in alice_read) check("reference client verifies the JS signature", "signature verified" in alice_read)
# bob's reply carries his signed smol:// address; a receiver that does
# not know the field must still preserve and display it (SPEC.md §5.5)
check("reference client preserves the signed reply address",
"reply-to:smol://bob@" in alice_read, alice_read)
# --- rotation: the JS client rotates, the reference client walks the chain # --- rotation: the JS client rotates, the reference client walks the chain
bob_rotated = run(bob + ["rotate"]) bob_rotated = run(bob + ["rotate"])

View file

@ -44,6 +44,7 @@
<label>subject <input name="subject" spellcheck="false"></label> <label>subject <input name="subject" spellcheck="false"></label>
<textarea name="body" rows="12"></textarea> <textarea name="body" rows="12"></textarea>
<footer> <footer>
<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>
<button value="cancel">cancel</button> <button value="cancel">cancel</button>
<button id="send" value="send">send</button> <button id="send" value="send">send</button>

View file

@ -133,18 +133,30 @@ async function renderFolder() {
// Open an envelope and split its body; anything unreadable shows as such // Open an envelope and split its body; anything unreadable shows as such
// rather than throwing, so one bad message cannot blank the list. The wire // rather than throwing, so one bad message cannot blank the list. The wire
// carries no sender name, so an unknown key can only be shown as its // carries no sender name, so an unknown key can only be shown as its
// fingerprint until the user imports the sender's smol:// address. // fingerprint until the user binds it to an address — which a signed Reply-To
// field lets the sender do in-band.
function describe(row) { function describe(row) {
try { try {
const opened = unseal(store.identities(), row.envelope); const opened = unseal(store.identities(), row.envelope);
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body)); const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
const known = store.addressForKey(opened.sender); const known = store.addressForKey(opened.sender);
let replyAddress = null;
if (fields["Reply-To"]) {
try {
const claimed = parseAddress(fields["Reply-To"]);
// Only a full smol:// address whose key matches the signer counts;
// anything else is ordinary text.
if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender))
replyAddress = claimed;
} catch { /* malformed claim: display, never bind */ }
}
return { return {
...opened, ...opened,
fields, body, fields, body,
subject: fields.Subject || "", subject: fields.Subject || "",
from: known ?? `unknown · ${fingerprint(opened.sender)}`, from: known ?? `unknown · ${fingerprint(opened.sender)}`,
knownSender: Boolean(known), knownSender: Boolean(known),
replyAddress,
}; };
} catch (err) { } catch (err) {
return { error: err.message }; return { error: err.message };
@ -177,6 +189,29 @@ async function openMessage(id) {
if (folder === "inbox") { if (folder === "inbox") {
if (opened.knownSender) { if (opened.knownSender) {
view.append(replyButton(opened, row)); view.append(replyButton(opened, row));
} else if (opened.replyAddress) {
// The sender signed their own smol:// address into the payload, so
// binding it is one click. A pinned conflicting key is refused (§8).
const note = document.createElement("p");
note.className = "placeholder";
note.textContent =
`this message carries the sender's signed address: ${opened.replyAddress.short}`;
const save = document.createElement("button");
save.textContent = "save sender & reply";
save.onclick = () => {
try {
saveReplyAddress(opened.replyAddress, opened.sender);
openMessage(row.id);
openCompose({
to: opened.replyAddress.short,
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
} catch (err) {
status(err.message, true);
}
};
view.append(note, save);
} else { } else {
// The wire gives only a signed key; a reply needs the sender's address, // The wire gives only a signed key; a reply needs the sender's address,
// which the user supplies once and we bind to that exact key. // which the user supplies once and we bind to that exact key.
@ -247,6 +282,19 @@ async function nameSender(text, senderKey) {
renderFolder(); renderFolder();
} }
// A signed Reply-To is the sender's own claim, so it saves as verified — but
// never over an address already pinned to a different key (§8: a key change
// without a rotation chain needs out-of-band confirmation).
function saveReplyAddress(addr, senderKey) {
const existing = store.contact(addr.short);
if (existing && !timingSafeEqual(existing.key, senderKey))
throw new SmolError(`${addr.short} is already known with a different key — ` +
`verify out of band before replying`);
store.saveContact(addr.short, senderKey, true);
status(`${addr.short} saved for this sender (verified key)`);
renderFolder();
}
// --- compose and send ----------------------------------------------------------- // --- compose and send -----------------------------------------------------------
const compose = $("compose"); const compose = $("compose");
@ -272,20 +320,26 @@ compose.querySelector("form").onsubmit = async (event) => {
const form = event.target; const form = event.target;
$("compose-error").textContent = ""; $("compose-error").textContent = "";
try { try {
await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo); await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo,
form.elements.anonymous.checked);
compose.close(); compose.close();
} catch (err) { } catch (err) {
$("compose-error").textContent = err.message; $("compose-error").textContent = err.message;
} }
}; };
async function sendMail(toText, subject, body, replyTo) { async function sendMail(toText, subject, body, replyTo, anonymous) {
const me = store.identity(); const me = store.identity();
if (!me) throw new SmolError("no identity yet"); if (!me) throw new SmolError("no identity yet");
const addr = parseAddress(toText); const addr = parseAddress(toText);
const recipient = await resolveRecipient(addr); const recipient = await resolveRecipient(addr);
const fields = [["Subject", subject], ["In-Reply-To", replyTo]].filter(([, v]) => v); const account = accountAddress();
const bodyBytes = utf8Bytes(buildFrontmatter(fields, body.replace(/\s+$/, "") + "\n")); const fields = [["Subject", subject], ["In-Reply-To", replyTo]];
// A signed Reply-To lets a first-time recipient name and answer us (§5.5
// allows unknown keys); "anonymous" omits it.
if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]);
const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v),
body.replace(/\s+$/, "") + "\n"));
const envelope = seal(me, recipient, bodyBytes); const envelope = seal(me, recipient, bodyBytes);
const opened = await connect(addr, { requirePin: false }); const opened = await connect(addr, { requirePin: false });
try { try {

View file

@ -118,6 +118,7 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.25); }
#compose input { flex: 1; } #compose input { flex: 1; }
#compose textarea { font-family: inherit; } #compose textarea { font-family: inherit; }
#compose footer { display: flex; gap: 8px; justify-content: flex-end; align-items: center; } #compose footer { display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
#compose .anon { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
.error { color: var(--warn); font-size: 13px; margin-right: auto; } .error { color: var(--warn); font-size: 13px; margin-right: auto; }
#settings section { margin-bottom: 20px; } #settings section { margin-bottom: 20px; }