feat: browser client for smol mail
This commit is contained in:
commit
9798d38040
19 changed files with 3331 additions and 0 deletions
492
web/js/app.js
Normal file
492
web/js/app.js
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
// 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 {
|
||||
DEFAULT_PORT, SmolError, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp,
|
||||
fetchOp, fingerprint, makeCert, messageId, newIdentity, openSession, parseAddress,
|
||||
parseFrontmatter, registerOp, resolveOp, seal, sendOp, unseal, walkChain,
|
||||
} from "./proto.js";
|
||||
import * as store from "./store.js";
|
||||
import { connectStream } from "./transport.js";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function status(text, warn = false) {
|
||||
$("status").textContent = text;
|
||||
$("status").className = warn ? "status warn" : "status";
|
||||
}
|
||||
|
||||
const fmtTime = (seconds) =>
|
||||
new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
// The stored account as an address object, default port included when custom.
|
||||
function accountAddress() {
|
||||
const account = store.account();
|
||||
if (!account) return null;
|
||||
const suffix = account.port === DEFAULT_PORT ? "" : `:${account.port}`;
|
||||
return parseAddress(`${account.user}@${account.host}${suffix}`);
|
||||
}
|
||||
|
||||
// --- connection --------------------------------------------------------------
|
||||
|
||||
// §4: registration and fetching demand a pinned key; sending to a recipient
|
||||
// whose key we already hold tolerates an unpinned server.
|
||||
async function connect(addr, { requirePin }) {
|
||||
const pinned = store.serverPin(addr.host);
|
||||
if (requirePin && !pinned)
|
||||
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
|
||||
`trusted channel, then pin it in settings.`);
|
||||
const stream = await connectStream(addr.host, addr.port);
|
||||
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);
|
||||
return opened;
|
||||
}
|
||||
|
||||
// --- identity setup ------------------------------------------------------------
|
||||
|
||||
function showSetup() {
|
||||
$("setup").hidden = false;
|
||||
$("app").hidden = true;
|
||||
$("account").textContent = "";
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
$("setup").hidden = true;
|
||||
$("app").hidden = false;
|
||||
renderAccount();
|
||||
renderFolder();
|
||||
}
|
||||
|
||||
$("create-identity").onclick = () => {
|
||||
store.setIdentity(newIdentity().seed);
|
||||
showApp();
|
||||
status("identity created. Back up the seed in settings now; it is the only secret.");
|
||||
openSettings();
|
||||
};
|
||||
|
||||
$("restore-identity").onclick = () => {
|
||||
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));
|
||||
try {
|
||||
store.setIdentity(seedBytes);
|
||||
} catch (err) {
|
||||
return status(err.message, true);
|
||||
}
|
||||
showApp();
|
||||
status("identity restored.");
|
||||
};
|
||||
|
||||
// --- header ---------------------------------------------------------------------
|
||||
|
||||
function renderAccount() {
|
||||
const me = store.identity();
|
||||
const account = store.account();
|
||||
$("account").textContent = me
|
||||
? `${account ? account.user + "@" + account.host : "(not registered)"} · ${fingerprint(me.publicKey)}`
|
||||
: "";
|
||||
}
|
||||
|
||||
// --- folders and list ---------------------------------------------------------
|
||||
|
||||
let folder = "inbox";
|
||||
let selectedId = null;
|
||||
|
||||
for (const button of $("folders").querySelectorAll("button"))
|
||||
button.onclick = () => {
|
||||
folder = button.dataset.folder;
|
||||
for (const b of $("folders").querySelectorAll("button")) b.classList.toggle("active", b === button);
|
||||
selectedId = null;
|
||||
renderFolder();
|
||||
};
|
||||
|
||||
async function renderFolder() {
|
||||
const list = $("list");
|
||||
if (folder === "contacts") return renderContacts(list);
|
||||
const rows = await store.listMessages(folder);
|
||||
list.replaceChildren();
|
||||
for (const row of rows) {
|
||||
const opened = describe(row);
|
||||
const item = document.createElement("div");
|
||||
item.className = "item" + (row.id === selectedId ? " selected" : "");
|
||||
const who = document.createElement("div");
|
||||
who.className = "who" + (store.isRead(row.id) ? "" : " unread");
|
||||
who.textContent = folder === "sent" ? row.recipient : (opened.from ?? "?");
|
||||
const subject = document.createElement("div");
|
||||
subject.className = "subject";
|
||||
subject.textContent = opened.error ? `<unreadable: ${opened.error}>`
|
||||
: (opened.subject || "(no subject)");
|
||||
const when = document.createElement("span");
|
||||
when.className = "when";
|
||||
when.textContent = fmtTime(folder === "sent" ? row.sentAt : opened.time ?? row.receivedAt);
|
||||
item.append(who, subject, when);
|
||||
item.onclick = () => openMessage(row.id);
|
||||
list.append(item);
|
||||
}
|
||||
if (!rows.length) list.append(Object.assign(document.createElement("p"),
|
||||
{ className: "placeholder", textContent: folder === "inbox" ? "no mail — fetch" : "nothing sent" }));
|
||||
}
|
||||
|
||||
// Open an envelope and split its body; anything unreadable shows as such
|
||||
// rather than throwing, so one bad message cannot blank the list.
|
||||
function describe(row) {
|
||||
try {
|
||||
const opened = unseal(store.identities(), row.envelope);
|
||||
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
|
||||
return {
|
||||
...opened,
|
||||
fields, body,
|
||||
subject: fields.Subject || "",
|
||||
from: store.addressForKey(opened.sender) ?? `<${b32encode(opened.sender).slice(0, 16)}…>`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- reading ------------------------------------------------------------------
|
||||
|
||||
async function openMessage(id) {
|
||||
const row = await store.getMessage(folder, id);
|
||||
if (!row) return;
|
||||
selectedId = id;
|
||||
store.markRead(id);
|
||||
renderFolder();
|
||||
const opened = describe(row);
|
||||
const view = $("view");
|
||||
if (opened.error) {
|
||||
view.innerHTML = `<p class="placeholder warn">${opened.error}</p>`;
|
||||
return;
|
||||
}
|
||||
const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject");
|
||||
view.innerHTML = `<h2></h2><div class="meta"></div><div class="fields"></div>` +
|
||||
`<div class="verified">signature verified</div><pre></pre>`;
|
||||
view.querySelector("h2").textContent = opened.subject || "(no subject)";
|
||||
view.querySelector(".meta").textContent = folder === "sent"
|
||||
? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
|
||||
: `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`;
|
||||
view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||
view.querySelector("pre").textContent = opened.body;
|
||||
if (folder === "inbox") {
|
||||
const reply = document.createElement("button");
|
||||
reply.textContent = "reply";
|
||||
reply.onclick = () => openCompose({
|
||||
to: store.addressForKey(opened.sender) ?? "",
|
||||
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
|
||||
replyTo: row.id,
|
||||
});
|
||||
view.append(reply);
|
||||
}
|
||||
}
|
||||
|
||||
// --- compose and send -----------------------------------------------------------
|
||||
|
||||
const compose = $("compose");
|
||||
|
||||
function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) {
|
||||
const form = compose.querySelector("form");
|
||||
form.to.value = to;
|
||||
form.subject.value = subject;
|
||||
form.body.value = body;
|
||||
form.dataset.replyTo = replyTo ?? "";
|
||||
$("compose-error").textContent = "";
|
||||
compose.showModal();
|
||||
}
|
||||
|
||||
$("write").onclick = () => openCompose();
|
||||
|
||||
compose.querySelector("form").onsubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (event.submitter?.value !== "send") {
|
||||
compose.close();
|
||||
return;
|
||||
}
|
||||
const form = event.target;
|
||||
$("compose-error").textContent = "";
|
||||
try {
|
||||
await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo);
|
||||
compose.close();
|
||||
} catch (err) {
|
||||
$("compose-error").textContent = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
async function sendMail(toText, subject, body, replyTo) {
|
||||
const me = store.identity();
|
||||
if (!me) throw new SmolError("no identity yet");
|
||||
const addr = parseAddress(toText);
|
||||
const recipient = await resolveRecipient(addr);
|
||||
const fields = [["Subject", subject], ["In-Reply-To", replyTo]].filter(([, v]) => v);
|
||||
const bodyBytes = utf8Bytes(buildFrontmatter(fields, body.replace(/\s+$/, "") + "\n"));
|
||||
const envelope = seal(me, recipient, bodyBytes);
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
try {
|
||||
await sendOp(opened.session, envelope);
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
// §5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
|
||||
await store.storeMessage("sent", {
|
||||
id: hex(messageId(envelope)),
|
||||
recipient: addr.short,
|
||||
envelope: seal(me, me.publicKey, bodyBytes),
|
||||
sentAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
status(`sent to ${addr.short}`);
|
||||
if (folder === "sent") renderFolder();
|
||||
}
|
||||
|
||||
// Prefer a key we already trust; fall back to RESOLVE with trust on first use.
|
||||
async function resolveRecipient(addr) {
|
||||
if (addr.identity) {
|
||||
store.saveContact(addr.short, addr.identity, true);
|
||||
return addr.identity;
|
||||
}
|
||||
const known = store.contact(addr.short);
|
||||
if (known) return known.key;
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
let current;
|
||||
try {
|
||||
({ identity: current } = await resolveOp(opened.session, addr.user));
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
store.saveContact(addr.short, current, false);
|
||||
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
||||
return current;
|
||||
}
|
||||
|
||||
// --- fetch ----------------------------------------------------------------------
|
||||
|
||||
$("fetch").onclick = async () => {
|
||||
const me = store.identity();
|
||||
const addr = accountAddress();
|
||||
if (!me) return status("no identity yet", true);
|
||||
if (!addr) return status("not registered; register in settings first", true);
|
||||
status("fetching…");
|
||||
let stored = 0, rejected = 0;
|
||||
try {
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
try {
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||
for (;;) {
|
||||
const records = await fetchOp(opened.session);
|
||||
if (!records.length) break;
|
||||
const acked = [];
|
||||
for (const record of records) {
|
||||
try {
|
||||
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
||||
throw new SmolError("id does not match the envelope");
|
||||
unseal(store.identities(), record.envelope);
|
||||
} catch (err) {
|
||||
// Left on the server rather than destroyed, so a client-side bug
|
||||
// cannot lose mail.
|
||||
rejected++;
|
||||
status(`${hex(record.id)}: ${err.message}; left on server`, true);
|
||||
continue;
|
||||
}
|
||||
if (await store.storeIfNew("inbox", {
|
||||
id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
|
||||
})) stored++;
|
||||
acked.push(record.id);
|
||||
}
|
||||
if (!acked.length) break;
|
||||
await deleteOp(opened.session, acked);
|
||||
}
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
status(`${stored} new, ${rejected} rejected` + (rejected ? " (left on the server)" : ""));
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
renderFolder();
|
||||
};
|
||||
|
||||
// --- contacts -------------------------------------------------------------------
|
||||
|
||||
function renderContacts(list) {
|
||||
list.replaceChildren();
|
||||
const contacts = store.allContacts();
|
||||
for (const { address, key, verified } of contacts) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "item";
|
||||
const who = document.createElement("div");
|
||||
who.className = "who";
|
||||
who.textContent = address;
|
||||
const fp = document.createElement("div");
|
||||
fp.className = "subject";
|
||||
fp.textContent = `${fingerprint(b32decode(key))} · ${verified ? "verified key" : "tofu"}`;
|
||||
const refresh = document.createElement("button");
|
||||
refresh.textContent = "refresh";
|
||||
refresh.onclick = () => refreshContact(address);
|
||||
item.append(who, fp, refresh);
|
||||
list.append(item);
|
||||
}
|
||||
if (!contacts.length) list.append(Object.assign(document.createElement("p"),
|
||||
{ className: "placeholder", textContent: "no contacts; import a smol:// address or write to someone" }));
|
||||
}
|
||||
|
||||
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
|
||||
// surfaced; anything else requires out-of-band verification.
|
||||
async function refreshContact(address) {
|
||||
try {
|
||||
const addr = parseAddress(address);
|
||||
if (addr.identity) throw new SmolError("that address already carries a key; use import instead");
|
||||
const known = store.contact(addr.short);
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
let current, chain;
|
||||
try {
|
||||
({ identity: current, chain } = await resolveOp(opened.session, addr.user));
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
if (!known) {
|
||||
store.saveContact(addr.short, current, false);
|
||||
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)) {
|
||||
store.saveContact(addr.short, current, known.verified);
|
||||
status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
|
||||
} else {
|
||||
status(`${addr.short} presents a different key with no valid rotation chain.\n` +
|
||||
`Verify out of band, then import the new smol:// address.`, true);
|
||||
}
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
renderFolder();
|
||||
}
|
||||
|
||||
// --- settings ---------------------------------------------------------------------
|
||||
|
||||
const settings = $("settings");
|
||||
|
||||
function openSettings() {
|
||||
renderSettings();
|
||||
settings.showModal();
|
||||
}
|
||||
|
||||
$("settings-open").onclick = openSettings;
|
||||
$("settings-close").onclick = () => settings.close();
|
||||
|
||||
function renderSettings() {
|
||||
const me = store.identity();
|
||||
const account = store.account();
|
||||
const card = $("identity-card");
|
||||
if (!me) { card.textContent = ""; return; }
|
||||
const share = account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)";
|
||||
card.innerHTML = `<div>fingerprint: ${fingerprint(me.publicKey)}</div>` +
|
||||
`<div>address: ${account ? account.user + "@" + account.host : "(not registered)"}</div>` +
|
||||
`<div>share: ${share}</div>` +
|
||||
`<div class="seed">${hex(me.seed)}</div>` +
|
||||
`<div class="warn">back this seed up; it is the only secret</div>` +
|
||||
`<div>retired keys: ${store.identities().length - 1} (kept to read old mail)</div>`;
|
||||
|
||||
const pins = $("pins");
|
||||
pins.replaceChildren();
|
||||
for (const { host, key } of store.allPins()) {
|
||||
const li = document.createElement("li");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = host;
|
||||
const value = document.createElement("span");
|
||||
value.className = "key";
|
||||
value.textContent = key;
|
||||
const remove = document.createElement("button");
|
||||
remove.textContent = "remove";
|
||||
remove.onclick = () => { store.unpinServer(host); renderSettings(); };
|
||||
li.append(name, value, remove);
|
||||
pins.append(li);
|
||||
}
|
||||
if (!store.allPins().length) pins.append(Object.assign(document.createElement("li"),
|
||||
{ className: "placeholder", textContent: "no pinned servers" }));
|
||||
}
|
||||
|
||||
$("pin-form").onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
try {
|
||||
const host = form.host.value.trim().toLowerCase();
|
||||
const key = b32decode(form.key.value.trim());
|
||||
if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`);
|
||||
store.pinServer(host, key);
|
||||
status(`pinned ${host}`);
|
||||
renderSettings();
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
$("import-form").onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
try {
|
||||
const addr = parseAddress(form.uri.value.trim());
|
||||
if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key");
|
||||
store.saveContact(addr.short, addr.identity, true);
|
||||
status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` +
|
||||
`fingerprint: ${fingerprint(addr.identity)}`);
|
||||
renderSettings();
|
||||
renderFolder();
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
$("register-form").onsubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
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() });
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
store.setAccount(addr);
|
||||
status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
|
||||
renderAccount();
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
$("rotate").onclick = async () => {
|
||||
const me = store.identity();
|
||||
const addr = accountAddress();
|
||||
if (!me || !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;
|
||||
try {
|
||||
const fresh = newIdentity();
|
||||
const cert = makeCert(me, fresh.seed);
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
try {
|
||||
await registerOp(opened.session, { username: addr.user, identity: fresh, cert });
|
||||
} finally {
|
||||
opened.session.stream.close();
|
||||
}
|
||||
store.rotateIdentity(fresh.seed);
|
||||
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
|
||||
renderAccount();
|
||||
renderSettings();
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
// --- boot --------------------------------------------------------------------------
|
||||
|
||||
store.identity() ? showApp() : showSetup();
|
||||
status(store.identity() ? "" : "create an identity to begin");
|
||||
437
web/js/crypto.js
Normal file
437
web/js/crypto.js
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
// Dependency-free primitives for Smol Mail (SPEC.md §1): SHA-256, SHA-512,
|
||||
// HMAC/HKDF-SHA256, ChaCha20-Poly1305, X25519, Ed25519, and the §2 key
|
||||
// conversions between the two curves. Pure JS so the same code runs in the
|
||||
// browser and in Node for testing against the reference implementation.
|
||||
|
||||
const utf8 = new TextEncoder();
|
||||
|
||||
export function concat(...parts) {
|
||||
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||
let off = 0;
|
||||
for (const p of parts) { out.set(p, off); off += p.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function utf8Bytes(text) { return utf8.encode(text); }
|
||||
|
||||
export function hex(bytes) {
|
||||
return [...bytes].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function unhex(text) {
|
||||
if (text.length % 2) throw new Error(`odd-length hex string: ${text}`);
|
||||
const out = new Uint8Array(text.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function leBytesToBigInt(bytes) {
|
||||
let n = 0n;
|
||||
for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function bigIntToLeBytes(value, length) {
|
||||
const out = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) { out[i] = Number(value & 0xffn); value >>= 8n; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function randomBytes(n) {
|
||||
const out = new Uint8Array(n);
|
||||
crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function timingSafeEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// --- SHA-256 ----------------------------------------------------------------
|
||||
|
||||
const K256 = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
|
||||
const rotl32 = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0;
|
||||
const rotr32 = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;
|
||||
|
||||
export function sha256(message) {
|
||||
const padded = new Uint8Array((((message.length + 9) + 63) >> 6 << 6));
|
||||
padded.set(message);
|
||||
padded[message.length] = 0x80;
|
||||
const bits = BigInt(message.length) * 8n;
|
||||
new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false);
|
||||
|
||||
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
||||
const w = new Uint32Array(64);
|
||||
const view = new DataView(padded.buffer);
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
let [a, b, c, d, e, f, g, hh] = h;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (hh + S1 + ch + K256[i] + w[i]) >>> 0;
|
||||
const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + maj) >>> 0;
|
||||
hh = g; g = f; f = e; e = (d + t1) >>> 0;
|
||||
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
const add = [a, b, c, d, e, f, g, hh];
|
||||
for (let i = 0; i < 8; i++) h[i] = (h[i] + add[i]) >>> 0;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 8; i++) new DataView(out.buffer).setUint32(i * 4, h[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- HMAC-SHA256 and HKDF (RFC 2104, RFC 5869) -------------------------------
|
||||
|
||||
export function hmacSha256(key, message) {
|
||||
let block = new Uint8Array(64).fill(0x36);
|
||||
for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i];
|
||||
const inner = sha256(concat(block, message));
|
||||
block = new Uint8Array(64).fill(0x5c);
|
||||
for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i];
|
||||
return sha256(concat(block, inner));
|
||||
}
|
||||
|
||||
export function hkdfSha256(ikm, salt, info, length = 32) {
|
||||
const prk = hmacSha256(salt, ikm);
|
||||
let out = new Uint8Array(0), block = new Uint8Array(0), counter = 1;
|
||||
while (out.length < length) {
|
||||
block = hmacSha256(prk, concat(block, info, Uint8Array.of(counter)));
|
||||
out = concat(out, block);
|
||||
counter++;
|
||||
}
|
||||
return out.slice(0, length);
|
||||
}
|
||||
|
||||
// --- SHA-512 (Ed25519 only) --------------------------------------------------
|
||||
|
||||
const M64 = (1n << 64n) - 1n;
|
||||
const K512 = [
|
||||
0x428a2f98d728ae22n, 0x7137449123ef65cdn, 0xb5c0fbcfec4d3b2fn, 0xe9b5dba58189dbbcn,
|
||||
0x3956c25bf348b538n, 0x59f111f1b605d019n, 0x923f82a4af194f9bn, 0xab1c5ed5da6d8118n,
|
||||
0xd807aa98a3030242n, 0x12835b0145706fben, 0x243185be4ee4b28cn, 0x550c7dc3d5ffb4e2n,
|
||||
0x72be5d74f27b896fn, 0x80deb1fe3b1696b1n, 0x9bdc06a725c71235n, 0xc19bf174cf692694n,
|
||||
0xe49b69c19ef14ad2n, 0xefbe4786384f25e3n, 0x0fc19dc68b8cd5b5n, 0x240ca1cc77ac9c65n,
|
||||
0x2de92c6f592b0275n, 0x4a7484aa6ea6e483n, 0x5cb0a9dcbd41fbd4n, 0x76f988da831153b5n,
|
||||
0x983e5152ee66dfabn, 0xa831c66d2db43210n, 0xb00327c898fb213fn, 0xbf597fc7beef0ee4n,
|
||||
0xc6e00bf33da88fc2n, 0xd5a79147930aa725n, 0x06ca6351e003826fn, 0x142929670a0e6e70n,
|
||||
0x27b70a8546d22ffcn, 0x2e1b21385c26c926n, 0x4d2c6dfc5ac42aedn, 0x53380d139d95b3dfn,
|
||||
0x650a73548baf63den, 0x766a0abb3c77b2a8n, 0x81c2c92e47edaee6n, 0x92722c851482353bn,
|
||||
0xa2bfe8a14cf10364n, 0xa81a664bbc423001n, 0xc24b8b70d0f89791n, 0xc76c51a30654be30n,
|
||||
0xd192e819d6ef5218n, 0xd69906245565a910n, 0xf40e35855771202an, 0x106aa07032bbd1b8n,
|
||||
0x19a4c116b8d2d0c8n, 0x1e376c085141ab53n, 0x2748774cdf8eeb99n, 0x34b0bcb5e19b48a8n,
|
||||
0x391c0cb3c5c95a63n, 0x4ed8aa4ae3418acbn, 0x5b9cca4f7763e373n, 0x682e6ff3d6b2b8a3n,
|
||||
0x748f82ee5defb2fcn, 0x78a5636f43172f60n, 0x84c87814a1f0ab72n, 0x8cc702081a6439ecn,
|
||||
0x90befffa23631e28n, 0xa4506cebde82bde9n, 0xbef9a3f7b2c67915n, 0xc67178f2e372532bn,
|
||||
0xca273eceea26619cn, 0xd186b8c721c0c207n, 0xeada7dd6cde0eb1en, 0xf57d4f7fee6ed178n,
|
||||
0x06f067aa72176fban, 0x0a637dc5a2c898a6n, 0x113f9804bef90daen, 0x1b710b35131c471bn,
|
||||
0x28db77f523047d84n, 0x32caab7b40c72493n, 0x3c9ebe0a15c9bebcn, 0x431d67c49c100d4cn,
|
||||
0x4cc5d4becb3e42b6n, 0x597f299cfc657e2an, 0x5fcb6fab3ad6faecn, 0x6c44198c4a475817n,
|
||||
];
|
||||
|
||||
export function sha512(message) {
|
||||
const padded = new Uint8Array((((message.length + 17) + 127) >> 7 << 7));
|
||||
padded.set(message);
|
||||
padded[message.length] = 0x80;
|
||||
const bits = BigInt(message.length) * 8n;
|
||||
new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false);
|
||||
|
||||
let h = [0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n,
|
||||
0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n];
|
||||
const rotr = (x, n) => ((x >> BigInt(n)) | (x << (64n - BigInt(n)))) & M64;
|
||||
const view = new DataView(padded.buffer);
|
||||
const w = new Array(80);
|
||||
for (let off = 0; off < padded.length; off += 128) {
|
||||
for (let i = 0; i < 16; i++) w[i] = view.getBigUint64(off + i * 8);
|
||||
for (let i = 16; i < 80; i++) {
|
||||
const s0 = rotr(w[i - 15], 1) ^ rotr(w[i - 15], 8) ^ (w[i - 15] >> 7n);
|
||||
const s1 = rotr(w[i - 2], 19) ^ rotr(w[i - 2], 61) ^ (w[i - 2] >> 6n);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & M64;
|
||||
}
|
||||
let [a, b, c, d, e, f, g, hh] = h;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const S1 = rotr(e, 14) ^ rotr(e, 18) ^ rotr(e, 41);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (hh + S1 + ch + K512[i] + w[i]) & M64;
|
||||
const S0 = rotr(a, 28) ^ rotr(a, 34) ^ rotr(a, 39);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + maj) & M64;
|
||||
hh = g; g = f; f = e; e = (d + t1) & M64;
|
||||
d = c; c = b; b = a; a = (t1 + t2) & M64;
|
||||
}
|
||||
const sum = [a, b, c, d, e, f, g, hh];
|
||||
h = h.map((v, i) => (v + sum[i]) & M64);
|
||||
}
|
||||
const out = new Uint8Array(64);
|
||||
for (let i = 0; i < 8; i++) new DataView(out.buffer).setBigUint64(i * 8, h[i], false);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- ChaCha20-Poly1305 AEAD (RFC 8439) --------------------------------------
|
||||
|
||||
function chachaBlock(key, counter, nonce) {
|
||||
const state = new Uint32Array(16);
|
||||
state.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
|
||||
const kview = new DataView(key.buffer);
|
||||
for (let i = 0; i < 8; i++) state[4 + i] = kview.getUint32(i * 4, true);
|
||||
state[12] = counter >>> 0;
|
||||
const nview = new DataView(nonce.buffer);
|
||||
for (let i = 0; i < 3; i++) state[13 + i] = nview.getUint32(i * 4, true);
|
||||
const x = Uint32Array.from(state);
|
||||
const qr = (a, b, c, d) => {
|
||||
x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 16);
|
||||
x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 12);
|
||||
x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 8);
|
||||
x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 7);
|
||||
};
|
||||
for (let i = 0; i < 10; i++) {
|
||||
qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15);
|
||||
qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14);
|
||||
}
|
||||
const out = new Uint8Array(64);
|
||||
const view = new DataView(out.buffer);
|
||||
for (let i = 0; i < 16; i++) view.setUint32(i * 4, (x[i] + state[i]) >>> 0, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function chacha20Xor(key, counter, nonce, data) {
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let off = 0; off < data.length; off += 64) {
|
||||
const stream = chachaBlock(key, counter + (off / 64), nonce);
|
||||
const n = Math.min(64, data.length - off);
|
||||
for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ stream[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Poly1305 over BigInt; correctness over speed, messages here stay small.
|
||||
function poly1305(key, message) {
|
||||
const P1305 = (1n << 130n) - 5n;
|
||||
const r = leBytesToBigInt(key.slice(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn;
|
||||
const s = leBytesToBigInt(key.slice(16, 32));
|
||||
let acc = 0n;
|
||||
for (let off = 0; off < message.length; off += 16) {
|
||||
const block = message.slice(off, Math.min(off + 16, message.length));
|
||||
acc = ((acc + leBytesToBigInt(block) + (1n << BigInt(block.length * 8))) * r) % P1305;
|
||||
}
|
||||
return bigIntToLeBytes((acc + s) & ((1n << 128n) - 1n), 16);
|
||||
}
|
||||
|
||||
export function aeadEncrypt(key, nonce, plaintext, aad) {
|
||||
const polyKey = chachaBlock(key, 0, nonce).slice(0, 32);
|
||||
const ciphertext = chacha20Xor(key, 1, nonce, plaintext);
|
||||
const le64 = (n) => bigIntToLeBytes(BigInt(n), 8);
|
||||
const pad = (n) => new Uint8Array((16 - (n % 16)) % 16);
|
||||
const mac = poly1305(polyKey, concat(
|
||||
aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length)));
|
||||
return concat(ciphertext, mac);
|
||||
}
|
||||
|
||||
export function aeadDecrypt(key, nonce, sealed, aad) {
|
||||
if (sealed.length < 16) throw new Error("ciphertext shorter than the Poly1305 tag");
|
||||
const ciphertext = sealed.slice(0, sealed.length - 16);
|
||||
const polyKey = chachaBlock(key, 0, nonce).slice(0, 32);
|
||||
const le64 = (n) => bigIntToLeBytes(BigInt(n), 8);
|
||||
const pad = (n) => new Uint8Array((16 - (n % 16)) % 16);
|
||||
const expect = poly1305(polyKey, concat(
|
||||
aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length)));
|
||||
if (!timingSafeEqual(expect, sealed.slice(sealed.length - 16)))
|
||||
throw new Error("decryption failed: bad Poly1305 tag");
|
||||
return chacha20Xor(key, 1, nonce, ciphertext);
|
||||
}
|
||||
|
||||
// --- X25519 (RFC 7748) -------------------------------------------------------
|
||||
|
||||
const P_ED = (1n << 255n) - 19n;
|
||||
|
||||
function mod(value, p = P_ED) { return ((value % p) + p) % p; }
|
||||
|
||||
function powMod(base, exponent, p = P_ED) {
|
||||
let out = 1n;
|
||||
base = mod(base, p);
|
||||
while (exponent > 0n) {
|
||||
if (exponent & 1n) out = out * base % p;
|
||||
base = base * base % p;
|
||||
exponent >>= 1n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clampScalar(scalar) {
|
||||
const k = Uint8Array.from(scalar);
|
||||
k[0] &= 248; k[31] &= 127; k[31] |= 64;
|
||||
return k;
|
||||
}
|
||||
|
||||
function x25519Raw(scalar, u) {
|
||||
const k = leBytesToBigInt(clampScalar(scalar));
|
||||
const x1 = leBytesToBigInt(u) & ((1n << 255n) - 1n);
|
||||
const a24 = 121665n;
|
||||
let x2 = 1n, z2 = 0n, x3 = x1, z3 = 1n, swap = 0n;
|
||||
for (let t = 254n; t >= 0n; t--) {
|
||||
const kt = (k >> t) & 1n;
|
||||
swap ^= kt;
|
||||
if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; }
|
||||
swap = kt;
|
||||
const a = mod(x2 + z2), aa = a * a % P_ED;
|
||||
const b = mod(x2 - z2), bb = b * b % P_ED;
|
||||
const e = mod(aa - bb);
|
||||
const c = mod(x3 + z3), d = mod(x3 - z3);
|
||||
const da = d * a % P_ED, cb = c * b % P_ED;
|
||||
x3 = mod(da + cb) ** 2n % P_ED;
|
||||
z3 = x1 * mod(da - cb) ** 2n % P_ED;
|
||||
x2 = aa * bb % P_ED;
|
||||
z2 = e * mod(aa + a24 * e) % P_ED;
|
||||
}
|
||||
if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; }
|
||||
return x2 * powMod(z2, P_ED - 2n) % P_ED;
|
||||
}
|
||||
|
||||
// §2's low-order rejection: a clamped scalar is a multiple of 8, so any
|
||||
// low-order peer point yields an all-zero shared secret — rejecting the zero
|
||||
// output rejects all of them.
|
||||
export function x25519(scalar, peerPublic) {
|
||||
const shared = bigIntToLeBytes(x25519Raw(scalar, peerPublic), 32);
|
||||
if (shared.every(b => b === 0)) throw new Error("rejected low-order key agreement point");
|
||||
return shared;
|
||||
}
|
||||
|
||||
export function x25519Base(scalar) {
|
||||
return bigIntToLeBytes(x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")), 32);
|
||||
}
|
||||
|
||||
// --- Ed25519 (RFC 8032) ------------------------------------------------------
|
||||
|
||||
const L_ED = (1n << 252n) + 27742317777372353535851937790883648493n;
|
||||
const D_ED = mod(-121665n * powMod(121666n, P_ED - 2n));
|
||||
const B_ED = { x: 15112221349535400772501151409588531511454012693041857206046113283949847762202n,
|
||||
y: mod(4n * powMod(5n, P_ED - 2n)) };
|
||||
const IDENTITY = { x: 0n, y: 1n, z: 1n, t: 0n };
|
||||
|
||||
const toProjective = ({ x, y }) => ({ x, y, z: 1n, t: mod(x * y) });
|
||||
|
||||
function pointAdd(p, q) {
|
||||
const a = mod(p.y - p.x) * mod(q.y - q.x) % P_ED;
|
||||
const b = mod(p.y + p.x) * mod(q.y + q.x) % P_ED;
|
||||
const c = 2n * p.t * q.t % P_ED * D_ED % P_ED;
|
||||
const d = 2n * p.z * q.z % P_ED;
|
||||
const e = mod(b - a), f = mod(d - c), g = mod(d + c), h = b + a;
|
||||
return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED };
|
||||
}
|
||||
|
||||
function pointDouble(p) {
|
||||
const a = p.x * p.x % P_ED;
|
||||
const b = p.y * p.y % P_ED;
|
||||
const c = 2n * p.z * p.z % P_ED;
|
||||
const d = P_ED - a; // a = -1 on this curve, so d = -A
|
||||
const e = mod(mod(p.x + p.y) ** 2n - a - b);
|
||||
const g = mod(d + b);
|
||||
const f = mod(g - c);
|
||||
const h = mod(d - b);
|
||||
return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED };
|
||||
}
|
||||
|
||||
function scalarMult(scalar, point) {
|
||||
let result = IDENTITY;
|
||||
for (let t = 254n; t >= 0n; t--) {
|
||||
result = pointDouble(result);
|
||||
if ((scalar >> t) & 1n) result = pointAdd(result, point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function encodePoint(p) {
|
||||
const zInv = powMod(p.z, P_ED - 2n);
|
||||
const x = p.x * zInv % P_ED, y = p.y * zInv % P_ED;
|
||||
const out = bigIntToLeBytes(y, 32);
|
||||
out[31] |= Number(x & 1n) << 7;
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodePoint(bytes) {
|
||||
if (bytes.length !== 32) throw new Error("Ed25519 public key must be 32 bytes");
|
||||
const sign = bytes[31] >> 7;
|
||||
const y = leBytesToBigInt(bytes) & ((1n << 255n) - 1n);
|
||||
if (y >= P_ED) throw new Error("non-canonical Ed25519 public key");
|
||||
const u = mod(y * y - 1n), v = mod(D_ED * y * y + 1n);
|
||||
const v2 = v * v % P_ED, v3 = v2 * v % P_ED, v4 = v2 * v2 % P_ED;
|
||||
let x = u * v3 % P_ED * powMod(u * v4 % P_ED * v3 % P_ED, (P_ED - 5n) / 8n) % P_ED;
|
||||
if (mod(v * x % P_ED * x) !== u) {
|
||||
if (mod(v * x % P_ED * x) === mod(-u)) x = x * powMod(2n, (P_ED - 1n) / 4n) % P_ED;
|
||||
else throw new Error("not a point on the Ed25519 curve");
|
||||
}
|
||||
if (x === 0n && sign) throw new Error("invalid sign bit on x = 0");
|
||||
if (Number(x & 1n) !== sign) x = P_ED - x;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function seedToScalar(seed) {
|
||||
const h = sha512(seed);
|
||||
return leBytesToBigInt(clampScalar(h.slice(0, 32)));
|
||||
}
|
||||
|
||||
export function ed25519PublicKey(seed) {
|
||||
if (seed.length !== 32) throw new Error("identity seed must be 32 bytes");
|
||||
return encodePoint(scalarMult(seedToScalar(seed), toProjective(B_ED)));
|
||||
}
|
||||
|
||||
export function ed25519Sign(seed, message) {
|
||||
const h = sha512(seed);
|
||||
const a = leBytesToBigInt(clampScalar(h.slice(0, 32)));
|
||||
const publicKey = encodePoint(scalarMult(a, toProjective(B_ED)));
|
||||
const r = leBytesToBigInt(sha512(concat(h.slice(32), message))) % L_ED;
|
||||
const rEnc = encodePoint(scalarMult(r, toProjective(B_ED)));
|
||||
const k = leBytesToBigInt(sha512(concat(rEnc, publicKey, message))) % L_ED;
|
||||
return concat(rEnc, bigIntToLeBytes((r + k * a) % L_ED, 32));
|
||||
}
|
||||
|
||||
export function ed25519Verify(publicKey, message, signature) {
|
||||
try {
|
||||
const a = toProjective(decodePoint(publicKey));
|
||||
const r = toProjective(decodePoint(signature.slice(0, 32)));
|
||||
const s = leBytesToBigInt(signature.slice(32, 64));
|
||||
if (signature.length !== 64 || s >= L_ED) return false;
|
||||
const k = leBytesToBigInt(sha512(concat(signature.slice(0, 32), publicKey, message))) % L_ED;
|
||||
const lhs = scalarMult(s, toProjective(B_ED));
|
||||
const rhs = pointAdd(scalarMult(k, a), r);
|
||||
return lhs.x * rhs.z % P_ED === rhs.x * lhs.z % P_ED
|
||||
&& lhs.y * rhs.z % P_ED === rhs.y * lhs.z % P_ED;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- §2 conversions between the identity key and X25519 ----------------------
|
||||
|
||||
export function ed25519ToX25519(publicKey) {
|
||||
const y = leBytesToBigInt(publicKey) & ((1n << 255n) - 1n);
|
||||
if (y >= P_ED) throw new Error("non-canonical Ed25519 public key");
|
||||
if (mod(1n - y) === 0n) throw new Error("identity element has no X25519 image");
|
||||
return bigIntToLeBytes(mod(1n + y) * powMod(1n - y, P_ED - 2n) % P_ED, 32);
|
||||
}
|
||||
|
||||
export function ed25519SeedToX25519(seed) {
|
||||
return clampScalar(sha512(seed).slice(0, 32));
|
||||
}
|
||||
99
web/js/noise.js
Normal file
99
web/js/noise.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics.
|
||||
// The initiator is anonymous; the responder's static key arrives encrypted in
|
||||
// message two, which is what server pinning checks.
|
||||
|
||||
import { aeadDecrypt, aeadEncrypt, concat, hkdfSha256, randomBytes, sha256, timingSafeEqual, utf8Bytes, x25519, x25519Base } from "./crypto.js";
|
||||
|
||||
const PROTOCOL = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name
|
||||
const PROLOGUE = utf8Bytes("smolmail/1");
|
||||
|
||||
// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE.
|
||||
function nonce(n) {
|
||||
const out = new Uint8Array(12);
|
||||
new DataView(out.buffer).setBigUint64(4, BigInt(n), true);
|
||||
return out;
|
||||
}
|
||||
|
||||
// One direction of the post-handshake transport. The key is unique per
|
||||
// session, so the counter starting at zero is safe.
|
||||
class CipherState {
|
||||
constructor(key) {
|
||||
this.key = key;
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
encrypt(plaintext) {
|
||||
const sealed = aeadEncrypt(this.key, nonce(this.counter), plaintext, new Uint8Array(0));
|
||||
this.counter++;
|
||||
return sealed;
|
||||
}
|
||||
|
||||
decrypt(sealed) {
|
||||
const plaintext = aeadDecrypt(this.key, nonce(this.counter), sealed, new Uint8Array(0));
|
||||
this.counter++;
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
export class NxInitiator {
|
||||
constructor() {
|
||||
this.h = utf8Bytes(PROTOCOL);
|
||||
this.ck = this.h.slice();
|
||||
this.mixHash(PROLOGUE);
|
||||
this.key = null;
|
||||
}
|
||||
|
||||
mixHash(data) {
|
||||
this.h = sha256(concat(this.h, data));
|
||||
}
|
||||
|
||||
mixKey(ikm) {
|
||||
const okm = hkdfSha256(ikm, this.ck, new Uint8Array(0), 64);
|
||||
this.ck = okm.slice(0, 32);
|
||||
this.key = okm.slice(32);
|
||||
}
|
||||
|
||||
// Message one is just our ephemeral public key. No key is set yet, so the
|
||||
// empty payload travels in the clear — and is still mixed into h.
|
||||
// `esk` is pinned only by the test vectors, like the spec's fixed-ephemeral
|
||||
// envelope.
|
||||
writeMessage1(esk) {
|
||||
this.esk = esk ?? randomBytes(32);
|
||||
this.epk = x25519Base(this.esk);
|
||||
this.mixHash(this.epk);
|
||||
this.mixHash(new Uint8Array(0));
|
||||
return this.epk;
|
||||
}
|
||||
|
||||
// Message two: e (plaintext), ee, then the responder's static and the
|
||||
// (empty) payload as AEAD ciphertexts chained through h. Each MixKey
|
||||
// restarts the nonce at zero.
|
||||
readMessage2(message) {
|
||||
if (message.length !== 32 + 48 + 16)
|
||||
throw new Error(`unexpected NX message length ${message.length}`);
|
||||
const re = message.slice(0, 32);
|
||||
this.mixHash(re);
|
||||
this.mixKey(x25519(this.esk, re));
|
||||
this.serverStatic = this.decryptAndHash(message.slice(32, 80));
|
||||
this.mixKey(x25519(this.esk, this.serverStatic)); // es
|
||||
const payload = this.decryptAndHash(message.slice(80));
|
||||
if (payload.length !== 0) throw new Error("unexpected payload in handshake");
|
||||
this.handshakeHash = this.h;
|
||||
// Split(): two transport keys from the final chaining key, zero-length ikm
|
||||
const okm = hkdfSha256(new Uint8Array(0), this.ck, new Uint8Array(0), 64);
|
||||
return { send: new CipherState(okm.slice(0, 32)), recv: new CipherState(okm.slice(32)) };
|
||||
}
|
||||
|
||||
decryptAndHash(sealed) {
|
||||
const plaintext = aeadDecrypt(this.key, nonce(0), sealed, this.h);
|
||||
this.mixHash(sealed);
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
// §4: a pinned key must match the one the handshake revealed, or everything stops.
|
||||
export function checkPin(pinned, serverStatic, host) {
|
||||
if (pinned && !timingSafeEqual(pinned, serverStatic))
|
||||
throw new Error(`${host} presented a different key than the one pinned`);
|
||||
return pinned !== null && pinned !== undefined;
|
||||
}
|
||||
407
web/js/proto.js
Normal file
407
web/js/proto.js
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// 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.
|
||||
|
||||
import {
|
||||
aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519,
|
||||
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, 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 MAX_FRAME = 1 << 20, NOISE_PAYLOAD = 65535 - 16, PAD_TO = 1024;
|
||||
export const ENVELOPE_HEADER = 69, PAYLOAD_HEADER = 45, MAX_CHAIN = 16;
|
||||
const FRONTMATTER_MAX = 4096, FRONTMATTER_KEYS = 64;
|
||||
|
||||
export const OP = { AUTH: 0x00, RESOLVE: 0x01, SEND: 0x02, FETCH: 0x03, DELETE: 0x04, REGISTER: 0x05 };
|
||||
export const STATUS = { 0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user",
|
||||
4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large",
|
||||
8: "rate limited", 9: "not permitted", 10: "internal error" };
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
// --- encoding helpers -------------------------------------------------------
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function b32encode(bytes) {
|
||||
let out = "", value = 0, bits = 0;
|
||||
for (const b of bytes) {
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) { bits -= 5; out += B32[(value >>> bits) & 31]; }
|
||||
}
|
||||
if (bits) out += B32[(value << (5 - bits)) & 31];
|
||||
return out.toLowerCase();
|
||||
}
|
||||
|
||||
export function b32decode(text) {
|
||||
let out = [], value = 0, bits = 0;
|
||||
for (const ch of text.trim().toUpperCase().replace(/=+$/, "")) {
|
||||
const idx = B32.indexOf(ch);
|
||||
if (idx < 0) throw new SmolError(`invalid base32 character '${ch}'`);
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) { bits -= 8; out.push((value >>> bits) & 0xff); }
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
// §3: the first 20 base32 characters of the identity, in groups of four.
|
||||
export function fingerprint(identity) {
|
||||
const s = b32encode(identity).slice(0, 20);
|
||||
return s.match(/.{4}/g).join(" ");
|
||||
}
|
||||
|
||||
export const u16BE = (n) => Uint8Array.of((n >> 8) & 0xff, n & 0xff);
|
||||
|
||||
export const u32BE = (n) => Uint8Array.of((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
|
||||
|
||||
export const i64BE = (n) => {
|
||||
const out = new Uint8Array(8);
|
||||
new DataView(out.buffer).setBigInt64(0, BigInt(n), false);
|
||||
return out;
|
||||
};
|
||||
|
||||
export const nowSeconds = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
// Fail-closed reader; every parse raises rather than reading past the end.
|
||||
export class Reader {
|
||||
constructor(buf) { this.buf = buf; this.pos = 0; }
|
||||
take(n) {
|
||||
if (n < 0 || this.pos + n > this.buf.length) throw new SmolError("truncated message");
|
||||
this.pos += n;
|
||||
return this.buf.slice(this.pos - n, this.pos);
|
||||
}
|
||||
u8() { return this.take(1)[0]; }
|
||||
u16() { const b = this.take(2); return (b[0] << 8) | b[1]; }
|
||||
u32() { return new DataView(this.take(4).buffer).getUint32(0); }
|
||||
i64() { return new DataView(this.take(8).buffer).getBigInt64(0); }
|
||||
get left() { return this.buf.length - this.pos; }
|
||||
get done() { return this.pos === this.buf.length; }
|
||||
}
|
||||
|
||||
// --- identity ---------------------------------------------------------------
|
||||
|
||||
// §2: 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));
|
||||
}
|
||||
|
||||
// --- addressing (§3) --------------------------------------------------------
|
||||
|
||||
const ADDRESS = /^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$/;
|
||||
|
||||
export function parseAddress(text) {
|
||||
text = text.trim();
|
||||
let identity = null;
|
||||
if (text.startsWith("smol://")) {
|
||||
const rest = text.slice("smol://".length);
|
||||
const slash = rest.lastIndexOf("/");
|
||||
if (slash < 0) throw new SmolError(`${text}: smol:// address carries no key`);
|
||||
identity = b32decode(rest.slice(slash + 1));
|
||||
if (identity.length !== KEY_LEN)
|
||||
throw new SmolError(`${text}: key is ${identity.length} bytes, expected ${KEY_LEN}`);
|
||||
text = rest.slice(0, slash);
|
||||
}
|
||||
const m = ADDRESS.exec(text.toLowerCase());
|
||||
if (!m) throw new SmolError(`'${text}' is not a valid address`);
|
||||
const { user, host } = m.groups;
|
||||
if ("._-".includes(user[0]) || "._-".includes(user.at(-1)))
|
||||
throw new SmolError(`${user} may not begin or end with a separator`);
|
||||
const port = m.groups.port ? Number(m.groups.port) : DEFAULT_PORT;
|
||||
return {
|
||||
user, host, port, identity,
|
||||
get short() { return `${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}`; },
|
||||
uri: (key) => `smol://${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}/${b32encode(key)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- message format (§5) ----------------------------------------------------
|
||||
|
||||
export function messageId(envelope) {
|
||||
return sha256(concat(LABEL.id, envelope)).slice(0, ID_LEN);
|
||||
}
|
||||
|
||||
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
|
||||
// cannot decrypt what they sent; opts.esk exists only so tests can pin it.
|
||||
export function seal(identity, recipient, body, when = nowSeconds(), opts = {}) {
|
||||
const esk = opts.esk ?? randomBytes(KEY_LEN);
|
||||
const epk = x25519Base(esk);
|
||||
const key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)), concat(epk, recipient), LABEL.seal);
|
||||
const header = concat(Uint8Array.of(1), identity.publicKey, i64BE(when), u32BE(body.length));
|
||||
let plaintext = concat(header, body,
|
||||
ed25519Sign(identity.seed, concat(LABEL.msg, recipient, epk, header, body)));
|
||||
if (opts.pad !== false)
|
||||
plaintext = concat(plaintext, new Uint8Array((PAD_TO - plaintext.length % PAD_TO) % PAD_TO));
|
||||
const aad = concat(utf8Bytes("SMOL"), Uint8Array.of(1), recipient, epk);
|
||||
return concat(aad, aeadEncrypt(key, new Uint8Array(12), plaintext, aad));
|
||||
}
|
||||
|
||||
// Inverse of seal(); throws unless the signature and the recipient both check
|
||||
// out. `identities` may include retired keys, per §7.
|
||||
export function unseal(identities, envelope) {
|
||||
if (envelope.length < ENVELOPE_HEADER + 16) throw new SmolError("envelope too short");
|
||||
if (utf8Bytes("SMOL").some((b, i) => b !== envelope[i])) throw new SmolError("not a Smol Mail envelope");
|
||||
if (envelope[4] !== 1) throw new SmolError(`unsupported envelope version ${envelope[4]}`);
|
||||
const to = envelope.slice(5, 37), epk = envelope.slice(37, 69), sealed = envelope.slice(69);
|
||||
const me = identities.find(i => timingSafeEqual(i.publicKey, to));
|
||||
if (!me) throw new SmolError(`addressed to ${b32encode(to).slice(0, 16)}…, not one of our keys`);
|
||||
const key = hkdfSha256(x25519(ed25519SeedToX25519(me.seed), epk), concat(epk, to), LABEL.seal);
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = aeadDecrypt(key, new Uint8Array(12), sealed, envelope.slice(0, ENVELOPE_HEADER));
|
||||
} catch {
|
||||
throw new SmolError("decryption failed: wrong key or corrupt envelope");
|
||||
}
|
||||
const r = new Reader(plaintext);
|
||||
if (r.u8() !== 1) throw new SmolError("unsupported payload version");
|
||||
const sender = r.take(KEY_LEN), when = r.i64(), bodyLen = r.u32();
|
||||
if (bodyLen > r.left) throw new SmolError("payload body length exceeds the payload");
|
||||
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");
|
||||
return { sender, time: Number(when), body, id: messageId(envelope) };
|
||||
}
|
||||
|
||||
// --- body frontmatter (§5.5) ------------------------------------------------
|
||||
|
||||
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.
|
||||
export function parseFrontmatter(text) {
|
||||
if (!text.startsWith("---\n")) return { fields: {}, body: text };
|
||||
const lines = text.split("\n");
|
||||
const close = lines.indexOf("---", 1);
|
||||
if (close < 0) return { fields: {}, body: text };
|
||||
const block = lines.slice(1, close), rest = lines.slice(close + 1).join("\n");
|
||||
const encoded = block.map(line => utf8Bytes(line).length + 1);
|
||||
if (block.length > FRONTMATTER_KEYS || encoded.reduce((a, b) => a + b, 0) > FRONTMATTER_MAX)
|
||||
return { fields: {}, body: text };
|
||||
const fields = {};
|
||||
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);
|
||||
if (!(key in fields)) fields[key] = line.slice(colon + 1).trim(); // first occurrence wins
|
||||
}
|
||||
return { fields, body: rest };
|
||||
}
|
||||
|
||||
// Emit a block only when needed, including to escape a body that genuinely
|
||||
// begins with `---` (§5.5).
|
||||
export function buildFrontmatter(fields, body) {
|
||||
if (!fields.length && !body.startsWith("---\n")) return body;
|
||||
return `---\n${fields.map(([k, v]) => `${k}: ${v}\n`).join("")}---\n${body}`;
|
||||
}
|
||||
|
||||
// --- key rotation (§7) -------------------------------------------------------
|
||||
|
||||
export function makeCert(oldIdentity, newSeed, when = nowSeconds()) {
|
||||
const newPub = ed25519PublicKey(newSeed), time = i64BE(when);
|
||||
return concat(oldIdentity.publicKey, newPub, time,
|
||||
ed25519Sign(oldIdentity.seed, concat(LABEL.rotate, oldIdentity.publicKey, newPub, time)));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
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;
|
||||
key = next;
|
||||
}
|
||||
return started && same(key, current);
|
||||
}
|
||||
|
||||
// --- framing and operations (§4, §6) -----------------------------------------
|
||||
|
||||
// Reassembles a byte stream (WebSocket or TCP) into exact-length reads.
|
||||
class ByteStream {
|
||||
constructor(stream) {
|
||||
this.chunks = [];
|
||||
this.length = 0;
|
||||
this.closed = null;
|
||||
this.waiters = [];
|
||||
stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); };
|
||||
stream.onClose = () => this.fail(new SmolError("server closed the connection"));
|
||||
}
|
||||
|
||||
wake() {
|
||||
this.waiters = this.waiters.filter(w => {
|
||||
if (this.closed) { w.reject(this.closed); return false; }
|
||||
if (this.length >= w.need) { w.resolve(); return false; }
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
fail(error) {
|
||||
this.closed = error;
|
||||
this.waiters.forEach(w => w.reject(error));
|
||||
this.waiters = [];
|
||||
}
|
||||
|
||||
async readExact(n) {
|
||||
if (this.closed) throw this.closed;
|
||||
if (this.length < n) {
|
||||
await new Promise((resolve, reject) => this.waiters.push({ need: n, resolve, reject }));
|
||||
if (this.closed) throw this.closed;
|
||||
}
|
||||
const out = new Uint8Array(n);
|
||||
let off = 0;
|
||||
while (off < n) {
|
||||
const chunk = this.chunks[0];
|
||||
const take = Math.min(chunk.length, n - off);
|
||||
out.set(off ? chunk.slice(0, take) : chunk.subarray(0, take), off);
|
||||
if (take === chunk.length) this.chunks.shift();
|
||||
else this.chunks[0] = chunk.subarray(take);
|
||||
off += take;
|
||||
this.length -= take;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// One Noise session: application frames split across u16-prefixed Noise
|
||||
// messages, requests and responses as in §6.1.
|
||||
export class Session {
|
||||
constructor(wire, stream, send, recv) {
|
||||
this.wire = wire;
|
||||
this.stream = stream;
|
||||
this.send = send;
|
||||
this.recv = recv;
|
||||
}
|
||||
|
||||
async readNoise() {
|
||||
const head = await this.wire.readExact(2);
|
||||
const length = (head[0] << 8) | head[1];
|
||||
if (length < 16) throw new SmolError(`server sent a ${length}-byte Noise message`);
|
||||
return this.recv.decrypt(await this.wire.readExact(length));
|
||||
}
|
||||
|
||||
async call(op, body = new Uint8Array(0)) {
|
||||
const frame = concat(u32BE(1 + body.length), Uint8Array.of(op), body);
|
||||
if (frame.length > MAX_FRAME + 4) throw new SmolError("request exceeds the maximum frame size");
|
||||
for (let off = 0; off < frame.length; off += NOISE_PAYLOAD) {
|
||||
const packet = this.send.encrypt(frame.slice(off, off + NOISE_PAYLOAD));
|
||||
this.stream.send(concat(u16BE(packet.length), packet));
|
||||
}
|
||||
let length = -1;
|
||||
let have = new Uint8Array(0);
|
||||
while (length < 0 || have.length < 4 + length) {
|
||||
have = concat(have, await this.readNoise());
|
||||
if (length < 0 && have.length >= 4) {
|
||||
length = new DataView(have.buffer).getUint32(0);
|
||||
if (length < 1 || length > MAX_FRAME)
|
||||
throw new SmolError(`server sent a frame of length ${length}`);
|
||||
}
|
||||
}
|
||||
const payload = have.slice(4, 4 + length);
|
||||
return { status: payload[1], body: payload.slice(2) }; // op echo, status, body (§6.1)
|
||||
}
|
||||
}
|
||||
|
||||
// Handshake plus §4 pinning. Returns the session, the server's static key as
|
||||
// revealed by the handshake, and whether that key was already pinned.
|
||||
export async function openSession(stream, host, pinned = null) {
|
||||
const wire = new ByteStream(stream);
|
||||
const nx = new NxInitiator();
|
||||
const m1 = nx.writeMessage1();
|
||||
stream.send(concat(u16BE(m1.length), m1));
|
||||
const head = await wire.readExact(2);
|
||||
const ciphers = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1]));
|
||||
if (pinned && !timingSafeEqual(pinned, nx.serverStatic))
|
||||
throw new SmolError(`${host} presented a different key than the one pinned\n` +
|
||||
` pinned: ${b32encode(pinned)}\n presented: ${b32encode(nx.serverStatic)}`);
|
||||
return {
|
||||
session: new Session(wire, stream, ciphers.send, ciphers.recv),
|
||||
serverStatic: nx.serverStatic,
|
||||
pinned: pinned !== null,
|
||||
handshakeHash: nx.handshakeHash,
|
||||
};
|
||||
}
|
||||
|
||||
export function expectOk(status, what) {
|
||||
if (status !== 0) throw new SmolError(`${what} failed: ${STATUS[status] ?? status} (${status})`);
|
||||
}
|
||||
|
||||
// §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) {
|
||||
const name = utf8Bytes(username);
|
||||
if (name.length > 255) throw new SmolError("username too long");
|
||||
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");
|
||||
}
|
||||
|
||||
// RESOLVE, returning the current key and its rotation chain (§6.1).
|
||||
export async function resolveOp(session, user) {
|
||||
const name = utf8Bytes(user);
|
||||
if (name.length > 255) throw new SmolError("username too long");
|
||||
const { status, body } = await session.call(OP.RESOLVE, concat(Uint8Array.of(name.length), name));
|
||||
expectOk(status, `resolving ${user}`);
|
||||
const r = new Reader(body);
|
||||
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);
|
||||
expectOk(status, "sending");
|
||||
return body.length === ID_LEN ? body : messageId(envelope);
|
||||
}
|
||||
|
||||
export async function fetchOp(session) {
|
||||
const { status, body } = await session.call(OP.FETCH);
|
||||
expectOk(status, "fetching");
|
||||
const r = new Reader(body);
|
||||
return Array.from({ length: r.u16() }, () => {
|
||||
const id = r.take(ID_LEN), receivedAt = Number(r.i64());
|
||||
return { id, receivedAt, envelope: r.take(r.u32()) };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOp(session, ids) {
|
||||
if (ids.length > 0xffff) throw new SmolError("too many ids for one DELETE");
|
||||
const body = concat(u16BE(ids.length), ...ids);
|
||||
const { status, body: reply } = await session.call(OP.DELETE, body);
|
||||
expectOk(status, "acknowledging");
|
||||
return new DataView(reply.buffer).getUint16(0);
|
||||
}
|
||||
|
||||
export async function registerOp(session, { 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,
|
||||
Uint8Array.of(tokenBytes.length), tokenBytes,
|
||||
Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0));
|
||||
const { status } = await session.call(OP.REGISTER, body);
|
||||
expectOk(status, `registering ${username}`);
|
||||
}
|
||||
191
web/js/store.js
Normal file
191
web/js/store.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// 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).
|
||||
|
||||
import { hex, unhex } from "./crypto.js";
|
||||
import { b32decode, b32encode, identityFromSeed } from "./proto.js";
|
||||
|
||||
const KEY = "gsmol";
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function save(state) {
|
||||
localStorage.setItem(KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function update(fn) {
|
||||
const state = load();
|
||||
const next = fn(state);
|
||||
save(next ?? state);
|
||||
}
|
||||
|
||||
// --- identity ----------------------------------------------------------------
|
||||
|
||||
export function seed() {
|
||||
const raw = load().seed;
|
||||
return raw ? unhex(raw) : null;
|
||||
}
|
||||
|
||||
export function identity() {
|
||||
const raw = seed();
|
||||
return raw ? identityFromSeed(raw) : 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.
|
||||
export function identities() {
|
||||
const me = identity();
|
||||
if (!me) return [];
|
||||
const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s)));
|
||||
return [me, ...retired];
|
||||
}
|
||||
|
||||
// --- account and server pins ---------------------------------------------------
|
||||
|
||||
export function account() {
|
||||
const a = load().account;
|
||||
return a ? { ...a } : null;
|
||||
}
|
||||
|
||||
export function setAccount(addr) {
|
||||
update(state => {
|
||||
state.account = { user: addr.user, host: addr.host, port: addr.port };
|
||||
});
|
||||
}
|
||||
|
||||
export function serverPin(host) {
|
||||
const raw = (load().servers || {})[host];
|
||||
return raw ? b32decode(raw) : null;
|
||||
}
|
||||
|
||||
export function pinServer(host, keyBytes) {
|
||||
update(state => {
|
||||
state.servers = { ...(state.servers || {}), [host]: b32encode(keyBytes) };
|
||||
});
|
||||
}
|
||||
|
||||
export function unpinServer(host) {
|
||||
update(state => {
|
||||
delete (state.servers || {})[host];
|
||||
});
|
||||
}
|
||||
|
||||
export function allPins() {
|
||||
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
|
||||
}
|
||||
|
||||
// --- contacts ------------------------------------------------------------------
|
||||
|
||||
export function contact(address) {
|
||||
const c = (load().contacts || {})[address];
|
||||
return c ? { key: b32decode(c.key), verified: c.verified } : null;
|
||||
}
|
||||
|
||||
export function saveContact(address, keyBytes, verified) {
|
||||
update(state => {
|
||||
state.contacts = {
|
||||
...(state.contacts || {}),
|
||||
[address]: { key: b32encode(keyBytes), verified, seenAt: Date.now() },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function addressForKey(keyBytes) {
|
||||
return Object.entries(load().contacts || {})
|
||||
.find(([, c]) => c.key === b32encode(keyBytes))?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function allContacts() {
|
||||
return Object.entries(load().contacts || {})
|
||||
.map(([address, c]) => ({ address, key: c.key, verified: c.verified }));
|
||||
}
|
||||
|
||||
// --- read markers ---------------------------------------------------------------
|
||||
|
||||
export function markRead(idHex) {
|
||||
update(state => {
|
||||
state.read = { ...(state.read || {}), [idHex]: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function isRead(idHex) {
|
||||
return Boolean((load().read || {})[idHex]);
|
||||
}
|
||||
|
||||
// --- sealed mail (IndexedDB) ------------------------------------------------------
|
||||
|
||||
const DB_NAME = "gsmol", DB_VERSION = 1;
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
for (const box of ["inbox", "sent"]) {
|
||||
if (!db.objectStoreNames.contains(box)) db.createObjectStore(box, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function tx(db, box, mode, fn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = fn(db.transaction(box, mode).objectStore(box));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function storeMessage(box, record) {
|
||||
const db = await openDb();
|
||||
await tx(db, box, "readwrite", store => store.put(record));
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Returns null when the id already exists, so fetch can leave server state alone.
|
||||
export async function storeIfNew(box, record) {
|
||||
const db = await openDb();
|
||||
const existing = await tx(db, box, "readonly", store => store.get(record.id));
|
||||
if (existing) { db.close(); return null; }
|
||||
await tx(db, box, "readwrite", store => store.put(record));
|
||||
db.close();
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function listMessages(box) {
|
||||
const db = await openDb();
|
||||
const rows = await tx(db, box, "readonly", store => store.getAll());
|
||||
db.close();
|
||||
return rows.sort((a, b) => (a.receivedAt ?? a.sentAt) - (b.receivedAt ?? b.sentAt));
|
||||
}
|
||||
|
||||
export async function getMessage(box, id) {
|
||||
const db = await openDb();
|
||||
const row = await tx(db, box, "readonly", store => store.get(id));
|
||||
db.close();
|
||||
return row ?? null;
|
||||
}
|
||||
22
web/js/transport.js
Normal file
22
web/js/transport.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// The byte pipe to a smolmaild server. Browsers cannot open TCP sockets, so
|
||||
// bytes travel through the local bridge's WebSocket relay unchanged; the
|
||||
// Noise session lives in this page either way.
|
||||
|
||||
const BRIDGE = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
|
||||
|
||||
export function connectStream(host, port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`);
|
||||
socket.binaryType = "arraybuffer";
|
||||
const stream = {
|
||||
send: bytes => socket.send(bytes),
|
||||
close: () => socket.close(),
|
||||
onData: null,
|
||||
onClose: null,
|
||||
};
|
||||
socket.onopen = () => resolve(stream);
|
||||
socket.onerror = () => reject(new Error(`cannot reach the bridge at ${BRIDGE}`));
|
||||
socket.onmessage = event => stream.onData?.(new Uint8Array(event.data));
|
||||
socket.onclose = () => stream.onClose?.();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue