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}`));
|
`this message carries the sender's signed address: ${opened.replyAddress.short}`));
|
||||||
actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => {
|
actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => {
|
||||||
try {
|
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);
|
openMessage(row.id);
|
||||||
startReply(row);
|
startReply(row);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -529,17 +531,18 @@ async function nameSender(text, senderKey) {
|
||||||
renderFolder();
|
renderFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A signed Reply-To is the sender's own claim, so it saves as verified — but
|
// A signed Reply-To is the sender's own claim, verified the moment unseal()
|
||||||
// never over an address already pinned to a different key (§8: a key change
|
// verified the message's signature — no further trust step to gate behind a
|
||||||
// without a rotation chain needs out-of-band confirmation).
|
// click. Never overwrites an address already pinned to a different key (§8:
|
||||||
function saveReplyAddress(addr, senderKey) {
|
// 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);
|
const existing = store.contact(addr.short);
|
||||||
if (existing && !timingSafeEqual(existing.key, senderKey))
|
if (existing && !timingSafeEqual(existing.key, senderKey))
|
||||||
throw new SmolError(`${addr.short} is already known with a different key — ` +
|
throw new SmolError(`${addr.short} is already known with a different key — ` +
|
||||||
`verify out of band before replying`);
|
`verify out of band before replying`);
|
||||||
store.saveContact(addr.short, senderKey, true);
|
store.saveContact(addr.short, senderKey, true);
|
||||||
status(`${addr.short} saved for this sender (verified key)`);
|
|
||||||
renderFolder();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- compose and send -----------------------------------------------------------
|
// --- compose and send -----------------------------------------------------------
|
||||||
|
|
@ -642,7 +645,7 @@ $("fetch").onclick = async () => {
|
||||||
button.disabled = true; // one session at a time; the icon spins while it is
|
button.disabled = true; // one session at a time; the icon spins while it is
|
||||||
label.textContent = "fetching…";
|
label.textContent = "fetching…";
|
||||||
status("fetching…");
|
status("fetching…");
|
||||||
let stored = 0;
|
let stored = 0, verified = 0;
|
||||||
const rejected = [];
|
const rejected = [];
|
||||||
try {
|
try {
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr, { requirePin: true });
|
||||||
|
|
@ -653,10 +656,11 @@ $("fetch").onclick = async () => {
|
||||||
if (!records.length) break;
|
if (!records.length) break;
|
||||||
const acked = [];
|
const acked = [];
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
|
let unsealed;
|
||||||
try {
|
try {
|
||||||
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
||||||
throw new SmolError("id does not match the envelope");
|
throw new SmolError("id does not match the envelope");
|
||||||
unseal(store.identities(), record.envelope);
|
unsealed = unseal(store.identities(), record.envelope);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Left on the server rather than destroyed, so a client-side bug
|
// Left on the server rather than destroyed, so a client-side bug
|
||||||
// cannot lose mail. Collected rather than announced, because the
|
// cannot lose mail. Collected rather than announced, because the
|
||||||
|
|
@ -664,9 +668,21 @@ $("fetch").onclick = async () => {
|
||||||
rejected.push(`${hex(record.id)}: ${err.message}`);
|
rejected.push(`${hex(record.id)}: ${err.message}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (await store.storeIfNew("inbox", {
|
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt };
|
||||||
id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
|
if (await store.storeIfNew("inbox", row)) {
|
||||||
})) stored++;
|
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);
|
acked.push(record.id);
|
||||||
}
|
}
|
||||||
if (!acked.length) break;
|
if (!acked.length) break;
|
||||||
|
|
@ -675,7 +691,9 @@ $("fetch").onclick = async () => {
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
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 ? "left on the server" : null].filter(Boolean).join("\n"),
|
||||||
rejected.length > 0);
|
rejected.length > 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -989,14 +1007,20 @@ $("recall-form").onsubmit = async (event) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
$("export-data").onclick = async () => {
|
$("export-data").onclick = async () => {
|
||||||
const data = await store.exportData();
|
try {
|
||||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
const data = await store.exportData();
|
||||||
const url = URL.createObjectURL(blob);
|
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||||
const stamp = new Date(data.exportedAt).toISOString().slice(0, 10);
|
const url = URL.createObjectURL(blob);
|
||||||
Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click();
|
const stamp = new Date(data.exportedAt).toISOString().slice(0, 10);
|
||||||
URL.revokeObjectURL(url);
|
Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click();
|
||||||
status(`exported ${data.inbox.length + data.sent.length} messages, ` +
|
URL.revokeObjectURL(url);
|
||||||
`${Object.keys(data.contacts).length} contacts, ${Object.keys(data.servers).length} server keys`);
|
// 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();
|
$("import-data-trigger").onclick = () => $("import-data").click();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
|
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
|
||||||
// is plaintext (the seed excepted — it is the user's browser profile).
|
// is plaintext (the seed excepted — it is the user's browser profile).
|
||||||
|
|
||||||
import { hex, unhex } from "./crypto.js";
|
import { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js";
|
||||||
import { b32decode, b32encode, identityFromSeed } from "./proto.js";
|
import { b32decode, b32encode, identityFromSeed } from "./proto.js";
|
||||||
|
|
||||||
const KEY = "gsmol";
|
const KEY = "gsmol";
|
||||||
|
|
@ -243,16 +243,27 @@ export async function unreadCount() {
|
||||||
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
||||||
const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// A gsmol-only construction, not the wire protocol's own HKDF namespace
|
||||||
|
// ("smolmail/1 ..." in proto.js) — export/import has no counterpart in
|
||||||
|
// SPEC.md, so it gets its own label rather than borrowing the protocol's.
|
||||||
|
// Sealing to a key derived from the identity's own seed means the file is
|
||||||
|
// opaque without it — no new passphrase to manage, and it can only ever be
|
||||||
|
// opened where that seed is already restored, exactly the situation import
|
||||||
|
// already assumes.
|
||||||
|
const EXPORT_LABEL = utf8Bytes("gsmol/1 export");
|
||||||
|
const exportKey = (seed) => hkdfSha256(seed, new Uint8Array(0), EXPORT_LABEL, 32);
|
||||||
|
|
||||||
// Deliberately excludes the seed: it already has its own reveal-and-copy flow
|
// Deliberately excludes the seed: it already has its own reveal-and-copy flow
|
||||||
// in settings, meant for a password manager, not a downloadable file. This
|
// in settings, meant for a password manager, not a downloadable file. Beyond
|
||||||
// covers what fetching makes irreplaceable (SPEC.md §5 deletes mail from the
|
// that, everything else in here was worth hiding too — contacts and pins are
|
||||||
// server once acknowledged) plus the trust state that took real verification
|
// a social graph and a list of which mail servers you use, not just the mail
|
||||||
// (RESOLVE, key comparison) to build up — pins and contacts.
|
// SPEC.md §5 makes irreplaceable once fetched — so the whole payload is
|
||||||
|
// sealed, not just the parts that were already ciphertext at rest.
|
||||||
export async function exportData() {
|
export async function exportData() {
|
||||||
|
const me = identity();
|
||||||
|
if (!me) throw new Error("no identity yet");
|
||||||
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
|
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
|
||||||
return {
|
const payload = {
|
||||||
gsmolExport: 1,
|
|
||||||
exportedAt: Date.now(),
|
|
||||||
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
||||||
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
||||||
[address, { key, verified, history }])),
|
[address, { key, verified, history }])),
|
||||||
|
|
@ -260,6 +271,19 @@ export async function exportData() {
|
||||||
sent: sent.map(row =>
|
sent: sent.map(row =>
|
||||||
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
|
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
|
||||||
};
|
};
|
||||||
|
// Random per export: the key is the same every time (deterministic from the
|
||||||
|
// seed), so nonce reuse has to be ruled out the way it always is under a
|
||||||
|
// fixed key — a fresh 96-bit nonce per seal, not a fixed one like seal()'s
|
||||||
|
// in proto.js gets away with (there, a fresh ephemeral key each message
|
||||||
|
// makes the derived key itself unique, so a zero nonce is safe).
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const ciphertext = aeadEncrypt(exportKey(me.seed), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
|
||||||
|
return {
|
||||||
|
gsmolExport: 2,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
nonce: toBase64(nonce),
|
||||||
|
ciphertext: toBase64(ciphertext),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never overwrites a trust binding that already differs locally — the same
|
// Never overwrites a trust binding that already differs locally — the same
|
||||||
|
|
@ -274,14 +298,30 @@ export async function exportData() {
|
||||||
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
export async function importData(data) {
|
export async function importData(data) {
|
||||||
if (!data || data.gsmolExport !== 1) throw new Error("not a gsmol export file");
|
let payload;
|
||||||
|
if (data?.gsmolExport === 2) {
|
||||||
|
const me = identity();
|
||||||
|
if (!me) throw new Error("no identity yet — restore it before importing");
|
||||||
|
try {
|
||||||
|
const plaintext = aeadDecrypt(
|
||||||
|
exportKey(me.seed), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0));
|
||||||
|
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
|
} catch {
|
||||||
|
throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted");
|
||||||
|
}
|
||||||
|
} else if (data?.gsmolExport === 1) {
|
||||||
|
payload = data; // pre-encryption shape: the fields already sit at the top level
|
||||||
|
} else {
|
||||||
|
throw new Error("not a gsmol export file");
|
||||||
|
}
|
||||||
|
|
||||||
const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0,
|
const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0,
|
||||||
mailAdded: 0, malformed: 0 };
|
mailAdded: 0, malformed: 0 };
|
||||||
|
|
||||||
update(state => {
|
update(state => {
|
||||||
const servers = { ...(state.servers || {}) };
|
const servers = { ...(state.servers || {}) };
|
||||||
if (data.servers !== undefined && !isRecord(data.servers)) summary.malformed++;
|
if (payload.servers !== undefined && !isRecord(payload.servers)) summary.malformed++;
|
||||||
for (const [host, key] of Object.entries(isRecord(data.servers) ? data.servers : {})) {
|
for (const [host, key] of Object.entries(isRecord(payload.servers) ? payload.servers : {})) {
|
||||||
try {
|
try {
|
||||||
if (b32decode(key).length !== 32) throw new Error("bad length");
|
if (b32decode(key).length !== 32) throw new Error("bad length");
|
||||||
} catch { summary.malformed++; continue; }
|
} catch { summary.malformed++; continue; }
|
||||||
|
|
@ -291,8 +331,8 @@ export async function importData(data) {
|
||||||
state.servers = servers;
|
state.servers = servers;
|
||||||
|
|
||||||
const contacts = { ...(state.contacts || {}) };
|
const contacts = { ...(state.contacts || {}) };
|
||||||
if (data.contacts !== undefined && !isRecord(data.contacts)) summary.malformed++;
|
if (payload.contacts !== undefined && !isRecord(payload.contacts)) summary.malformed++;
|
||||||
for (const [address, c] of Object.entries(isRecord(data.contacts) ? data.contacts : {})) {
|
for (const [address, c] of Object.entries(isRecord(payload.contacts) ? payload.contacts : {})) {
|
||||||
try {
|
try {
|
||||||
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
|
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
|
||||||
} catch { summary.malformed++; continue; }
|
} catch { summary.malformed++; continue; }
|
||||||
|
|
@ -307,7 +347,7 @@ export async function importData(data) {
|
||||||
state.contacts = contacts;
|
state.contacts = contacts;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [box, rows] of [["inbox", data.inbox], ["sent", data.sent]]) {
|
for (const [box, rows] of [["inbox", payload.inbox], ["sent", payload.sent]]) {
|
||||||
if (rows !== undefined && !Array.isArray(rows)) summary.malformed++;
|
if (rows !== undefined && !Array.isArray(rows)) summary.malformed++;
|
||||||
for (const row of Array.isArray(rows) ? rows : []) {
|
for (const row of Array.isArray(rows) ? rows : []) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue