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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Layout
|
||||
|
|
|
|||
|
|
@ -201,6 +201,9 @@ const commands = {
|
|||
|
||||
const fields = [["Subject", parseOption("--subject")], ["In-Reply-To", parseOption("--reply-to")]]
|
||||
.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 envelope = proto.seal(me, recipient, body);
|
||||
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]])
|
||||
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)
|
||||
# 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
|
||||
bob_rotated = run(bob + ["rotate"])
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
<label>subject <input name="subject" spellcheck="false"></label>
|
||||
<textarea name="body" rows="12"></textarea>
|
||||
<footer>
|
||||
<label class="anon"><input type="checkbox" name="anonymous"> anonymous — omit my address</label>
|
||||
<span id="compose-error" class="error"></span>
|
||||
<button value="cancel">cancel</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
|
||||
// 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
|
||||
// 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) {
|
||||
try {
|
||||
const opened = unseal(store.identities(), row.envelope);
|
||||
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
|
||||
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 {
|
||||
...opened,
|
||||
fields, body,
|
||||
subject: fields.Subject || "",
|
||||
from: known ?? `unknown · ${fingerprint(opened.sender)}`,
|
||||
knownSender: Boolean(known),
|
||||
replyAddress,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
|
|
@ -177,6 +189,29 @@ async function openMessage(id) {
|
|||
if (folder === "inbox") {
|
||||
if (opened.knownSender) {
|
||||
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 {
|
||||
// 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.
|
||||
|
|
@ -247,6 +282,19 @@ async function nameSender(text, senderKey) {
|
|||
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 -----------------------------------------------------------
|
||||
|
||||
const compose = $("compose");
|
||||
|
|
@ -272,20 +320,26 @@ compose.querySelector("form").onsubmit = async (event) => {
|
|||
const form = event.target;
|
||||
$("compose-error").textContent = "";
|
||||
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();
|
||||
} catch (err) {
|
||||
$("compose-error").textContent = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
async function sendMail(toText, subject, body, replyTo) {
|
||||
async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||
const me = store.identity();
|
||||
if (!me) throw new SmolError("no identity yet");
|
||||
const addr = parseAddress(toText);
|
||||
const recipient = await resolveRecipient(addr);
|
||||
const fields = [["Subject", subject], ["In-Reply-To", replyTo]].filter(([, v]) => v);
|
||||
const bodyBytes = utf8Bytes(buildFrontmatter(fields, body.replace(/\s+$/, "") + "\n"));
|
||||
const account = accountAddress();
|
||||
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 opened = await connect(addr, { requirePin: false });
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.25); }
|
|||
#compose input { flex: 1; }
|
||||
#compose textarea { font-family: inherit; }
|
||||
#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; }
|
||||
|
||||
#settings section { margin-bottom: 20px; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue