aboutsummaryrefslogtreecommitdiffstats
path: root/tools/amber-dm-upgrade-test.js
blob: 9a5070abc91e7fb1af743eedd71d032a71da147a (plain) (blame)
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// Signed in with Amber by intent (NIP-55), nothing sealed to the login npub
// is decrypted — each decrypt would be a trip through the app — so a thread
// showed only what that device had sent (isolabellart, 2026-10-09). Worse, those
// wraps were given up on the wallet key alone and logged as opened, so they
// stayed hidden for good. Now Messages says so and offers "Connect Amber":
// the same account over NIP-46 (nostrconnect), after which every wrap the
// relays hold is opened again — including ones the old code wrote off.
// The remote signer is played by this script over a shimmed relay.
// Run: bun tools/amber-dm-upgrade-test.js   (and ALL_LOGGED=1 bun tools/amber-dm-upgrade-test.js)
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import * as nip44 from 'nostr-tools/nip44';
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 SK = generateSecretKey(); const PK = getPublicKey(SK); // the Amber account
const PEER_SK = generateSecretKey(); const PEER = getPublicKey(PEER_SK);
const OLD = wrapEvent(PEER_SK, { publicKey: PK }, 'an old message the wrap log wrote off');
const NEW = wrapEvent(PEER_SK, { publicKey: PK }, 'a message from before the upgrade');
// ALL_LOGGED=1: every wrap already written off by the old code, so nothing
// is pending and the Amber signer is never woken (isolabellart's phone)
const ALL = process.env.ALL_LOGGED === '1';

const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5311, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p === '/nip46.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.setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36');
await page.evaluateOnNewDocument((fx) => {
  window.__amberUrls = [];
  window.__coinosAmberLaunch = (u) => { window.__amberUrls.push(u); };
  window.__published = [];
  const subs = [];
  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)) && (!f.since || ev.created_at >= f.since);
  // the test pushes the signer's answers through here, to every live sub that wants them
  window.__inject = (ev) => { for (const s of subs) if (s.fs.some((f) => fits(ev, f))) s.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', s.id, ev]) })); };
  const Real = window.WebSocket;
  window.WebSocket = function (...a) {
    const ws = new Real(...a);
    const send = ws.send.bind(ws);
    ws.send = (d) => {
      try {
        const m = JSON.parse(d);
        if (m[0] === 'REQ') {
          const fs = m.slice(2);
          for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1);
          subs.push({ ws, id: m[1], fs });
          const evs = fx.filter((ev) => fs.some((f) => fits(ev, f)));
          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] === 'CLOSE') { for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); return; }
        if (m[0] === 'EVENT') {
          window.__published.push(m[1]);
          setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
          return;
        }
      } catch {}
      return send(d);
    };
    return ws;
  };
  window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
}, [OLD, NEW]);
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; };

// The remote signer: answers the connect, then get_public_key / nip44_decrypt.
let client = null, handled = new Set(), decrypts = 0;
const signerLoop = async () => {
  const pub = await page.evaluate(() => window.__published.filter((e) => e.kind === 24133));
  for (const ev of pub) {
    if (handled.has(ev.id) || !client || ev.pubkey !== client) continue;
    handled.add(ev.id);
    const ck = nip44.getConversationKey(SK, ev.pubkey);
    const req = JSON.parse(nip44.decrypt(ev.content, ck));
    let result;
    if (req.method === 'get_public_key') result = PK;
    else if (req.method === 'nip44_decrypt') { decrypts++; result = nip44.decrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); }
    else if (req.method === 'nip44_encrypt') result = nip44.encrypt(req.params[1], nip44.getConversationKey(SK, req.params[0]));
    else if (req.method === 'sign_event') { const u = JSON.parse(req.params[0]); result = JSON.stringify(finalizeEvent(u, SK)); }
    else if (req.method === 'ping') result = 'pong';
    else if (req.method === 'connect') result = 'ack';
    const resp = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [['p', ev.pubkey]], content: nip44.encrypt(JSON.stringify({ id: req.id, result }), ck) }, SK);
    await page.evaluate((e) => window.__inject(e), resp);
  }
};

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5311/', { 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 base = cacheKeyFor(mn + '\n');
  // an Amber login, and a wrap log in which the old code had already
  // written OLD off as opened
  await page.evaluate(([k, pk, oldId, newId]) => {
    localStorage.setItem(k + ':nostrlogin', JSON.stringify({ pubkey: pk, session: { amber: pk }, via: 'amber' }));
    const m = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
    m.wraps = [oldId.slice(0, 16)].concat(newId ? [newId.slice(0, 16)] : []); m.dmHeal = 2;
    localStorage.setItem(k + ':messages', JSON.stringify(m));
  }, [base, PK, OLD.id, ALL ? NEW.id : null]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click());

  console.log('\n[signed in with Amber by intent]');
  check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect Amber')));
  await sleep(4000);
  check('no decrypt intent went to Amber for an arriving wrap', !(await page.evaluate(() => window.__amberUrls.some((u) => /nip44_decrypt/.test(u)))));
  check('neither message shows', !(await text()).includes('a message from before the upgrade') && !(await text()).includes('wrote off'));
  if (!ALL) check('the arriving wrap is not logged as opened', await page.evaluate(([k, id]) => !(JSON.parse(localStorage.getItem(k + ':messages') || '{}').wraps || []).includes(id.slice(0, 16)), [base, NEW.id]));

  console.log('\n[Connect Amber: the same account over NIP-46]');
  await click('connect amber'); await sleep(300);
  check('a screen explains it and offers the signer-app link', await waitText('read your messages', 5000));
  let uri = null;
  for (let i = 0; i < 40 && !uri; i++) { uri = await page.evaluate(() => document.querySelector('a[href^="nostrconnect://"]')?.getAttribute('href')); if (!uri) await sleep(250); }
  check('...a nostrconnect:// link asking to decrypt', !!uri && /nip44_decrypt/.test(decodeURIComponent(uri)), uri && uri.slice(0, 60));
  const u = new URL(uri.replace('nostrconnect://', 'http://'));
  client = u.host;
  const secret = u.searchParams.get('secret');
  // the signer app accepts: an answer carrying the secret
  await sleep(1000);
  const accept = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [['p', client]],
    content: nip44.encrypt(JSON.stringify({ id: 'x', result: secret }), nip44.getConversationKey(SK, client)) }, SK);
  await page.evaluate((e) => window.__inject(e), accept);
  let opened = false;
  for (let i = 0; i < 120 && !opened; i++) {
    await signerLoop();
    await page.evaluate(() => { if (!document.querySelector('.chat-log')) { const r = document.querySelector('[data-key^="dm:"]'); const c = r && r.querySelector('.chat-preview'); if (c) c.click(); } });
    opened = (await text()).includes('a message from before the upgrade') && (await text()).includes('wrote off');
    if (!opened) await sleep(500);
  }
  check('the screen closes and the notice is gone', !(await text()).includes('read your messages') && !(await text()).includes('can’t be opened here'));
  check('both messages open — the one held back and the one written off', opened, 'decrypts asked of the signer: ' + decrypts);
  const st = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':nostrlogin') || '{}'), base);
  check('the account is now a bunker session for the same npub', st.pubkey === PK && st.via === 'bunker' && !!st.session && !st.session.amber, JSON.stringify({ via: st.via }));
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ Amber users can read their messages' : '\n❌ failed');
process.exit(ok ? 0 : 1);