feat: auto-verify signed Reply-To on fetch; seal the export to the identity

This commit is contained in:
randogoth 2026-09-26 22:14:40 +03:00
parent 1385ecb56c
commit eef7bf9b1d
2 changed files with 99 additions and 35 deletions

View file

@ -2,7 +2,7 @@
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
// 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";
const KEY = "gsmol";
@ -243,16 +243,27 @@ export async function unreadCount() {
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
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
// in settings, meant for a password manager, not a downloadable file. This
// covers what fetching makes irreplaceable (SPEC.md §5 deletes mail from the
// server once acknowledged) plus the trust state that took real verification
// (RESOLVE, key comparison) to build up — pins and contacts.
// in settings, meant for a password manager, not a downloadable file. Beyond
// that, everything else in here was worth hiding too — contacts and pins are
// a social graph and a list of which mail servers you use, not just the mail
// 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() {
const me = identity();
if (!me) throw new Error("no identity yet");
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
return {
gsmolExport: 1,
exportedAt: Date.now(),
const payload = {
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
[address, { key, verified, history }])),
@ -260,6 +271,19 @@ export async function exportData() {
sent: sent.map(row =>
({ 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
@ -274,14 +298,30 @@ export async function exportData() {
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
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,
mailAdded: 0, malformed: 0 };
update(state => {
const servers = { ...(state.servers || {}) };
if (data.servers !== undefined && !isRecord(data.servers)) summary.malformed++;
for (const [host, key] of Object.entries(isRecord(data.servers) ? data.servers : {})) {
if (payload.servers !== undefined && !isRecord(payload.servers)) summary.malformed++;
for (const [host, key] of Object.entries(isRecord(payload.servers) ? payload.servers : {})) {
try {
if (b32decode(key).length !== 32) throw new Error("bad length");
} catch { summary.malformed++; continue; }
@ -291,8 +331,8 @@ export async function importData(data) {
state.servers = servers;
const contacts = { ...(state.contacts || {}) };
if (data.contacts !== undefined && !isRecord(data.contacts)) summary.malformed++;
for (const [address, c] of Object.entries(isRecord(data.contacts) ? data.contacts : {})) {
if (payload.contacts !== undefined && !isRecord(payload.contacts)) summary.malformed++;
for (const [address, c] of Object.entries(isRecord(payload.contacts) ? payload.contacts : {})) {
try {
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
} catch { summary.malformed++; continue; }
@ -307,7 +347,7 @@ export async function importData(data) {
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++;
for (const row of Array.isArray(rows) ? rows : []) {
try {