feat: adopt smolmail protocol 1.1 with accept tokens and leave-mail-on-server
This commit is contained in:
parent
eef7bf9b1d
commit
d6fa097626
11 changed files with 911 additions and 232 deletions
|
|
@ -36,13 +36,13 @@
|
|||
|
||||
<main id="setup" class="setup" hidden>
|
||||
<h2>welcome</h2>
|
||||
<p>Identity is one 32-byte seed. Back it up: it is the only secret, and losing it means losing the address.</p>
|
||||
<p>Identity is one 32-byte master secret. Back it up: it is the only secret, and losing it means losing the address.</p>
|
||||
<div class="row">
|
||||
<button id="create-identity">create a new identity</button>
|
||||
</div>
|
||||
<p class="hint">or restore from a saved seed:</p>
|
||||
<p class="hint">or restore from a saved master:</p>
|
||||
<div class="row restore-row">
|
||||
<label>seed<input id="restore-seed" placeholder="64 hex characters" spellcheck="false"></label>
|
||||
<label>master<input id="restore-master" placeholder="64 hex characters" spellcheck="false"></label>
|
||||
<label>address (optional)<input id="restore-address" placeholder="alice@example.org, if already registered"
|
||||
spellcheck="false"></label>
|
||||
<label id="restore-key-label">server's public key
|
||||
|
|
@ -59,6 +59,10 @@
|
|||
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">inbox</span>
|
||||
<span id="count-inbox" class="count"></span>
|
||||
</button>
|
||||
<button data-folder="requests" aria-label="requests">
|
||||
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">requests</span>
|
||||
<span id="count-requests" class="count"></span>
|
||||
</button>
|
||||
<button data-folder="sent" aria-label="sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label">sent</span></button>
|
||||
<button data-folder="contacts" aria-label="contacts"><svg class="icon"><use href="#i-contacts"/></svg><span class="label">contacts</span></button>
|
||||
</div>
|
||||
|
|
@ -100,7 +104,7 @@
|
|||
<section>
|
||||
<h3><span id="step-server" class="step" data-n="1">1</span> your server</h3>
|
||||
<p class="hint">Pin the key of the server that will hold your mail. Get it from its operator over a
|
||||
channel you already trust — registering, recalling and fetching all refuse an unpinned server (SPEC.md §4).</p>
|
||||
channel you already trust — registering, recalling and fetching all refuse an unpinned server.</p>
|
||||
<form id="pin-form">
|
||||
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
|
||||
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
|
||||
|
|
@ -119,7 +123,7 @@
|
|||
</form>
|
||||
<details>
|
||||
<summary>already registered elsewhere?</summary>
|
||||
<p class="hint">Restoring a seed brings back the identity, not the memory of what address it was
|
||||
<p class="hint">Restoring a master brings back the identity, not the memory of what address it was
|
||||
registered under in a different browser — the server was never asked. Recall it here: this
|
||||
looks the address up and checks it resolves to this identity's key, without registering anything.</p>
|
||||
<form id="recall-form">
|
||||
|
|
@ -134,11 +138,23 @@
|
|||
<div id="identity-card"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>fetch</h3>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" id="leave-on-server">
|
||||
<span>
|
||||
<span class="check-title">leave mail on server</span>
|
||||
<span class="hint">Fetch keeps a copy on the server instead of deleting it; delete a message
|
||||
here to remove it from both.</span>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>backup</h3>
|
||||
<p class="hint">Mail, once fetched, exists only here — the server deletes it as it's picked up
|
||||
(SPEC.md §5). This backs up the local inbox, sent mail, contacts and pinned servers into one
|
||||
file. It never includes the seed: that has its own reveal-and-copy flow above, meant for a
|
||||
<p class="hint">Mail, once fetched, exists only here unless "leave mail on server" is on above.
|
||||
This backs up the local inbox, requests, sent mail, contacts and pinned servers into one file.
|
||||
It never includes the master: that has its own reveal-and-copy flow above, meant for a
|
||||
password manager, not a downloadable file.</p>
|
||||
<div class="backup-row">
|
||||
<button type="button" id="export-data">export</button>
|
||||
|
|
|
|||
393
web/js/app.js
393
web/js/app.js
|
|
@ -1,11 +1,12 @@
|
|||
// UI wiring for the smol mail client: identity setup, folders, reading,
|
||||
// composing, fetching with verify-before-ack, and the §4/§7 trust surfaces.
|
||||
|
||||
import { hex, timingSafeEqual, utf8Bytes } from "./crypto.js";
|
||||
import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js";
|
||||
import {
|
||||
DEFAULT_PORT, SmolError, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp,
|
||||
fetchOp, fingerprint, makeCert, messageId, newIdentity, openSession, parseAddress,
|
||||
parseFrontmatter, registerOp, resolveOp, seal, sendOp, unseal, walkChain,
|
||||
DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode,
|
||||
b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed,
|
||||
makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp,
|
||||
resolveOp, seal, sendOp, tokenFor, unseal, walkChain,
|
||||
} from "./proto.js";
|
||||
import * as store from "./store.js";
|
||||
import { connectStream } from "./transport.js";
|
||||
|
|
@ -37,11 +38,25 @@ function icon(name) {
|
|||
const placeholder = (text, warn = false) =>
|
||||
el("p", { className: warn ? "placeholder warn" : "placeholder", textContent: text });
|
||||
|
||||
// A native <dialog> shown with showModal() renders in the browser's top
|
||||
// layer, above the rest of the page — a toast anchored to <body> sits
|
||||
// visually behind an open dialog's backdrop no matter its z-index. Keeping
|
||||
// the toast inside whichever dialog is open puts it in that same top-layer
|
||||
// subtree, so a status raised while settings or compose is open (e.g. after
|
||||
// "register") actually gets seen instead of landing, barely visible, behind it.
|
||||
function reparentStatus() {
|
||||
const toast = $("status");
|
||||
const target = document.querySelector("dialog[open]") ?? document.body;
|
||||
if (toast.parentElement !== target) target.append(toast);
|
||||
}
|
||||
for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("close", reparentStatus);
|
||||
|
||||
// The status line is a toast: click to dismiss, empty means hidden. Plain
|
||||
// notices fade; warnings stay until dismissed, since those carry the §4/§8
|
||||
// trust messages the user must actually read.
|
||||
let toastTimer = null;
|
||||
function status(text, warn = false) {
|
||||
reparentStatus();
|
||||
const toast = $("status");
|
||||
toast.textContent = text;
|
||||
toast.hidden = !text;
|
||||
|
|
@ -125,7 +140,7 @@ async function connect(addr, { requirePin }) {
|
|||
const opened = await openSession(stream, addr.host, pinned);
|
||||
if (!pinned)
|
||||
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
|
||||
`RESOLVE results from this session are UNVERIFIED (SPEC.md §8).`, true);
|
||||
`Lookups from this session are UNVERIFIED.`, true);
|
||||
return opened;
|
||||
}
|
||||
|
||||
|
|
@ -151,18 +166,22 @@ function showApp() {
|
|||
}
|
||||
|
||||
$("create-identity").onclick = () => {
|
||||
store.setIdentity(newIdentity().seed);
|
||||
store.setIdentity(newMaster());
|
||||
showApp();
|
||||
status("identity created. Back up the seed in settings now; it is the only secret.");
|
||||
status("identity created. Back up the master in settings now; it is the only secret.");
|
||||
openSettings();
|
||||
};
|
||||
|
||||
$("restore-identity").onclick = async () => {
|
||||
const text = $("restore-seed").value.trim();
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true);
|
||||
const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
|
||||
const text = $("restore-master").value.trim();
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a master is 64 hex characters", true);
|
||||
const masterBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
|
||||
try {
|
||||
store.setIdentity(seedBytes);
|
||||
// §2: a master alone does not say which rotation index a server has
|
||||
// bound; assume 0 (a never-rotated identity, the common case) until an
|
||||
// address lets us confirm it below. Either way the accepted-correspondent
|
||||
// set is unknown, so it must not overwrite the server's (syncOk: false).
|
||||
store.restoreMaster(masterBytes, 0);
|
||||
} catch (err) {
|
||||
return status(err.message, true);
|
||||
}
|
||||
|
|
@ -198,7 +217,7 @@ $("restore-identity").onclick = async () => {
|
|||
|
||||
if (addressText) {
|
||||
try {
|
||||
const addr = await recallAccount(addressText);
|
||||
const addr = await recallAndDiscoverRotation(masterBytes, addressText);
|
||||
lines.push(`recalled ${addr.short}.`);
|
||||
renderAccount();
|
||||
renderSettings();
|
||||
|
|
@ -260,6 +279,7 @@ function resetView() {
|
|||
// leaving the inbox used to blank the badge.
|
||||
async function updateUnreadBadge() {
|
||||
$("count-inbox").textContent = (await store.unreadCount()) || "";
|
||||
$("count-requests").textContent = (await store.requestsUnreadCount()) || "";
|
||||
}
|
||||
|
||||
async function renderFolder() {
|
||||
|
|
@ -278,7 +298,8 @@ async function renderFolder() {
|
|||
if (token !== renderToken) return;
|
||||
list.replaceChildren();
|
||||
if (!rows.length) {
|
||||
list.append(placeholder(folder === "inbox" ? "no mail — fetch" : "nothing sent"));
|
||||
list.append(placeholder(folder === "inbox" ? "no mail — fetch"
|
||||
: folder === "requests" ? "no unsolicited mail" : "nothing sent"));
|
||||
return void updateListStatus();
|
||||
}
|
||||
for (let offset = 0; offset < rows.length; offset += BATCH) {
|
||||
|
|
@ -292,8 +313,9 @@ async function renderFolder() {
|
|||
// A real button, so every row is reachable and openable from the keyboard.
|
||||
function messageRow(row) {
|
||||
const opened = describe(row);
|
||||
// Only the inbox has a read state; sent copies were never "unread".
|
||||
const unread = folder === "inbox" && !store.isRead(row.id);
|
||||
// Both mail tiers have a read state; sent copies were never "unread".
|
||||
const inbound = folder === "inbox" || folder === "requests";
|
||||
const unread = inbound && !store.isRead(row.id);
|
||||
const item = el("button", {
|
||||
type: "button",
|
||||
className: `item${row.id === selectedId ? " selected" : ""}${unread ? " unread" : ""}`,
|
||||
|
|
@ -302,7 +324,7 @@ function messageRow(row) {
|
|||
if (row.id === selectedId) item.setAttribute("aria-current", "true");
|
||||
const who = el("div", { className: "who",
|
||||
textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") });
|
||||
if (folder === "inbox" && !opened.error && !opened.knownSender)
|
||||
if (inbound && !opened.error && !opened.knownSender)
|
||||
who.append(el("span", { className: "dot", title: "sender key not bound to an address" }));
|
||||
item.append(who,
|
||||
el("div", { className: "mid" },
|
||||
|
|
@ -356,7 +378,7 @@ function openEnvelope(row) {
|
|||
const opened = unseal(store.identities(), row.envelope);
|
||||
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
|
||||
entry = { ...opened, fields, body,
|
||||
subject: fields.Subject || "",
|
||||
subject: fields.subject || "",
|
||||
snippet: body.replace(/\s+/g, " ").trim().slice(0, 140) };
|
||||
} catch (err) {
|
||||
entry = { error: err.message };
|
||||
|
|
@ -376,9 +398,9 @@ function describe(row) {
|
|||
if (opened.error) return opened;
|
||||
const known = store.addressForKey(opened.sender);
|
||||
let replyAddress = null;
|
||||
if (opened.fields["Reply-To"]) {
|
||||
if (opened.fields["reply-to"]) {
|
||||
try {
|
||||
const claimed = parseAddress(opened.fields["Reply-To"]);
|
||||
const claimed = parseAddress(opened.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))
|
||||
|
|
@ -419,7 +441,8 @@ async function openMessage(id) {
|
|||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages");
|
||||
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
|
||||
|
||||
const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject");
|
||||
// "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content.
|
||||
const fields = Object.entries(opened.fields).filter(([k]) => k !== "subject" && k !== "accept");
|
||||
const actions = el("div", { className: "actions" });
|
||||
view.replaceChildren(...[
|
||||
back,
|
||||
|
|
@ -433,7 +456,32 @@ async function openMessage(id) {
|
|||
el("pre", { textContent: opened.body }),
|
||||
actions,
|
||||
].filter(node => node != null));
|
||||
if (folder !== "inbox") return;
|
||||
|
||||
// Deleting removes the local copy, and the server's too if "leave mail on
|
||||
// server" left one there to remove (settings; SPEC.md §10).
|
||||
actions.append(el("button", { type: "button", onclick: async () => {
|
||||
try {
|
||||
await deleteMessage(folder, row);
|
||||
status("message deleted");
|
||||
selectedId = null;
|
||||
resetView();
|
||||
renderFolder();
|
||||
} catch (err) {
|
||||
status(err.message, true); // the local copy is kept if a needed server delete failed
|
||||
}
|
||||
} }, "delete"));
|
||||
|
||||
if (folder !== "inbox" && folder !== "requests") return;
|
||||
|
||||
if (folder === "requests" && opened.knownSender) {
|
||||
const known = store.addressForKey(opened.sender);
|
||||
if (!store.accepted(known)?.active) {
|
||||
actions.before(placeholder(
|
||||
`unsolicited mail from ${known}, not yet accepted into your main tier`));
|
||||
actions.append(el("button", { type: "button", onclick: () => acceptContact(known) },
|
||||
"accept — future mail lands in your main tier"));
|
||||
}
|
||||
}
|
||||
|
||||
if (opened.knownSender) {
|
||||
actions.append(el("button", { type: "button", onclick: () => startReply(row) },
|
||||
|
|
@ -479,6 +527,28 @@ async function openMessage(id) {
|
|||
}
|
||||
}
|
||||
|
||||
// Deletes a message locally, and from the server too if it might still be
|
||||
// sitting there (only possible when "leave mail on server" was on when it was
|
||||
// fetched — SPEC.md §10). Sent copies are local-only; there is nothing
|
||||
// server-side to remove for them (§5.6). Throws, leaving the local copy in
|
||||
// place, if a needed server-side delete fails — otherwise a message could
|
||||
// look gone locally while silently persisting on the server.
|
||||
async function deleteMessage(folder, row) {
|
||||
if (folder !== "sent" && row.keptOnServer) {
|
||||
const me = store.identity();
|
||||
const addr = accountAddress();
|
||||
if (!me || !addr) throw new SmolError("not registered; cannot reach the server to delete this message");
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
try {
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||
await deleteOp(opened.session, [unhex(row.id)]);
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
}
|
||||
await store.removeMessage(folder, row.id);
|
||||
}
|
||||
|
||||
// §4/§8: a signature always verifies here, because unseal() throws otherwise.
|
||||
// What can be weak is the binding between that key and an address, so that is
|
||||
// what the chip reports.
|
||||
|
|
@ -585,7 +655,8 @@ compose.querySelector("form").onsubmit = async (event) => {
|
|||
|
||||
async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||
const me = store.identity();
|
||||
if (!me) throw new SmolError("no identity yet");
|
||||
const master = store.master();
|
||||
if (!me || !master) throw new SmolError("no identity yet");
|
||||
const addr = parseAddress(toText);
|
||||
const recipient = await resolveRecipient(addr);
|
||||
const account = accountAddress();
|
||||
|
|
@ -593,12 +664,19 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
|||
// 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)]);
|
||||
// §5.8: hand an accepted correspondent the token for our own mailbox, so a
|
||||
// first reply from them reaches our main tier.
|
||||
const accepted = store.accepted(addr.short);
|
||||
if (accepted?.active) fields.push(["Accept", b32encode(tokenFor(master, accepted.identity))]);
|
||||
const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v),
|
||||
body.replace(/\s+$/, "") + "\n"));
|
||||
const envelope = seal(me, recipient, bodyBytes);
|
||||
// §5.8: our token for their mailbox, if they have given us one.
|
||||
const held = store.tokenFrom(addr.short);
|
||||
const mac = held ? acceptMac(held, messageId(envelope)) : null;
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
try {
|
||||
await sendOp(opened.session, envelope);
|
||||
await sendOp(opened.session, envelope, mac);
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
|
|
@ -609,7 +687,7 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
|||
envelope: seal(me, me.publicKey, bodyBytes),
|
||||
sentAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
status(`sent to ${addr.short}`);
|
||||
status(`sent to ${addr.short}${mac ? " (accepted)" : ""}`);
|
||||
if (folder === "sent") renderFolder();
|
||||
}
|
||||
|
||||
|
|
@ -635,10 +713,41 @@ async function resolveRecipient(addr) {
|
|||
|
||||
// --- fetch ----------------------------------------------------------------------
|
||||
|
||||
// The address we know a signer by: a contact, or the Reply-To it signed for
|
||||
// itself. Naming a mailbox is not trusting a key, so nothing is pinned here
|
||||
// (§5.7, §8).
|
||||
function addressOfSigner(sender, replyToField) {
|
||||
const known = store.addressForKey(sender);
|
||||
if (known) return known;
|
||||
if (!replyToField) return null;
|
||||
try {
|
||||
const parsed = parseAddress(replyToField);
|
||||
if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short;
|
||||
} catch { /* malformed claim: no address to learn a token under */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// §5.8: an Accept field is bound to the signer of the message that carried
|
||||
// it, which unseal() has already verified.
|
||||
function learnTokenFrom(unsealed, opened) {
|
||||
const raw = opened.fields["accept"];
|
||||
if (!raw) return;
|
||||
let token;
|
||||
try {
|
||||
token = b32decode(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (token.length !== TOKEN_LEN) return;
|
||||
const address = addressOfSigner(unsealed.sender, opened.fields["reply-to"]);
|
||||
if (address) store.learnToken(address, token);
|
||||
}
|
||||
|
||||
$("fetch").onclick = async () => {
|
||||
const me = store.identity();
|
||||
const master = store.master();
|
||||
const addr = accountAddress();
|
||||
if (!me) return status("no identity yet", true);
|
||||
if (!me || !master) return status("no identity yet", true);
|
||||
if (!addr) return status("not registered; register in settings first", true);
|
||||
const button = $("fetch"), label = button.querySelector(".label");
|
||||
const resting = label.textContent;
|
||||
|
|
@ -647,15 +756,24 @@ $("fetch").onclick = async () => {
|
|||
status("fetching…");
|
||||
let stored = 0, verified = 0;
|
||||
const rejected = [];
|
||||
// §10: acknowledging (deleting) is the default; "leave mail on server" pages
|
||||
// forward by cursor instead, so already-fetched mail is never re-downloaded
|
||||
// even though it isn't deleted (storeIfNew also dedupes, as a second line
|
||||
// of defense).
|
||||
const leaveOnServer = store.leaveOnServer();
|
||||
let [afterTime, afterId] = store.cursor();
|
||||
try {
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
try {
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||
const { sync, tokens } = store.tokenSet(master);
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||
for (;;) {
|
||||
const records = await fetchOp(opened.session);
|
||||
const records = await fetchOp(opened.session, afterTime, afterId);
|
||||
if (!records.length) break;
|
||||
const acked = [];
|
||||
for (const record of records) {
|
||||
afterTime = record.receivedAt;
|
||||
afterId = record.id;
|
||||
let unsealed;
|
||||
try {
|
||||
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
||||
|
|
@ -668,29 +786,39 @@ $("fetch").onclick = async () => {
|
|||
rejected.push(`${hex(record.id)}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt };
|
||||
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
|
||||
tier: record.isRequest ? store.TIER_REQUESTS : store.TIER_MAIN, keptOnServer: leaveOnServer };
|
||||
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) {
|
||||
const described = describe(row);
|
||||
if (described.replyAddress) {
|
||||
try {
|
||||
bindReplyAddress(claim, unsealed.sender);
|
||||
bindReplyAddress(described.replyAddress, unsealed.sender);
|
||||
verified++;
|
||||
} catch { /* already known under a different key: leave it be */ }
|
||||
}
|
||||
learnTokenFrom(unsealed, described);
|
||||
}
|
||||
acked.push(record.id);
|
||||
}
|
||||
if (!acked.length) break;
|
||||
await deleteOp(opened.session, acked);
|
||||
if (leaveOnServer) {
|
||||
// Persisted per batch, so an interrupted fetch resumes here rather
|
||||
// than re-paging from the start next time.
|
||||
store.setCursor(afterTime, afterId);
|
||||
} else if (acked.length) {
|
||||
await deleteOp(opened.session, acked);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
||||
// record left on the server (rejected above) simply resurfaces then.
|
||||
if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN));
|
||||
status([`${stored} new, ${rejected.length} rejected`,
|
||||
verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null,
|
||||
...rejected.map(line => ` ${line}`),
|
||||
|
|
@ -710,6 +838,7 @@ $("fetch").onclick = async () => {
|
|||
function renderContacts(list) {
|
||||
const contacts = store.allContacts();
|
||||
list.replaceChildren(...contacts.map(({ address, key, verified, history }) => {
|
||||
const accepted = store.accepted(address);
|
||||
const item = el("button", {
|
||||
type: "button",
|
||||
className: `item contact${address === selectedId ? " selected" : ""}`,
|
||||
|
|
@ -722,6 +851,7 @@ function renderContacts(list) {
|
|||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||
el("span", { className: verified ? "badge ok" : "badge",
|
||||
textContent: verified ? "verified key" : "tofu" }),
|
||||
accepted?.active ? el("span", { className: "badge ok", textContent: "accepted" }) : null,
|
||||
history.length ? el("span", { className: "badge",
|
||||
textContent: `${history.length} previous` }) : null));
|
||||
item.dataset.search =
|
||||
|
|
@ -753,6 +883,18 @@ function openContact(address) {
|
|||
}
|
||||
} }, icon("refresh"), "re-resolve");
|
||||
|
||||
const isAccepted = Boolean(store.accepted(address)?.active);
|
||||
const tokenButton = el("button", { type: "button",
|
||||
textContent: isAccepted ? "block" : "accept",
|
||||
onclick: async (event) => {
|
||||
event.target.disabled = true;
|
||||
try {
|
||||
if (isAccepted) await blockContact(address); else await acceptContact(address);
|
||||
} finally {
|
||||
event.target.disabled = false;
|
||||
}
|
||||
} });
|
||||
|
||||
view.replaceChildren(...[
|
||||
el("button", { type: "button", className: "back",
|
||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"),
|
||||
|
|
@ -763,10 +905,16 @@ function openContact(address) {
|
|||
field("fingerprint", fingerprint(known.key)),
|
||||
field("public key", b32encode(known.key)),
|
||||
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "the address")),
|
||||
el("div", { className: "seed-card" },
|
||||
el("h4", { textContent: "accept token" }),
|
||||
el("p", { className: "hint", textContent: isAccepted
|
||||
? "This contact's mail lands in your main tier."
|
||||
: "This contact's mail lands in requests until accepted." }),
|
||||
el("div", { className: "actions" }, tokenButton)),
|
||||
known.history.length ? el("section", { className: "history" },
|
||||
el("h3", { textContent: `previous keys (${known.history.length})` }),
|
||||
el("p", { className: "hint", textContent:
|
||||
"Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these was sent " +
|
||||
"Replaced by a signed rotation chain. Mail signed with these was sent " +
|
||||
"before the change; rotation is not revocation, so a stolen key can still rotate onward." }),
|
||||
...known.history.map(({ key, until }) => el("div", { className: "keyblock" },
|
||||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||
|
|
@ -796,7 +944,7 @@ async function refreshContact(address) {
|
|||
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
||||
} else if (timingSafeEqual(known.key, current)) {
|
||||
status(`${addr.short}: key unchanged`);
|
||||
} else if (walkChain(known.key, current, chain)) {
|
||||
} else if (walkChain(addr.user, known.key, current, chain)) {
|
||||
store.saveContact(addr.short, current, known.verified);
|
||||
status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
|
||||
} else {
|
||||
|
|
@ -810,6 +958,59 @@ async function refreshContact(address) {
|
|||
else renderFolder();
|
||||
}
|
||||
|
||||
// --- accept tokens (§5.8) --------------------------------------------------------
|
||||
|
||||
// An accept or a block only takes effect once the server holds the changed
|
||||
// set, so it is pushed right away rather than at the next fetch.
|
||||
async function pushTokens() {
|
||||
const me = store.identity();
|
||||
const master = store.master();
|
||||
const addr = accountAddress();
|
||||
if (!me || !master) throw new SmolError("no identity yet");
|
||||
if (!addr) {
|
||||
status("not registered; the set will be pushed with your first fetch", true);
|
||||
return 0;
|
||||
}
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
try {
|
||||
const { sync, tokens } = store.tokenSet(master);
|
||||
return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Admit a contact to the main tier; their token travels in our next message
|
||||
// to them.
|
||||
async function acceptContact(address) {
|
||||
try {
|
||||
const key = store.contact(address)?.key;
|
||||
if (!key) throw new SmolError(`no key for ${address} yet`);
|
||||
store.accept(address, key);
|
||||
store.setSyncOk(true);
|
||||
const held = await pushTokens();
|
||||
status(`${address} accepted; server now holds ${held} accept token(s)`);
|
||||
renderFolder();
|
||||
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Withdraw a contact's accept token; their mail lands in requests from their
|
||||
// next message on.
|
||||
async function blockContact(address) {
|
||||
try {
|
||||
store.block(address);
|
||||
const held = await pushTokens();
|
||||
status(`${address} blocked; server now holds ${held} accept token(s)`);
|
||||
renderFolder();
|
||||
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- settings ---------------------------------------------------------------------
|
||||
|
||||
const settings = $("settings");
|
||||
|
|
@ -822,6 +1023,8 @@ function openSettings() {
|
|||
$("settings-open").onclick = openSettings;
|
||||
$("settings-close").onclick = () => settings.close();
|
||||
|
||||
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
|
||||
|
||||
// Registering against an unpinned server is refused, so the dialog is ordered
|
||||
// by what depends on what; the chips say how far along that order you are.
|
||||
function step(id, done) {
|
||||
|
|
@ -835,6 +1038,8 @@ function renderSettings() {
|
|||
const account = store.account();
|
||||
const pins = store.allPins();
|
||||
|
||||
$("leave-on-server").checked = store.leaveOnServer();
|
||||
|
||||
step("step-server", pins.length > 0);
|
||||
step("step-address", Boolean(account));
|
||||
|
||||
|
|
@ -850,16 +1055,18 @@ function renderSettings() {
|
|||
const card = $("identity-card");
|
||||
card.replaceChildren();
|
||||
if (!me) return;
|
||||
const masterBytes = store.master();
|
||||
const share = account ? accountAddress().uri(me.publicKey) : null;
|
||||
const retired = store.identities().length - 1;
|
||||
const rotations = store.rotations();
|
||||
const acceptedCount = store.allAccepted().filter(a => a.active).length;
|
||||
|
||||
// Settings opens by itself right after an identity is created, which is a poor
|
||||
// moment to paint the only secret across the screen — so it is asked for.
|
||||
const masked = "\u2022".repeat(64);
|
||||
const seed = el("div", { className: "seed", textContent: masked });
|
||||
const master = el("div", { className: "seed", textContent: masked });
|
||||
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
|
||||
const showing = seed.textContent !== masked;
|
||||
seed.textContent = showing ? masked : hex(me.seed);
|
||||
const showing = master.textContent !== masked;
|
||||
master.textContent = showing ? masked : hex(masterBytes);
|
||||
reveal.textContent = showing ? "reveal" : "hide";
|
||||
} });
|
||||
|
||||
|
|
@ -869,27 +1076,30 @@ function renderSettings() {
|
|||
field("shareable", share ?? "register above to get one", !share,
|
||||
share ? copyButton(share, "your address") : null),
|
||||
el("div", { className: "seed-card" },
|
||||
el("h4", { textContent: "recovery seed" }),
|
||||
el("div", { className: "row" }, seed, reveal, copyButton(hex(me.seed), "the seed")),
|
||||
el("h4", { textContent: "recovery master" }),
|
||||
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the master")),
|
||||
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " +
|
||||
"write as you, and losing it loses the address with it — there is no reset. Copy it " +
|
||||
"somewhere safe now." })),
|
||||
field("retired keys", String(retired)),
|
||||
retired ? el("p", { className: "hint",
|
||||
textContent: "Kept so mail sealed to a superseded key stays readable." }) : null,
|
||||
field("rotations", String(rotations)),
|
||||
rotations ? el("p", { className: "hint",
|
||||
textContent: "Earlier keys are re-derived on demand from the master, so mail sealed to a " +
|
||||
"superseded key stays readable." }) : null,
|
||||
field("accepted correspondents", String(acceptedCount) +
|
||||
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
|
||||
el("details", {},
|
||||
el("summary", { textContent: "advanced" }),
|
||||
el("p", { className: "hint", textContent:
|
||||
"Rotating issues a fresh key with a signed certificate proving it replaces this one. " +
|
||||
"Contacts accept the change from that chain, and the old seed is kept, since mail sealed " +
|
||||
"to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }),
|
||||
"Rotating issues a fresh key — the next one derivable from the master — with a signed " +
|
||||
"certificate proving it replaces this one. Contacts accept the change from that chain, and " +
|
||||
"the old key stays readable with nothing else. Rotation is not revocation." }),
|
||||
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }),
|
||||
el("p", { className: "hint", textContent:
|
||||
"Log out to remove this identity from this browser entirely: the seed, pins, contacts and " +
|
||||
"every cached message. Without a backup of the seed there is no way back in — same as any " +
|
||||
"other loss of it. Fetching deletes from the server once acknowledged (SPEC.md §5), so a " +
|
||||
"message already fetched here has no other copy anywhere; anything still unfetched stays " +
|
||||
"on the server and comes back on the next fetch after logging back in." }),
|
||||
"Log out to remove this identity from this browser entirely: the master, pins, contacts and " +
|
||||
"every cached message. Without a backup of the master there is no way back in — same as any " +
|
||||
"other loss of it. Fetching deletes from the server once acknowledged unless \u201cleave mail " +
|
||||
"on server\u201d is on, so a message already fetched here may have no other " +
|
||||
"copy anywhere; anything still on the server comes back on the next fetch after logging back in." }),
|
||||
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
|
||||
onclick: logout })),
|
||||
].filter(node => node != null));
|
||||
|
|
@ -948,13 +1158,18 @@ $("import-form").onsubmit = (event) => {
|
|||
$("register-form").onsubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const submit = form.querySelector("button");
|
||||
const resting = submit.textContent;
|
||||
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||
submit.textContent = "registering…";
|
||||
try {
|
||||
const me = store.identity();
|
||||
if (!me) throw new SmolError("no identity yet");
|
||||
const addr = parseAddress(form.address.value.trim());
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
try {
|
||||
await registerOp(opened.session, { username: addr.user, identity: me, token: form.token.value.trim() });
|
||||
await registerOp(opened.session, opened.serverStatic,
|
||||
{ username: addr.user, identity: me, token: form.token.value.trim() });
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
|
|
@ -965,10 +1180,13 @@ $("register-form").onsubmit = async (event) => {
|
|||
form.reset();
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = resting;
|
||||
}
|
||||
};
|
||||
|
||||
// Restoring a seed brings back the identity, not the memory of what address a
|
||||
// Restoring a master brings back the identity, not the memory of what address a
|
||||
// *different* browser registered it under — "account" is local-only state,
|
||||
// never asked of the server. This binds it without REGISTER: RESOLVE the
|
||||
// address and require it name this exact key, so a typo or someone else's
|
||||
|
|
@ -991,9 +1209,41 @@ async function recallAccount(text) {
|
|||
return addr;
|
||||
}
|
||||
|
||||
// §2: a master alone does not say which rotation index a server has bound,
|
||||
// so restoring resolves the address and walks indices 0..MAX_CHAIN until one
|
||||
// derives the key RESOLVE returned, correcting restore-identity's default
|
||||
// guess of index 0. Also binds "account", like recallAccount().
|
||||
async function recallAndDiscoverRotation(masterBytes, text) {
|
||||
const addr = parseAddress(text.trim());
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
let current;
|
||||
try {
|
||||
({ identity: current } = await resolveOp(opened.session, addr.user));
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
let found = null;
|
||||
for (let n = 0; n <= MAX_CHAIN; n++) {
|
||||
if (timingSafeEqual(identityFromSeed(identitySeed(masterBytes, n)).publicKey, current)) {
|
||||
found = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found === null)
|
||||
throw new SmolError(`the key bound to ${addr.short} is not derived from this master ` +
|
||||
`within ${MAX_CHAIN} rotations`);
|
||||
store.setRotationIndex(found);
|
||||
store.setAccount(addr);
|
||||
return addr;
|
||||
}
|
||||
|
||||
$("recall-form").onsubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const submit = form.querySelector("button");
|
||||
const resting = submit.textContent;
|
||||
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||
submit.textContent = "recalling…";
|
||||
try {
|
||||
const me = store.identity();
|
||||
const addr = await recallAccount(form.address.value);
|
||||
|
|
@ -1003,6 +1253,9 @@ $("recall-form").onsubmit = async (event) => {
|
|||
form.reset();
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = resting;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1017,7 +1270,7 @@ $("export-data").onclick = async () => {
|
|||
// 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");
|
||||
status("exported — sealed to this identity; only a browser with this master restored can open it");
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
|
|
@ -1046,40 +1299,50 @@ $("import-data").onchange = async (event) => {
|
|||
}
|
||||
};
|
||||
|
||||
async function rotateIdentity() {
|
||||
// §7: rotate to the next index's derived key and rebind the account with a
|
||||
// signed certificate. The superseded key stays derivable from the master,
|
||||
// since mail sealed to it stays readable with nothing else.
|
||||
async function rotateIdentity(event) {
|
||||
const me = store.identity();
|
||||
const master = store.master();
|
||||
const addr = accountAddress();
|
||||
if (!me || !addr) return status("rotate needs a registered account", true);
|
||||
if (!me || !master || !addr) return status("rotate needs a registered account", true);
|
||||
if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " +
|
||||
"chain. The old key is kept, since mail sealed to it stays readable with nothing else."))
|
||||
return;
|
||||
const button = event.target;
|
||||
button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||
button.textContent = "rotating…";
|
||||
try {
|
||||
const fresh = newIdentity();
|
||||
const cert = makeCert(me, fresh.seed);
|
||||
const freshSeed = identitySeed(master, store.rotations() + 1);
|
||||
const fresh = identityFromSeed(freshSeed);
|
||||
const cert = makeCert(addr.user, me, freshSeed);
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
try {
|
||||
await registerOp(opened.session, { username: addr.user, identity: fresh, cert });
|
||||
await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert });
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
store.rotateIdentity(fresh.seed);
|
||||
store.advanceRotation();
|
||||
// The key set grew, so a cached "not one of our keys" failure may be stale.
|
||||
openedCache.clear();
|
||||
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
|
||||
renderAccount();
|
||||
renderSettings();
|
||||
renderSettings(); // rebuilds the identity card, this button included
|
||||
renderFolder();
|
||||
} catch (err) {
|
||||
button.disabled = false;
|
||||
button.textContent = "rotate identity";
|
||||
status(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike rotate, nothing is kept: this is the seed's only local copy, gone.
|
||||
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
||||
async function logout() {
|
||||
if (!window.confirm(
|
||||
"Log out and erase this browser?\n\n" +
|
||||
"This deletes the seed, pins, contacts and every cached message from this browser. Make sure " +
|
||||
"the seed is backed up elsewhere first — without it there is no way back in."
|
||||
"This deletes the master, pins, contacts and every cached message from this browser. Make sure " +
|
||||
"the master is backed up elsewhere first — without it there is no way back in."
|
||||
)) return;
|
||||
await store.clearAll();
|
||||
location.reload();
|
||||
|
|
|
|||
123
web/js/proto.js
123
web/js/proto.js
|
|
@ -1,18 +1,20 @@
|
|||
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
|
||||
// signed envelopes, body frontmatter, key rotation, and the framed request
|
||||
// and response bodies of the five operations.
|
||||
// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed and
|
||||
// signed envelopes, body frontmatter, key rotation, accept tokens, and the
|
||||
// framed request and response bodies of the five operations.
|
||||
|
||||
import {
|
||||
aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519,
|
||||
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, randomBytes, sha256, timingSafeEqual,
|
||||
utf8Bytes, x25519, x25519Base,
|
||||
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, hmacSha256, randomBytes, sha256,
|
||||
timingSafeEqual, utf8Bytes, x25519, x25519Base,
|
||||
} from "./crypto.js";
|
||||
import { NxInitiator } from "./noise.js";
|
||||
|
||||
export const DEFAULT_PORT = 1961;
|
||||
export const KEY_LEN = 32, SIG_LEN = 64, CERT_LEN = 136, ID_LEN = 16;
|
||||
export const KEY_LEN = 32, SIG_LEN = 64, CERT_LEN = 200, ID_LEN = 32, TOKEN_LEN = 32;
|
||||
export const MAX_FRAME = 1 << 20, NOISE_PAYLOAD = 65535 - 16, PAD_TO = 1024;
|
||||
export const ENVELOPE_HEADER = 69, PAYLOAD_HEADER = 45, MAX_CHAIN = 16;
|
||||
export const MAX_SKEW = 86400; // §5.3: how far ahead of our clock a payload may be dated
|
||||
export const FLAG_REQUESTS = 0x01; // §6.1: set when a FETCH record missed an accept token
|
||||
const FRONTMATTER_MAX = 4096, FRONTMATTER_KEYS = 64;
|
||||
|
||||
export const OP = { AUTH: 0x00, RESOLVE: 0x01, SEND: 0x02, FETCH: 0x03, DELETE: 0x04, REGISTER: 0x05 };
|
||||
|
|
@ -25,7 +27,9 @@ export class SmolError extends Error {}
|
|||
const LABEL = Object.freeze({
|
||||
auth: utf8Bytes("smolmail/1 auth"), seal: utf8Bytes("smolmail/1 seal"),
|
||||
msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"),
|
||||
rotate: utf8Bytes("smolmail/1 rotate"),
|
||||
rotate: utf8Bytes("smolmail/1 rotate"), identity: utf8Bytes("smolmail/1 identity"),
|
||||
accept: utf8Bytes("smolmail/1 accept"), mac: utf8Bytes("smolmail/1 mac"),
|
||||
register: utf8Bytes("smolmail/1 register"),
|
||||
});
|
||||
|
||||
// --- encoding helpers -------------------------------------------------------
|
||||
|
|
@ -89,16 +93,37 @@ export class Reader {
|
|||
get done() { return this.pos === this.buf.length; }
|
||||
}
|
||||
|
||||
// --- identity ---------------------------------------------------------------
|
||||
// --- identity (§2) ------------------------------------------------------------
|
||||
|
||||
// §2: an Ed25519 keypair with the X25519 agreement keys derived from it.
|
||||
// An Ed25519 keypair with the X25519 agreement keys derived from it.
|
||||
export function identityFromSeed(seed) {
|
||||
if (seed.length !== KEY_LEN) throw new SmolError(`identity seed must be ${KEY_LEN} bytes`);
|
||||
return { seed, publicKey: ed25519PublicKey(seed) };
|
||||
}
|
||||
|
||||
export function newIdentity() {
|
||||
return identityFromSeed(randomBytes(KEY_LEN));
|
||||
export function newMaster() {
|
||||
return randomBytes(KEY_LEN);
|
||||
}
|
||||
|
||||
// §2: the only secret a user holds. Every rotation index's signing seed, and
|
||||
// the accept key, are derived from it with HKDF.
|
||||
export function identitySeed(master, index) {
|
||||
return hkdfSha256(master, new Uint8Array(0), concat(LABEL.identity, u32BE(index)));
|
||||
}
|
||||
|
||||
export function acceptKeyFor(master) {
|
||||
return hkdfSha256(master, new Uint8Array(0), LABEL.accept);
|
||||
}
|
||||
|
||||
// §5.8: the token this account issues to one correspondent, independent of
|
||||
// the rotation index so it survives the owner's key rotation.
|
||||
export function tokenFor(master, correspondentIdentity) {
|
||||
return hmacSha256(acceptKeyFor(master), correspondentIdentity);
|
||||
}
|
||||
|
||||
// §5.8: what a sender attaches to SEND to reach the recipient's main tier.
|
||||
export function acceptMac(token, id) {
|
||||
return hmacSha256(token, concat(LABEL.mac, id));
|
||||
}
|
||||
|
||||
// --- addressing (§3) --------------------------------------------------------
|
||||
|
|
@ -132,8 +157,10 @@ export function parseAddress(text) {
|
|||
|
||||
// --- message format (§5) ----------------------------------------------------
|
||||
|
||||
// §5.4: derived from the envelope so no sender can choose it; used whole,
|
||||
// nothing truncates it.
|
||||
export function messageId(envelope) {
|
||||
return sha256(concat(LABEL.id, envelope)).slice(0, ID_LEN);
|
||||
return sha256(concat(LABEL.id, envelope));
|
||||
}
|
||||
|
||||
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
|
||||
|
|
@ -174,6 +201,7 @@ export function unseal(identities, envelope) {
|
|||
const body = r.take(bodyLen), signature = r.take(SIG_LEN); // trailing bytes are padding
|
||||
if (!ed25519Verify(sender, concat(LABEL.msg, to, epk, plaintext.slice(0, PAYLOAD_HEADER), body), signature))
|
||||
throw new SmolError("signature does not verify");
|
||||
if (when > BigInt(nowSeconds() + MAX_SKEW)) throw new SmolError("payload is dated in the future");
|
||||
return { sender, time: Number(when), body, id: messageId(envelope) };
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +211,8 @@ const FM_KEY = /^[A-Za-z0-9-]{1,64}$/;
|
|||
|
||||
// A flat `Key: value` block, deliberately not YAML. Any malformed line
|
||||
// invalidates the whole block, which is then returned as ordinary body text:
|
||||
// frontmatter fails closed toward display, never toward silent discard.
|
||||
// frontmatter fails closed toward display, never toward silent discard. Keys
|
||||
// are compared case-insensitively (§5.5), so they are returned lowercased.
|
||||
export function parseFrontmatter(text) {
|
||||
if (!text.startsWith("---\n")) return { fields: {}, body: text };
|
||||
const lines = text.split("\n");
|
||||
|
|
@ -197,7 +226,7 @@ export function parseFrontmatter(text) {
|
|||
for (let i = 0; i < block.length; i++) {
|
||||
const line = block[i], colon = line.indexOf(":");
|
||||
if (colon < 0 || !FM_KEY.test(line.slice(0, colon))) return { fields: {}, body: text };
|
||||
const key = line.slice(0, colon);
|
||||
const key = line.slice(0, colon).toLowerCase();
|
||||
if (!(key in fields)) fields[key] = line.slice(colon + 1).trim(); // first occurrence wins
|
||||
}
|
||||
return { fields, body: rest };
|
||||
|
|
@ -212,28 +241,35 @@ export function buildFrontmatter(fields, body) {
|
|||
|
||||
// --- key rotation (§7) -------------------------------------------------------
|
||||
|
||||
export function makeCert(oldIdentity, newSeed, when = nowSeconds()) {
|
||||
// old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys
|
||||
// sign, so the old key alone cannot hand the username to a key nobody
|
||||
// controls; the username is covered but not carried, so a verifier always
|
||||
// supplies the one it is checking.
|
||||
export function makeCert(username, oldIdentity, newSeed, when = nowSeconds()) {
|
||||
const newPub = ed25519PublicKey(newSeed), time = i64BE(when);
|
||||
const signed = concat(LABEL.rotate, utf8Bytes(username), oldIdentity.publicKey, newPub, time);
|
||||
return concat(oldIdentity.publicKey, newPub, time,
|
||||
ed25519Sign(oldIdentity.seed, concat(LABEL.rotate, oldIdentity.publicKey, newPub, time)));
|
||||
ed25519Sign(oldIdentity.seed, signed), ed25519Sign(newSeed, signed));
|
||||
}
|
||||
|
||||
// Accept a key change only when a signed chain leads from the key we hold to
|
||||
// the one the server now returns (§7).
|
||||
export function walkChain(pinned, current, chain) {
|
||||
// the one the server now returns, both keys signing each link (§7).
|
||||
export function walkChain(username, pinned, current, chain) {
|
||||
const same = (a, b) => timingSafeEqual(a, b);
|
||||
if (same(pinned, current)) return true;
|
||||
if (!chain.length || chain.length > MAX_CHAIN) return false;
|
||||
let key = pinned, started = false;
|
||||
for (const cert of chain) {
|
||||
const old = cert.slice(0, 32), next = cert.slice(32, 64), when = cert.slice(64, 72), sig = cert.slice(72);
|
||||
const old = cert.slice(0, 32), next = cert.slice(32, 64), when = cert.slice(64, 72);
|
||||
const sigOld = cert.slice(72, 136), sigNew = cert.slice(136, 200);
|
||||
if (!started) {
|
||||
if (!same(old, key)) continue; // a link predating the key we hold
|
||||
started = true;
|
||||
} else if (!same(old, key)) {
|
||||
return false; // the chain is not continuous
|
||||
}
|
||||
if (!ed25519Verify(old, concat(LABEL.rotate, old, next, when), sig)) return false;
|
||||
const signed = concat(LABEL.rotate, utf8Bytes(username), old, next, when);
|
||||
if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) return false;
|
||||
key = next;
|
||||
}
|
||||
return started && same(key, current);
|
||||
|
|
@ -356,13 +392,20 @@ export function expectOk(status, what) {
|
|||
}
|
||||
|
||||
// §4 session authentication: sign the handshake hash, which binds the
|
||||
// signature to this session's server ephemeral and cannot be replayed.
|
||||
export async function authenticate(session, handshakeHash, username, identity) {
|
||||
// signature to this session's server ephemeral and cannot be replayed, and
|
||||
// push the accept token set (§5.8). `sync = 0` leaves the server's stored set
|
||||
// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly.
|
||||
// Returns the number of accept tokens the server now holds.
|
||||
export async function authenticate(session, handshakeHash, username, identity, { sync = 0, tokens = [] } = {}) {
|
||||
const name = utf8Bytes(username);
|
||||
if (name.length > 255) throw new SmolError("username too long");
|
||||
if (tokens.length > 0xffff) throw new SmolError("too many accept tokens for one AUTH");
|
||||
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
|
||||
ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)));
|
||||
expectOk((await session.call(OP.AUTH, body)).status, "authentication");
|
||||
ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)),
|
||||
Uint8Array.of(sync), u16BE(tokens.length), ...tokens);
|
||||
const { status, body: reply } = await session.call(OP.AUTH, body);
|
||||
expectOk(status, "authentication");
|
||||
return reply.length >= 2 ? new Reader(reply).u16() : 0;
|
||||
}
|
||||
|
||||
// RESOLVE, returning the current key and its rotation chain (§6.1).
|
||||
|
|
@ -375,19 +418,26 @@ export async function resolveOp(session, user) {
|
|||
return { identity: r.take(KEY_LEN), chain: Array.from({ length: r.u8() }, () => r.take(CERT_LEN)) };
|
||||
}
|
||||
|
||||
export async function sendOp(session, envelope) {
|
||||
const { status, body } = await session.call(OP.SEND, envelope);
|
||||
// §5.8: `mac` is the sender's proof of an accept token, absent or TOKEN_LEN bytes.
|
||||
export async function sendOp(session, envelope, mac = null) {
|
||||
const macBytes = mac ?? new Uint8Array(0);
|
||||
if (macBytes.length && macBytes.length !== TOKEN_LEN)
|
||||
throw new SmolError(`accept MAC must be ${TOKEN_LEN} bytes`);
|
||||
const body = concat(Uint8Array.of(macBytes.length), macBytes, envelope);
|
||||
const { status, body: reply } = await session.call(OP.SEND, body);
|
||||
expectOk(status, "sending");
|
||||
return body.length === ID_LEN ? body : messageId(envelope);
|
||||
return reply.length === ID_LEN ? reply : messageId(envelope);
|
||||
}
|
||||
|
||||
export async function fetchOp(session) {
|
||||
const { status, body } = await session.call(OP.FETCH);
|
||||
// §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
|
||||
export async function fetchOp(session, afterReceivedAt = 0, afterId = new Uint8Array(ID_LEN)) {
|
||||
const body = concat(i64BE(afterReceivedAt), afterId);
|
||||
const { status, body: reply } = await session.call(OP.FETCH, body);
|
||||
expectOk(status, "fetching");
|
||||
const r = new Reader(body);
|
||||
const r = new Reader(reply);
|
||||
return Array.from({ length: r.u16() }, () => {
|
||||
const id = r.take(ID_LEN), receivedAt = Number(r.i64());
|
||||
return { id, receivedAt, envelope: r.take(r.u32()) };
|
||||
const id = r.take(ID_LEN), receivedAt = Number(r.i64()), flags = r.u8();
|
||||
return { id, receivedAt, flags, envelope: r.take(r.u32()), isRequest: Boolean(flags & FLAG_REQUESTS) };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -399,12 +449,19 @@ export async function deleteOp(session, ids) {
|
|||
return new Reader(reply).u16();
|
||||
}
|
||||
|
||||
export async function registerOp(session, { username, identity, token = "", cert = null }) {
|
||||
// §6.1: the signature is proof of possession, bound to the server that will
|
||||
// store the binding so it cannot be replayed to another server.
|
||||
export function registerSigned(serverStatic, username, identity) {
|
||||
return concat(LABEL.register, serverStatic, utf8Bytes(username), identity);
|
||||
}
|
||||
|
||||
export async function registerOp(session, serverStatic, { username, identity, token = "", cert = null }) {
|
||||
const name = utf8Bytes(username);
|
||||
const tokenBytes = utf8Bytes(token);
|
||||
if (name.length > 255 || tokenBytes.length > 255 || cert && cert.length > 255)
|
||||
throw new SmolError("REGISTER field too long");
|
||||
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
|
||||
ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)),
|
||||
Uint8Array.of(tokenBytes.length), tokenBytes,
|
||||
Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0));
|
||||
const { status } = await session.call(OP.REGISTER, body);
|
||||
|
|
|
|||
266
web/js/store.js
266
web/js/store.js
|
|
@ -1,12 +1,14 @@
|
|||
// Browser state: identity, pins, contacts and read markers in localStorage;
|
||||
// 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 master secret excepted — it is the user's browser profile).
|
||||
|
||||
import { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js";
|
||||
import { b32decode, b32encode, identityFromSeed } from "./proto.js";
|
||||
import { ID_LEN, MAX_CHAIN, b32decode, b32encode, identityFromSeed, identitySeed, tokenFor } from "./proto.js";
|
||||
|
||||
const KEY = "gsmol";
|
||||
|
||||
export const TIER_MAIN = 0, TIER_REQUESTS = 1;
|
||||
|
||||
// Every getter reads the whole blob and some are called per message row, so the
|
||||
// parse is cached. `derived` holds the identities that follow from it, which
|
||||
// cost an Ed25519 scalar multiplication each.
|
||||
|
|
@ -39,44 +41,82 @@ function update(fn) {
|
|||
save(next ?? state);
|
||||
}
|
||||
|
||||
// --- identity ----------------------------------------------------------------
|
||||
// --- identity (§2) -------------------------------------------------------------
|
||||
|
||||
export function seed() {
|
||||
const raw = load().seed;
|
||||
export function master() {
|
||||
const raw = load().master;
|
||||
return raw ? unhex(raw) : null;
|
||||
}
|
||||
|
||||
// The rotation index (§7) of the identity currently in use.
|
||||
export function rotations() {
|
||||
return load().rotations ?? 0;
|
||||
}
|
||||
|
||||
export function identity() {
|
||||
return identities()[0] ?? null;
|
||||
}
|
||||
|
||||
export function setIdentity(newSeed) {
|
||||
if (seed()) throw new Error("an identity already exists; rotate it instead");
|
||||
update(state => { state.seed = hex(newSeed); });
|
||||
}
|
||||
|
||||
// Rotation (§7): the old seed is retained, since mail sealed to a superseded
|
||||
// key is readable with nothing else.
|
||||
export function rotateIdentity(newSeed) {
|
||||
const old = seed();
|
||||
if (!old) throw new Error("no identity to rotate");
|
||||
update(state => {
|
||||
state.retired = [...(state.retired || []), { seed: hex(old), at: Date.now() }];
|
||||
state.seed = hex(newSeed);
|
||||
});
|
||||
}
|
||||
|
||||
// §7: seeds rotated away from are retained, since mail sealed to a superseded
|
||||
// key is readable with nothing else.
|
||||
// §7: every key rotated away from is re-derivable from the master, since mail
|
||||
// sealed to a superseded key is readable with nothing else.
|
||||
export function identities() {
|
||||
if (derived) return derived;
|
||||
const raw = seed();
|
||||
if (!raw) return [];
|
||||
const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s)));
|
||||
derived = [identityFromSeed(raw), ...retired];
|
||||
const m = master();
|
||||
if (!m) return [];
|
||||
derived = [];
|
||||
for (let n = rotations(); n >= 0; n--) derived.push(identityFromSeed(identitySeed(m, n)));
|
||||
return derived;
|
||||
}
|
||||
|
||||
function bindMaster(newMaster, rotationIndex, syncOk) {
|
||||
if (master()) throw new Error("an identity already exists; rotate it instead");
|
||||
update(state => {
|
||||
state.master = hex(newMaster);
|
||||
state.rotations = rotationIndex;
|
||||
state.syncOk = syncOk;
|
||||
});
|
||||
setCursor(0, new Uint8Array(ID_LEN));
|
||||
}
|
||||
|
||||
// A fresh identity: rotation index 0, and an empty accepted set is already
|
||||
// complete, so it may sync.
|
||||
export function setIdentity(newMaster) {
|
||||
bindMaster(newMaster, 0, true);
|
||||
}
|
||||
|
||||
// §2: recovering a master alone does not recover which correspondents were
|
||||
// accepted, so that set must not overwrite the server's until rebuilt.
|
||||
export function restoreMaster(newMaster, rotationIndex = 0) {
|
||||
bindMaster(newMaster, rotationIndex, false);
|
||||
}
|
||||
|
||||
// Corrects the rotation index once a RESOLVE against the account's address
|
||||
// reveals which one the server actually has bound (see restoreMaster()'s
|
||||
// default of 0, the common case of a never-rotated identity).
|
||||
export function setRotationIndex(n) {
|
||||
update(state => { state.rotations = n; });
|
||||
}
|
||||
|
||||
// Whether the accepted-correspondent set held here may replace the server's
|
||||
// on the next AUTH — false right after a restore from the master alone, whose
|
||||
// empty set must not erase the server's (§4).
|
||||
export function syncOk() {
|
||||
return load().syncOk ?? true;
|
||||
}
|
||||
|
||||
export function setSyncOk(ok) {
|
||||
update(state => { state.syncOk = ok; });
|
||||
}
|
||||
|
||||
// Rotation (§7): only the index advances; the superseded key stays derivable
|
||||
// from the master, so nothing has to be archived.
|
||||
export function advanceRotation() {
|
||||
const current = rotations();
|
||||
if (!master()) throw new Error("no identity to rotate");
|
||||
if (current >= MAX_CHAIN) throw new Error(`the rotation chain is full at ${MAX_CHAIN} links`);
|
||||
update(state => { state.rotations = current + 1; });
|
||||
}
|
||||
|
||||
// --- account and server pins ---------------------------------------------------
|
||||
|
||||
export function account() {
|
||||
|
|
@ -111,6 +151,32 @@ export function allPins() {
|
|||
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
|
||||
}
|
||||
|
||||
// --- FETCH behavior and cursor (§6.1) -------------------------------------------
|
||||
|
||||
// When true, fetch does not acknowledge (delete) what it retrieves — mail
|
||||
// stays on the server until explicitly deleted. Defaults to the original
|
||||
// behavior: fetched mail is acknowledged immediately.
|
||||
export function leaveOnServer() {
|
||||
return load().leaveOnServer ?? false;
|
||||
}
|
||||
|
||||
export function setLeaveOnServer(value) {
|
||||
update(state => { state.leaveOnServer = value; });
|
||||
}
|
||||
|
||||
export function cursor() {
|
||||
const state = load();
|
||||
const afterId = state.afterId;
|
||||
return [state.afterTime ?? 0, afterId ? unhex(afterId) : new Uint8Array(ID_LEN)];
|
||||
}
|
||||
|
||||
export function setCursor(afterTime, afterId) {
|
||||
update(state => {
|
||||
state.afterTime = afterTime;
|
||||
state.afterId = hex(afterId);
|
||||
});
|
||||
}
|
||||
|
||||
// --- contacts ------------------------------------------------------------------
|
||||
|
||||
export function contact(address) {
|
||||
|
|
@ -144,6 +210,70 @@ export function allContacts() {
|
|||
.map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] }));
|
||||
}
|
||||
|
||||
// --- accept tokens (§5.8) --------------------------------------------------------
|
||||
|
||||
export function accepted(address) {
|
||||
const a = (load().accepted || {})[address];
|
||||
return a ? { identity: b32decode(a.identity), active: a.active } : null;
|
||||
}
|
||||
|
||||
// Admit a contact to the main tier. The identity is frozen at acceptance — a
|
||||
// re-accept after a block must not change which key the token is derived
|
||||
// from (§5.8).
|
||||
export function accept(address, identityBytes) {
|
||||
update(state => {
|
||||
const table = { ...(state.accepted || {}) };
|
||||
const previous = table[address];
|
||||
table[address] = {
|
||||
identity: previous?.identity ?? b32encode(identityBytes),
|
||||
active: true,
|
||||
addedAt: previous?.addedAt ?? Date.now(),
|
||||
};
|
||||
state.accepted = table;
|
||||
});
|
||||
}
|
||||
|
||||
// Withdraw a contact's accept token; their mail lands in the requests tier
|
||||
// from their next message on. Throws if the contact was never accepted.
|
||||
export function block(address) {
|
||||
if (!(load().accepted || {})[address]) throw new Error(`${address} was never accepted`);
|
||||
update(state => {
|
||||
const table = { ...(state.accepted || {}) };
|
||||
table[address] = { ...table[address], active: false };
|
||||
state.accepted = table;
|
||||
});
|
||||
}
|
||||
|
||||
export function allAccepted() {
|
||||
return Object.entries(load().accepted || {})
|
||||
.map(([address, a]) => ({ address, identity: b32decode(a.identity), active: a.active }));
|
||||
}
|
||||
|
||||
// §4: the tokens to push with AUTH, and whether to push at all. A client that
|
||||
// cannot vouch for its own set — one restored from the master alone — must
|
||||
// not replace the server's with an incomplete one.
|
||||
export function tokenSet(masterBytes) {
|
||||
if (!syncOk()) return { sync: 0, tokens: [] };
|
||||
const active = allAccepted().filter(a => a.active).sort((a, b) => a.address.localeCompare(b.address));
|
||||
return { sync: 1, tokens: active.map(a => tokenFor(masterBytes, a.identity)) };
|
||||
}
|
||||
|
||||
// A token received from a correspondent, filed under the address that issued
|
||||
// it: an address outlives the keys behind it, so the token keeps working
|
||||
// across the issuer's rotations (§5.8).
|
||||
export function tokenFrom(address) {
|
||||
const raw = (load().tokens || {})[address];
|
||||
return raw ? b32decode(raw.token) : null;
|
||||
}
|
||||
|
||||
export function learnToken(address, tokenBytes) {
|
||||
update(state => {
|
||||
const tokens = { ...(state.tokens || {}) };
|
||||
tokens[address] = { token: b32encode(tokenBytes), seenAt: Date.now() };
|
||||
state.tokens = tokens;
|
||||
});
|
||||
}
|
||||
|
||||
// --- read markers ---------------------------------------------------------------
|
||||
|
||||
export function markRead(idHex) {
|
||||
|
|
@ -201,6 +331,11 @@ function tx(db, box, mode, fn) {
|
|||
});
|
||||
}
|
||||
|
||||
// "requests" is a view over the same physical "inbox" records, filtered by
|
||||
// tier (§5.8) — not a separate folder, so a message keeps one identity
|
||||
// regardless of which tier it arrived in.
|
||||
const physicalFolder = (folder) => folder === "requests" ? "inbox" : folder;
|
||||
|
||||
export async function storeMessage(box, record) {
|
||||
const db = await openDb();
|
||||
await tx(db, box, "readwrite", store => store.put(record));
|
||||
|
|
@ -215,27 +350,37 @@ export async function storeIfNew(box, record) {
|
|||
return record;
|
||||
}
|
||||
|
||||
export async function listMessages(box) {
|
||||
export async function listMessages(folder) {
|
||||
const physical = physicalFolder(folder);
|
||||
const db = await openDb();
|
||||
const rows = await tx(db, box, "readonly", store => store.getAll());
|
||||
return rows.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
|
||||
const rows = await tx(db, physical, "readonly", store => store.getAll());
|
||||
const wantTier = folder === "requests" ? TIER_REQUESTS : TIER_MAIN;
|
||||
const filtered = physical === "inbox" ? rows.filter(r => (r.tier ?? TIER_MAIN) === wantTier) : rows;
|
||||
return filtered.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
|
||||
}
|
||||
|
||||
export async function getMessage(box, id) {
|
||||
export async function getMessage(folder, id) {
|
||||
const db = await openDb();
|
||||
return await tx(db, box, "readonly", store => store.get(id)) ?? null;
|
||||
return await tx(db, physicalFolder(folder), "readonly", store => store.get(id)) ?? null;
|
||||
}
|
||||
|
||||
export async function removeMessage(folder, id) {
|
||||
const db = await openDb();
|
||||
await tx(db, physicalFolder(folder), "readwrite", store => store.delete(id));
|
||||
}
|
||||
|
||||
// The unread badge must survive leaving the inbox, so it is counted from ids
|
||||
// and read markers alone — no envelope is opened.
|
||||
export async function unreadCount() {
|
||||
const db = await openDb();
|
||||
const ids = await tx(db, "inbox", "readonly", store => store.getAllKeys());
|
||||
async function unreadIn(folder) {
|
||||
const rows = await listMessages(folder);
|
||||
const read = load().read || {};
|
||||
return ids.filter(id => !read[id]).length;
|
||||
return rows.filter(row => !read[row.id]).length;
|
||||
}
|
||||
|
||||
// --- export / import: mail, contacts, pins — never the seed ---------------------
|
||||
export const unreadCount = () => unreadIn("inbox");
|
||||
export const requestsUnreadCount = () => unreadIn("requests");
|
||||
|
||||
// --- export / import: mail, contacts, pins — never the master --------------------
|
||||
|
||||
// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per
|
||||
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
|
||||
|
|
@ -246,38 +391,42 @@ 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
|
||||
// Sealing to a key derived from the identity's own master 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
|
||||
// opened where that master 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);
|
||||
const exportKey = (masterBytes) => hkdfSha256(masterBytes, 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. 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.
|
||||
// Deliberately excludes the master: it already has its own reveal-and-copy
|
||||
// flow 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")]);
|
||||
const m = master();
|
||||
if (!m) throw new Error("no identity yet");
|
||||
const [inbox, requests, sent] = await Promise.all(
|
||||
[listMessages("inbox"), listMessages("requests"), listMessages("sent")]);
|
||||
const payload = {
|
||||
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
||||
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
||||
[address, { key, verified, history }])),
|
||||
inbox: inbox.map(row => ({ id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope) })),
|
||||
inbox: [...inbox, ...requests].map(row => ({
|
||||
id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope),
|
||||
tier: row.tier ?? TIER_MAIN, keptOnServer: row.keptOnServer ?? false,
|
||||
})),
|
||||
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
|
||||
// master), 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));
|
||||
const ciphertext = aeadEncrypt(exportKey(m), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
|
||||
return {
|
||||
gsmolExport: 2,
|
||||
exportedAt: Date.now(),
|
||||
|
|
@ -300,11 +449,11 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
|
|||
export async function importData(data) {
|
||||
let payload;
|
||||
if (data?.gsmolExport === 2) {
|
||||
const me = identity();
|
||||
if (!me) throw new Error("no identity yet — restore it before importing");
|
||||
const m = master();
|
||||
if (!m) 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));
|
||||
exportKey(m), 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");
|
||||
|
|
@ -352,7 +501,8 @@ export async function importData(data) {
|
|||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
try {
|
||||
const record = box === "inbox"
|
||||
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope) }
|
||||
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope),
|
||||
tier: row.tier ?? TIER_MAIN, keptOnServer: Boolean(row.keptOnServer) }
|
||||
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
|
||||
if (await storeIfNew(box, record)) summary.mailAdded++;
|
||||
} catch { summary.malformed++; }
|
||||
|
|
@ -362,8 +512,8 @@ export async function importData(data) {
|
|||
return summary;
|
||||
}
|
||||
|
||||
// Logout: erases the seed, pins, contacts and every cached message from this
|
||||
// browser. Closes the held connection first so the delete isn't left
|
||||
// Logout: erases the master, pins, contacts and every cached message from
|
||||
// this browser. Closes the held connection first so the delete isn't left
|
||||
// "blocked" waiting for a handle that never closes on its own.
|
||||
export async function clearAll() {
|
||||
localStorage.removeItem(KEY);
|
||||
|
|
|
|||
|
|
@ -356,6 +356,14 @@ dialog::backdrop { background: var(--backdrop); }
|
|||
#compose textarea { font-family: inherit; resize: vertical; }
|
||||
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
|
||||
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
|
||||
/* A checkbox with a multi-line label, e.g. settings' "leave mail on server" —
|
||||
the checkbox sits at the label's cap height rather than centered against
|
||||
the full wrapped block, which is what made it look adrift. */
|
||||
#settings label.check-row { flex-direction: row; align-items: flex-start; gap: 10px; flex: none; }
|
||||
#settings label.check-row input { flex: none; margin: 3px 0 0; }
|
||||
.check-row .check-title { display: block; color: var(--fg); font-size: 14px; }
|
||||
.check-row .hint { display: block; margin: 2px 0 0; }
|
||||
/* Without this every action read exactly like "cancel" and "close". */
|
||||
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
|
||||
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue