// Headless driver for the end-to-end test: the same web/js modules the browser // uses, over a TCP socket (or, with GSMOL_TRANSPORT=ws, through the bridge's // WebSocket relay — the exact byte path the browser takes). // // node test/client.mjs --state bob.json [args] // // Commands mirror the reference smolmail.py: keygen, trust, register, resolve, // import, send, fetch, list, read, rotate. State lives in a JSON file; mail is // stored sealed, like the browser store does. import { existsSync, readFileSync, writeFileSync } from "node:fs"; import net from "node:net"; import { hex, timingSafeEqual, unhex, utf8Bytes } from "../web/js/crypto.js"; import * as proto from "../web/js/proto.js"; const { b32decode, b32encode, fingerprint } = proto; const args = process.argv.slice(2); const stateIndex = args.indexOf("--state"); const statePath = stateIndex >= 0 ? args[stateIndex + 1] : "client-state.json"; const command = args.find((a, i) => i !== stateIndex && i !== stateIndex + 1 && !a.startsWith("--")); const rest = args.slice(args.indexOf(command) + 1); const load = () => existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {}; const save = (state) => writeFileSync(statePath, JSON.stringify(state)); function parseOption(name) { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; } // --- transports ------------------------------------------------------------- function connectTcp(host, port) { return new Promise((resolve, reject) => { const socket = net.connect({ host, port }); socket.on("error", reject); socket.on("connect", () => { const stream = { send: (bytes) => socket.write(bytes), close: () => socket.end(), onData: null, onClose: null, }; socket.on("data", (chunk) => stream.onData?.(new Uint8Array(chunk))); socket.on("close", () => stream.onClose?.()); resolve(stream); }); }); } function connectWs(host, port) { const bridge = process.env.GSMOL_BRIDGE || "ws://127.0.0.1:8096"; 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.onerror = () => reject(new Error(`cannot reach the bridge at ${bridge}`)); socket.onopen = () => resolve(stream); socket.onmessage = (event) => stream.onData?.(new Uint8Array(event.data)); socket.onclose = () => stream.onClose?.(); }); } const connectStream = process.env.GSMOL_TRANSPORT === "ws" ? connectWs : connectTcp; // --- session ------------------------------------------------------------------ async function open(host, port, { requirePin }) { const state = load(); const pinned = state.servers?.[host] ? b32decode(state.servers[host]) : null; if (requirePin && !pinned) throw new proto.SmolError(`no pinned key for ${host}`); const stream = await connectStream(host, port); const opened = await proto.openSession(stream, host, pinned); if (!pinned) console.error(`warning: ${host} is not pinned; its key is ${b32encode(opened.serverStatic)}`); return opened; } function identities(state) { const current = proto.identityFromSeed(unhex(state.seed)); const retired = (state.retired || []).map(s => proto.identityFromSeed(unhex(s))); return [current, ...retired]; } // --- commands ------------------------------------------------------------------- const commands = { keygen() { const state = load(); state.seed = hex(cryptoRandom(32)); save(state); const me = proto.identityFromSeed(unhex(state.seed)); console.log(`public key: ${b32encode(me.publicKey)}`); console.log(`fingerprint: ${fingerprint(me.publicKey)}`); }, trust() { const host = rest[0], key = b32decode(rest[1]); if (key.length !== 32) throw new proto.SmolError(`server key is ${key.length} bytes, expected 32`); const state = load(); state.servers = { ...(state.servers || {}), [host]: b32encode(key) }; save(state); console.log(`pinned ${host} ${b32encode(key)}`); }, async register() { const state = load(); const me = proto.identityFromSeed(unhex(state.seed)); const addr = proto.parseAddress(rest[0]); const opened = await open(addr.host, addr.port, { requirePin: true }); try { await proto.registerOp(opened.session, { username: addr.user, identity: me, token: parseOption("--token") || "", }); } finally { opened.session.stream.close(); } state.account = { user: addr.user, host: addr.host, port: addr.port }; save(state); console.log(`registered ${addr.short}`); console.log(`share: ${addr.uri(me.publicKey)}`); }, async resolve() { const state = load(); const addr = proto.parseAddress(rest[0]); const opened = await open(addr.host, addr.port, { requirePin: false }); let current, chain; try { ({ identity: current, chain } = await proto.resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } const known = state.contacts?.[addr.short]; if (!known) { state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(current), verified: false } }; save(state); console.log(`${addr.short} ${b32encode(current)}`); console.log(` pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`); return; } if (timingSafeEqual(b32decode(known.key), current)) return console.log(`${addr.short}: unchanged`); if (proto.walkChain(b32decode(known.key), current, chain)) { state.contacts[addr.short] = { key: b32encode(current), verified: known.verified }; save(state); console.log(`warning: ${addr.short} rotated its key; a signed chain confirms it`); return; } throw new proto.SmolError(`${addr.short} presents a different key with no valid rotation chain`); }, import() { const state = load(); const addr = proto.parseAddress(rest[0]); if (!addr.identity) throw new proto.SmolError("import needs a smol:// address carrying a key"); state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(addr.identity), verified: true }, }; save(state); console.log(`imported ${addr.short} ${b32encode(addr.identity)} (verified)`); }, contacts() { const state = load(); for (const [address, c] of Object.entries(state.contacts || {})) console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"}`); }, async send() { const state = load(); const me = proto.identityFromSeed(unhex(state.seed)); const addr = proto.parseAddress(rest[0]); let recipient; if (addr.identity) { recipient = addr.identity; state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(recipient), verified: true }, }; } else if (state.contacts?.[addr.short]) { recipient = b32decode(state.contacts[addr.short].key); } else { const opened = await open(addr.host, addr.port, { requirePin: false }); try { ({ identity: recipient } = await proto.resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(recipient), verified: false } }; console.error(`warning: ${addr.short} pinned (trust on first use)`); } save(state); const fields = [["Subject", parseOption("--subject")], ["In-Reply-To", parseOption("--reply-to")]] .filter(([, v]) => v); if (state.account && !rest.includes("--anonymous")) fields.push(["Reply-To", proto.parseAddress(`${state.account.user}@${state.account.host}` + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)).uri(me.publicKey)]); const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n")); const envelope = proto.seal(me, recipient, body); const opened = await open(addr.host, addr.port, { requirePin: false }); try { await proto.sendOp(opened.session, envelope); } finally { opened.session.stream.close(); } state.sent = [...(state.sent || []), { id: hex(proto.messageId(envelope)), recipient: addr.short, envelope: hex(proto.seal(me, me.publicKey, body)), sentAt: proto.nowSeconds(), }]; save(state); console.log(`sent ${hex(proto.messageId(envelope)).slice(0, 8)} to ${addr.short}`); }, async fetch() { const state = load(); const me = proto.identityFromSeed(unhex(state.seed)); const account = proto.parseAddress(`${state.account.user}@${state.account.host}` + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)); const ids = identities(state); const opened = await open(account.host, account.port, { requirePin: true }); let total = 0, stored = 0, rejected = 0; try { await proto.authenticate(opened.session, opened.handshakeHash, account.user, me); for (;;) { const records = await proto.fetchOp(opened.session); if (!records.length) break; const acked = []; for (const record of records) { total++; try { if (!timingSafeEqual(proto.messageId(record.envelope), record.id)) throw new proto.SmolError("id does not match the envelope"); proto.unseal(ids, record.envelope); } catch (err) { rejected++; console.error(`warning: ${hex(record.id)}: ${err.message}; left on server`); continue; } if (!state.inbox) state.inbox = []; if (!state.inbox.some(m => m.id === hex(record.id))) { state.inbox.push({ id: hex(record.id), envelope: hex(record.envelope), receivedAt: record.receivedAt, }); stored++; } acked.push(record.id); } if (!acked.length) break; await proto.deleteOp(opened.session, acked); } } finally { opened.session.stream.close(); } save(state); console.log(`${total} message(s): ${stored} new, ${rejected} rejected`); }, list() { const state = load(); for (const m of state.inbox || []) describe(state, m, "inbox"); for (const m of state.sent || []) describe(state, m, "sent"); }, read() { const state = load(); const wanted = rest[0].toLowerCase(); const match = [...(state.inbox || []).map(m => [m, "inbox"]), ...(state.sent || []).map(m => [m, "sent"])] .find(([m]) => m.id.startsWith(wanted)); if (!match) throw new proto.SmolError(`no message matching ${wanted}`); describe(state, match[0], match[1], true); }, async rotate() { const state = load(); const old = proto.identityFromSeed(unhex(state.seed)); const account = proto.parseAddress(`${state.account.user}@${state.account.host}` + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)); const fresh = proto.newIdentity(); const cert = proto.makeCert(old, fresh.seed); const opened = await open(account.host, account.port, { requirePin: true }); try { await proto.registerOp(opened.session, { username: account.user, identity: fresh, cert }); } finally { opened.session.stream.close(); } state.retired = [...(state.retired || []), state.seed]; state.seed = hex(fresh.seed); save(state); console.log(`rotated ${account.short}`); console.log(`new key: ${b32encode(fresh.publicKey)}`); console.log(`fingerprint: ${fingerprint(fresh.publicKey)}`); }, }; function describe(state, row, box, verbose = false) { const id = box === "inbox" ? row.id : row.id; try { const opened = proto.unseal(identities(state), unhex(row.envelope)); const { fields, body } = proto.parseFrontmatter(new TextDecoder().decode(opened.body)); const who = box === "sent" ? row.recipient : Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(opened.sender))?.[0] ?? `<${b32encode(opened.sender).slice(0, 20)}…>`; const stamp = new Date((opened.time) * 1000).toISOString().slice(0, 16).replace("T", " "); if (!verbose) return console.log( `${id.slice(0, 8)} ${stamp} ${who.padEnd(28).slice(0, 28)} ${fields.Subject || "(no subject)"}`); console.log(`id: ${id}`); console.log(`${box === "sent" ? "to" : "from"}: ${who}`); console.log(`key: ${b32encode(opened.sender)}`); console.log(`date: ${new Date(opened.time * 1000).toISOString()}`); for (const [k, v] of Object.entries(fields)) console.log(`${k.toLowerCase()}: ${v}`); console.log("signature verified\n"); console.log(body); } catch (err) { if (verbose) throw err; console.log(`${id.slice(0, 8)} `); } } function cryptoRandom(n) { const out = new Uint8Array(n); crypto.getRandomValues(out); return out; } if (!commands[command]) { console.error(`unknown command: ${command}`); process.exit(2); } await commands[command]();