tinyrelayfile
tinyrelay/scripts/signer-recovery.test.mjs/

scripts/signer-recovery.test.mjs raw
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";
6
7const 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");
9
10class 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}
24
25async 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}
62
63const 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};
66
67test("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});
77
78test("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});
89
90test("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});
111
112test("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});
122
123test("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});
137
138test("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});
149
150test("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});
163
164test("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});
171
172test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs