diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 23:54:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 23:54:43 -0700 |
| commit | a70f34f2a5d35352f99bae8f10cbab12637e08ee (patch) | |
| tree | 7a5b83fd62d0737f4b856b04c2405ba440c39e63 /tools | |
| parent | 3d6bf81a19ca080d3710893b1e65f5aa3f01315f (diff) | |
| download | coinosv3-a70f34f2a5d35352f99bae8f10cbab12637e08ee.tar.gz coinosv3-a70f34f2a5d35352f99bae8f10cbab12637e08ee.zip | |
Messages: Amber users can read their DMs. Signed in with Amber by intent (NIP-55), nothing sealed to the login npub was ever decrypted — each decrypt would be a trip through the app — so isolabellart's thread with Adam showed only what that phone had sent. Worse, decryptorsComplete() counted the Amber signer as complete, so those wraps were tried on the wallet key alone and logged as opened, hidden for good. Now an interactive signer counts as incomplete (wraps wait, uncounted); Messages and the DM thread say "messages sent to you can't be opened here" with Connect Amber, which opens a nostrconnect:// link for the SAME account over NIP-46 (Amber's bunker mode; a different npub is refused, a callbackUrl reload resumes the handshake). Once a signer that can decrypt takes over, a wallet that ran with Amber re-runs the heal pass over every wrap the relays hold, so messages the old code wrote off come back. tools/amber-dm-upgrade-test.js (plays the remote signer over a shimmed relay).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/amber-dm-upgrade-test.js | 154 |
1 files changed, 154 insertions, 0 deletions
diff --git a/tools/amber-dm-upgrade-test.js b/tools/amber-dm-upgrade-test.js new file mode 100644 index 0000000..4e34a94 --- /dev/null +++ b/tools/amber-dm-upgrade-test.js @@ -0,0 +1,154 @@ +// 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 +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'); + +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]) => { + 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)]; m.dmHeal = 2; + localStorage.setItem(k + ':messages', JSON.stringify(m)); + }, [base, PK, OLD.id]); + 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')); + 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); |
