feat: auto-verify signed Reply-To on fetch; seal the export to the identity
This commit is contained in:
parent
1385ecb56c
commit
eef7bf9b1d
2 changed files with 99 additions and 35 deletions
|
|
@ -445,7 +445,9 @@ async function openMessage(id) {
|
|||
`this message carries the sender's signed address: ${opened.replyAddress.short}`));
|
||||
actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => {
|
||||
try {
|
||||
saveReplyAddress(opened.replyAddress, opened.sender);
|
||||
bindReplyAddress(opened.replyAddress, opened.sender);
|
||||
status(`${opened.replyAddress.short} saved for this sender (verified key)`);
|
||||
renderFolder();
|
||||
openMessage(row.id);
|
||||
startReply(row);
|
||||
} catch (err) {
|
||||
|
|
@ -529,17 +531,18 @@ 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) {
|
||||
// A signed Reply-To is the sender's own claim, verified the moment unseal()
|
||||
// verified the message's signature — no further trust step to gate behind a
|
||||
// click. Never overwrites an address already pinned to a different key (§8:
|
||||
// a key change without a rotation chain needs out-of-band confirmation). No
|
||||
// UI side effects: callers (the reader's button, fetch's auto-bind) decide
|
||||
// how to report the result and whether many need batching into one summary.
|
||||
function bindReplyAddress(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 -----------------------------------------------------------
|
||||
|
|
@ -642,7 +645,7 @@ $("fetch").onclick = async () => {
|
|||
button.disabled = true; // one session at a time; the icon spins while it is
|
||||
label.textContent = "fetching…";
|
||||
status("fetching…");
|
||||
let stored = 0;
|
||||
let stored = 0, verified = 0;
|
||||
const rejected = [];
|
||||
try {
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
|
|
@ -653,10 +656,11 @@ $("fetch").onclick = async () => {
|
|||
if (!records.length) break;
|
||||
const acked = [];
|
||||
for (const record of records) {
|
||||
let unsealed;
|
||||
try {
|
||||
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
||||
throw new SmolError("id does not match the envelope");
|
||||
unseal(store.identities(), record.envelope);
|
||||
unsealed = unseal(store.identities(), record.envelope);
|
||||
} catch (err) {
|
||||
// Left on the server rather than destroyed, so a client-side bug
|
||||
// cannot lose mail. Collected rather than announced, because the
|
||||
|
|
@ -664,9 +668,21 @@ $("fetch").onclick = async () => {
|
|||
rejected.push(`${hex(record.id)}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
if (await store.storeIfNew("inbox", {
|
||||
id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
|
||||
})) stored++;
|
||||
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt };
|
||||
if (await store.storeIfNew("inbox", row)) {
|
||||
stored++;
|
||||
// A signed Reply-To is exactly as verified as the signature
|
||||
// unseal() just checked — see bindReplyAddress(). describe()
|
||||
// both parses the same claim the reader would show and warms its
|
||||
// cache, so opening the message right after costs nothing extra.
|
||||
const claim = describe(row).replyAddress;
|
||||
if (claim) {
|
||||
try {
|
||||
bindReplyAddress(claim, unsealed.sender);
|
||||
verified++;
|
||||
} catch { /* already known under a different key: leave it be */ }
|
||||
}
|
||||
}
|
||||
acked.push(record.id);
|
||||
}
|
||||
if (!acked.length) break;
|
||||
|
|
@ -675,7 +691,9 @@ $("fetch").onclick = async () => {
|
|||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
status([`${stored} new, ${rejected.length} rejected`, ...rejected.map(line => ` ${line}`),
|
||||
status([`${stored} new, ${rejected.length} rejected`,
|
||||
verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null,
|
||||
...rejected.map(line => ` ${line}`),
|
||||
rejected.length ? "left on the server" : null].filter(Boolean).join("\n"),
|
||||
rejected.length > 0);
|
||||
} catch (err) {
|
||||
|
|
@ -989,14 +1007,20 @@ $("recall-form").onsubmit = async (event) => {
|
|||
};
|
||||
|
||||
$("export-data").onclick = async () => {
|
||||
const data = await store.exportData();
|
||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const stamp = new Date(data.exportedAt).toISOString().slice(0, 10);
|
||||
Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click();
|
||||
URL.revokeObjectURL(url);
|
||||
status(`exported ${data.inbox.length + data.sent.length} messages, ` +
|
||||
`${Object.keys(data.contacts).length} contacts, ${Object.keys(data.servers).length} server keys`);
|
||||
try {
|
||||
const data = await store.exportData();
|
||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const stamp = new Date(data.exportedAt).toISOString().slice(0, 10);
|
||||
Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click();
|
||||
URL.revokeObjectURL(url);
|
||||
// No counts in the message: the file itself is sealed precisely so an
|
||||
// observer learns nothing from it, and a summary here would just move
|
||||
// that same leak from the file to the screen right next to it.
|
||||
status("exported — sealed to this identity; only a browser with this seed restored can open it");
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
$("import-data-trigger").onclick = () => $("import-data").click();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue