1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
// NIP-42 inbox relays: a NIP-17 inbox such as auth.nostr1.com (or a personal
// relay) answers a REQ for someone's gift wraps with CLOSED "auth-required"
// and shows them only after an AUTH signed by that someone; some refuse
// writes the same way. Messages read our own kind-10050 inbox that way,
// sends a DM to the peer's gated inbox after authenticating, and keeps a
// copy of what we send in our own inbox so other clients see it too.
// Both inboxes are played by this script's WebSocket shim.
// Run: bun tools/dm-nip42-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { wrapEvent } from 'nostr-tools/nip17';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const PEER_SK = generateSecretKey(); const PEER = getPublicKey(PEER_SK);
const MY_INBOX = 'wss://inbox.example.com';
const PEER_INBOX = 'wss://peer-inbox.example.com';
const now = Math.floor(Date.now() / 1000);
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5312, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument(([MY_INBOX, PEER_INBOX]) => {
window.__fx = []; window.__gated = []; window.__auths = []; window.__published = [];
const gatedHosts = [MY_INBOX, PEER_INBOX].map((u) => new URL(u).host);
const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#p'] || ev.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1])))
&& (!f.authors || f.authors.includes(ev.pubkey));
const Real = window.WebSocket;
window.WebSocket = function (...a) {
const ws = new Real(...a);
const host = new URL(a[0]).host;
const gated = gatedHosts.includes(host);
const challenge = 'ch-' + Math.random().toString(36).slice(2);
let authed = null;
const say = (m) => setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(m) })), 0);
if (gated) ws.addEventListener('open', () => say(['AUTH', challenge]));
const send = ws.send.bind(ws);
ws.send = (d) => {
try {
const m = JSON.parse(d);
if (m[0] === 'AUTH') {
const ev = m[1];
const good = ev.kind === 22242 && ev.tags.some((t) => t[0] === 'challenge' && t[1] === challenge);
window.__auths.push({ host, ev, good });
if (good) authed = ev.pubkey;
say(['OK', ev.id, good, good ? '' : 'auth-required: bad challenge']);
return;
}
if (m[0] === 'REQ') {
const fs = m.slice(2);
if (gated && !authed) { say(['CLOSED', m[1], 'auth-required: you must auth']); return; }
const pool = gated ? window.__gated.filter((g) => g.host === host).map((g) => g.ev) : window.__fx;
// a gated inbox shows wraps only to the pubkey they're for
const evs = pool.filter((ev) => fs.some((f) => fits(ev, f)) && (!gated || ev.kind !== 1059 || ev.tags.some((t) => t[0] === 'p' && t[1] === authed)));
setTimeout(() => {
for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
}, 0);
return;
}
if (m[0] === 'EVENT') {
if (gated && !authed) { say(['OK', m[1].id, false, 'auth-required: authenticate to write']); return; }
window.__published.push({ host, ev: m[1] });
say(['OK', m[1].id, true, '']);
return;
}
} catch {}
return send(d);
};
return ws;
};
window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
}, [MY_INBOX, PEER_INBOX]);
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const text = () => page.evaluate(() => document.body.innerText);
const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5312/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
await page.waitForSelector('.words .w .t');
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive', 20000);
const ME_SK = privateKeyFromSeedWords(mn); const ME = getPublicKey(ME_SK);
const base = cacheKeyFor(mn + '\n');
// our inbox list names the gated relay; the peer's names theirs
const fx = [
finalizeEvent({ kind: 10050, created_at: now - 100, tags: [['relay', MY_INBOX]], content: '' }, ME_SK),
finalizeEvent({ kind: 10050, created_at: now - 100, tags: [['relay', PEER_INBOX]], content: '' }, PEER_SK),
finalizeEvent({ kind: 0, created_at: now - 100, tags: [], content: JSON.stringify({ name: 'Gated Peer' }) }, PEER_SK),
];
// a DM that exists only on our gated inbox — sent by a client that
// delivers to 10050 alone
const ONLY = wrapEvent(PEER_SK, { publicKey: ME }, 'only on the gated inbox');
// and one to somebody else, which the inbox must never hand us
await page.evaluateOnNewDocument(([fx, only, host]) => { window.__fxSeed = fx; window.__gatedSeed = [{ host, ev: only }]; }, [fx, ONLY, new URL(MY_INBOX).host]);
await page.evaluateOnNewDocument(() => { window.__fx = window.__fxSeed; window.__gated = window.__gatedSeed; });
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click());
console.log('\n[reading our own NIP-42 inbox]');
check('the DM held only by the gated inbox shows', await waitText('only on the gated inbox', 25000));
const myAuth = (await page.evaluate(() => window.__auths)).filter((a) => a.host === new URL('wss://inbox.example.com').host);
check('...after an AUTH signed by us for that relay\'s challenge', myAuth.some((a) => a.good && a.ev.pubkey === ME && verifyEvent(a.ev) && a.ev.tags.some((t) => t[0] === 'relay' && /inbox\.example\.com/.test(t[1]))), JSON.stringify(myAuth.map((a) => [a.good, a.ev.pubkey.slice(0, 8)])));
console.log('\n[sending to a peer whose inbox wants AUTH for writes]');
await page.evaluate(() => { const r = document.querySelector('[data-key^="dm:"] .chat-preview'); r && r.click(); });
await sleep(800);
await page.waitForSelector('#msg-draft', { timeout: 8000 });
await page.type('#msg-draft', 'hello through the gate');
await page.keyboard.press('Enter');
let pub = [];
for (let i = 0; i < 40; i++) { pub = await page.evaluate(() => window.__published.filter((p) => p.ev.kind === 1059)); if (pub.some((p) => p.host.startsWith('peer-')) && pub.some((p) => p.host.startsWith('inbox.'))) break; await sleep(250); }
const peerAuth = (await page.evaluate(() => window.__auths)).filter((a) => a.host.startsWith('peer-'));
check('the wrap reaches the peer\'s gated inbox, after authenticating there', pub.some((p) => p.host.startsWith('peer-') && p.ev.tags.some((t) => t[0] === 'p' && t[1] === PEER)) && peerAuth.some((a) => a.good && a.ev.pubkey === ME), JSON.stringify(pub.map((p) => p.host)));
check('our own copy lands in our gated inbox too', pub.some((p) => p.host.startsWith('inbox.') && p.ev.tags.some((t) => t[0] === 'p' && t[1] === ME)));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ NIP-42 inbox relays read and written' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|