clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / scripts / signer-recovery.test.mjs
signer-recovery.test.mjs 12.0K
1import assert from "node:assert/strict";2import {webcrypto} from "node:crypto";3import {readFile} from "node:fs/promises";4import test from "node:test";5import vm from "node:vm";67const bridge = await readFile(new URL("../tinyclient/bridge.js", import.meta.url), "utf8");8const shared = await readFile(new URL("../tinyclient/tiny.js", import.meta.url), "utf8");910class Element {11 constructor(tag = "div") { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = new Map(); this.hidden = false; }12 setAttribute(k, v) { this.attributes.set(k, String(v)); }13 getAttribute(k) { return this.attributes.get(k) ?? null; }14 hasAttribute(k) { return this.attributes.has(k); }15 append(...items) { this.children.push(...items); }16 replaceChildren(...items) { this.children = items; }17 addEventListener(name, fn) { this.events ||= {}; this.events[name] = fn; }18 dispatch(name, event = {}) { return this.events?.[name]?.({...event, currentTarget: this}); }19 matches() { return false; }20 querySelector() { return null; }21 get textContent() { return this.children.map(child => child.textContent ?? "").join(""); }22 set textContent(value) { this.children = [{textContent: String(value)}]; }23}2425async function page({actor = "a".repeat(64), native = null, stored = false, signerFactory = () => null, now = Date.now()} = {}) {26 const nodes = new Map();27 for (const id of ["session-status", "session-login", "session-logout", "signer-status", "bunker", "bunker-url"]) nodes.set(id, new Element(id));28 const connection = new Element("signer-connection"); connection.setAttribute("actor", actor); nodes.set("signer-connection", connection);29 const docEvents = new Map(), winEvents = new Map(), session = new Map(), local = new Map(), requests = [];30 const on = (map, name, fn) => map.set(name, [...(map.get(name) || []), fn]);31 const fire = (map, name, event = {}) => Promise.all((map.get(name) || []).map(fn => fn(event)));32 const nativeObject = native;33 const signer = nativeObject;34 let clock = now;35 const sandbox = {36 tiny: {}, document: {37 visibilityState: "visible", referrer: "", documentElement: {dataset: {}},38 getElementById: id => nodes.get(id), querySelector: selector => selector === "signer-connection" ? nodes.get("signer-connection") : null,39 querySelectorAll: () => [], createElement: tag => new Element(tag),40 addEventListener: on.bind(null, docEvents), dispatchEvent: event => fire(docEvents, event.type, event)41 },42 crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, Date: class extends Date { static now() { return clock; } }, setTimeout, clearTimeout,43 btoa: value => Buffer.from(value).toString("base64"),44 location: {pathname: "/r/work/rooms/general", href: "https://tiny.example/r/work/rooms/general?x=1#part", origin: "https://tiny.example", assign(url) { this.assigned = url; }, replace(url) { this.replaced = url; }, reload() { this.reloaded = true; }},45 navigator: {clipboard: {writeText: async () => {}}}, getComputedStyle: () => ({getPropertyValue: () => ""}),46 matchMedia: () => ({matches: false, addEventListener() {}}), addEventListener: on.bind(null, winEvents),47 localStorage: {getItem: k => local.get(k) ?? null, setItem: (k, v) => local.set(k, String(v)), removeItem: k => local.delete(k)},48 sessionStorage: {getItem: k => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: k => session.delete(k)},49 NostrSigner: {50 hexToBytes: () => new Uint8Array(32), bytesToHex: () => "key", generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b".repeat(64),51 parseBunkerInput: async () => ({pubkey: "c".repeat(64), relays: ["wss://relay.test"]}), BunkerSigner: {fromBunker: signerFactory}52 },53 fetch: async (url, options) => { requests.push({url, options}); return new Response("{}"); }54 };55 sandbox.window = sandbox;56 if (signer) sandbox.nostr = signer;57 vm.runInNewContext(shared + bridge, sandbox);58 await new Promise(resolve => setTimeout(resolve, 0));59 if (stored) { session.set("tiny.bunker/r/work.uri", "bunker://saved"); session.set("tiny.bunker/r/work.sk", "key"); session.set("tiny.bunker/r/work.identity", "c".repeat(64)); session.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: "c".repeat(64), relays: ["wss://relay.test"]})); }60 return {sandbox, nodes, docEvents, winEvents, session, local, requests, setClock: value => { clock = value; }, fire: (map, name, event) => fire(map, name, event)};61}6263const saved = (session, actor = "c".repeat(64)) => {64 session.set("tiny.bunker/r/work.uri", "bunker://saved"); session.set("tiny.bunker/r/work.sk", "key"); session.set("tiny.bunker/r/work.identity", actor); session.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: actor, relays: ["wss://relay.test"]}));65};6667test("recovery checks a late native signer without replacing window.nostr", async () => {68 const native = {getPublicKey: async () => "a".repeat(64), signEvent: async event => event};69 const {sandbox, winEvents} = await page();70 sandbox.nostr = native;71 await sandbox.tiny.reconnectSigner();72 assert.equal(sandbox.nostr, native);73 assert.equal(sandbox.tiny.signerState(), "ready");74 assert.equal(sandbox.location.replaced, undefined);75 assert.ok(winEvents.get("pageshow"));76});7778test("missing remote credentials gives a connect path and navigation restores it", async () => {79 const {sandbox, nodes, docEvents} = await page();80 await sandbox.tiny.reconnectSigner({force: true}).catch(() => {});81 assert.equal(nodes.get("signer-connection").hidden, false);82 assert.equal(nodes.get("signer-connection").children[1].textContent, "Connect signer");83 nodes.get("signer-connection").children[1].dispatch("click");84 assert.match(sandbox.location.assigned, /signin\?connect=1&next=/);85 const replacement = new Element("signer-connection"); replacement.setAttribute("actor", "a".repeat(64)); nodes.set("signer-connection", replacement);86 await Promise.all((docEvents.get("tiny:navigation") || []).map(fn => fn()));87 assert.equal(replacement.hidden, false);88});8990test("remote ping keeps the same signer and failed replacement closes both pools", async () => {91 let oldClose = 0, oldDestroy = 0, candidateClose = 0, candidateDestroy = 0, ping = 0;92 const pool = () => ({destroy: () => { oldDestroy++; }});93 const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, pool: pool(), connect: async () => {}, ping: async () => { ping++; }, close: () => { oldClose++; }, getPublicKey: async () => "c".repeat(64), signEvent: async e => e};94 const candidate = {bp: old.bp, pool: {destroy: () => { candidateDestroy++; }}, connect: async () => {}, close: () => { candidateClose++; }, getPublicKey: async () => "c".repeat(64)};95 const failedCandidate = {...candidate, connect: async () => { throw Error("offline"); }};96 const {sandbox, session, setClock} = await page({actor: "c".repeat(64), signerFactory: () => old, now: 1000});97 saved(session);98 await sandbox.tiny.reconnectSigner({force: true});99 const first = sandbox.tinySigner;100 setClock(20000);101 await sandbox.tiny.reconnectSigner();102 assert.equal(sandbox.tinySigner, first); assert.equal(ping, 1);103 // Force a replacement after ping fails.104 old.ping = async () => { throw Error("offline"); };105 sandbox.NostrSigner.BunkerSigner.fromBunker = () => failedCandidate;106 await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /offline/);107 assert.equal(sandbox.tiny.signerState(), "lost");108 assert.ok(oldClose > 0 && oldDestroy > 0 && candidateClose > 0 && candidateDestroy > 0);109 assert.equal(session.get("tiny.bunker/r/work.sk"), "key");110});111112test("signer denial stays ready while transport failure reports lost", async () => {113 let reject = false;114 const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, getPublicKey: async () => "c".repeat(64), signEvent: async () => { throw Error(reject ? "user rejected" : "connection closed"); }};115 const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => remote}); saved(session);116 await sandbox.tiny.reconnectSigner({force: true});117 await assert.rejects(sandbox.tinySigner.signEvent({}), /connection closed/); assert.equal(sandbox.tiny.signerState(), "lost");118 reject = true;119 await sandbox.tiny.reconnectSigner({force: true});120 await assert.rejects(sandbox.tinySigner.signEvent({}), /user rejected/); assert.equal(sandbox.tiny.signerState(), "ready");121});122123test("a brief background suspension forces a fresh remote health check", async () => {124 let pings = 0;125 const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, ping: async () => { pings++; }, getPublicKey: async () => "c".repeat(64)};126 const {sandbox, session, fire, docEvents, setClock} = await page({actor: "c".repeat(64), signerFactory: () => remote, now: 1000}); saved(session);127 await sandbox.tiny.reconnectSigner({force: true});128 setClock(2000);129 sandbox.document.visibilityState = "hidden";130 await fire(docEvents, "visibilitychange");131 sandbox.document.visibilityState = "visible";132 await fire(docEvents, "visibilitychange");133 await sandbox.tiny.reconnectSigner();134 assert.equal(sandbox.tiny.signerState(), "ready");135 assert.equal(pings, 1);136});137138test("successful remote replacement closes the old signer and changes the facade", async () => {139 let oldClose = 0, oldDestroy = 0;140 const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, pool: {destroy: () => { oldDestroy++; }}, close: () => { oldClose++; }, getPublicKey: async () => "c".repeat(64), ping: async () => { throw Error("offline"); }};141 const next = {bp: old.bp, connect: async () => {}, pool: {destroy() {}}, close() {}, getPublicKey: async () => "c".repeat(64)};142 const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => old}); saved(session);143 await sandbox.tiny.reconnectSigner({force: true}); const previous = sandbox.tinySigner;144 sandbox.NostrSigner.BunkerSigner.fromBunker = () => next;145 await sandbox.tiny.reconnectSigner({force: true});146 assert.notEqual(sandbox.tinySigner, previous); assert.equal(sandbox.tiny.signerState(), "ready");147 assert.ok(oldClose > 0 && oldDestroy > 0);148});149150test("an old pending signer operation cannot clear a replacement", async () => {151 let release;152 const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c".repeat(64), signEvent: async () => new Promise(resolve => { release = resolve; }), ping: async () => { throw Error("offline"); }};153 const next = {bp: old.bp, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c".repeat(64)};154 const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => old}); saved(session);155 await sandbox.tiny.reconnectSigner({force: true});156 const pending = sandbox.tinySigner.signEvent({});157 sandbox.NostrSigner.BunkerSigner.fromBunker = () => next;158 await sandbox.tiny.reconnectSigner({force: true});159 await assert.rejects(pending, /connection changed/);160 release({});161 assert.equal(sandbox.tiny.signerState(), "ready");162});163164test("native signer identity mismatch reports loss", async () => {165 const native = {getPublicKey: async () => "b".repeat(64), signEvent: async event => event};166 const {sandbox} = await page({actor: "a".repeat(64), native});167 sandbox.nostr = native;168 await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /signed-in account/);169 assert.equal(sandbox.tiny.signerState(), "lost"); assert.equal(sandbox.nostr, native);170});171172test("NIP-44 transport failure reports loss", async () => {173 const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, getPublicKey: async () => "c".repeat(64), nip44Decrypt: async () => { throw Error("connection closed"); }};174 const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => remote}); saved(session);175 await sandbox.tiny.reconnectSigner({force: true});176 await assert.rejects(sandbox.tinySigner.nip44Decrypt("peer", "ciphertext"), /connection closed/);177 assert.equal(sandbox.tiny.signerState(), "lost");178});