feat: carry a signed Reply-To address for first contact
This commit is contained in:
parent
fd2d9298f8
commit
aab98ff598
6 changed files with 69 additions and 6 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue