diff options
| author | Adam Soltys <[email protected]> | 2026-09-27 08:29:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-27 08:29:00 -0700 |
| commit | b06bbb48e6c6c7f81a9592ab9562bb8e3042b2b6 (patch) | |
| tree | e26dd2251b60ec0f73c0aa55d8d3fc5d40ca30ad | |
| parent | 095b5601c0b02d05f40ae7f435f9630988bd212b (diff) | |
| download | coinosv3-b06bbb48e6c6c7f81a9592ab9562bb8e3042b2b6.tar.gz coinosv3-b06bbb48e6c6c7f81a9592ab9562bb8e3042b2b6.zip | |
Amber (NIP-55) signs you in on Android: one intent per request, the answer read off the clipboard or pasted.
Closes #7. A "Sign in with Amber" door on Android opens nostrsigner: intents for
get_public_key, sign_event and nip44 encrypt/decrypt, with a waiting card that
reads the clipboard when the tab returns (or takes a paste). Requests queue one
at a time — a sign-in fires the backup, relay list and follows together — and
each answer is checked for its type so a stale clipboard is never mistaken for
one. An interactive signer is not asked to decrypt DMs or private lists that
arrive on their own; each trip through Amber is a deliberate one.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
| -rw-r--r-- | src/features/messages.js | 8 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 32 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | src/nostr-login.js | 130 | ||||
| -rw-r--r-- | tools/amber-login-test.js | 93 |
5 files changed, 264 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 16bc052..06cb6dd 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2141,7 +2141,7 @@ export function messagesFeature(ctx) { dec: async (ct) => nip44.decrypt(ct, wallet.nostr.ck), }); const login = hook('nostrLoginIdentity'); - if (login && login.signer && login.signer.encryptSelf) out.push({ + if (login && login.signer && login.signer.encryptSelf && !login.signer.interactive) out.push({ pk: login.pubkey, sign: (e) => login.signer.signEvent(e), enc: (txt) => login.signer.encryptSelf(txt), @@ -2506,7 +2506,9 @@ export function messagesFeature(ctx) { const out = []; if (wallet.nostr && wallet.nostr.sk) out.push(wallet.nostr.sk); const login = hook('nostrLoginIdentity'); - if (login && login.signer && login.signer.decryptFrom) out.push(login.signer); + // an interactive signer (Amber by intent) is a trip through another app + // per call: never for a wrap that arrives on its own + if (login && login.signer && login.signer.decryptFrom && !login.signer.interactive) out.push(login.signer); return out; } // A "full-strength" attempt has every key this wallet expects: retries @@ -2515,7 +2517,7 @@ export function messagesFeature(ctx) { // sealed to the login npub. function decryptorsComplete() { const login = hook('nostrLoginIdentity'); - return !login || !!(login.signer && login.signer.decryptFrom); + return !login || !login.signer || !!login.signer.interactive || !!login.signer.decryptFrom; } // Raw-key unwraps (seed-derived / pasted nsec — the common case) run in diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index fef054b..306a12f 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -15,7 +15,8 @@ import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, - walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker } from '../nostr-login.js'; + walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker, + amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, onAmberChange } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>'; @@ -126,6 +127,7 @@ export function nostrLoginFeature(ctx) { ? ui.nostrLoginVia : signer && signer.kind === 'bunker' ? 'bunker' : signer && signer.kind === 'extension' ? 'extension' + : signer && signer.kind === 'amber' ? 'amber' : 'key'; const busy = (v) => { ui.nostrLoginBusy = v; render(); }; @@ -382,6 +384,22 @@ export function nostrLoginFeature(ctx) { // is a lazy chunk, so the two strings live here too). const GOOGLE_CENTRAL = 'https://auth.njump.me'; // flip to auth.coinos.io with pomegranate.js DEFAULT_CENTRAL const GOOGLE_LEGACY = 'https://auth.njump.me'; + // While a request is out with the signer app: what to do, and a way in for + // the answer when the browser won't read the clipboard for us. + onAmberChange(() => render()); + function amberWaitCard() { + const p = amberPending(); + if (!p) return null; + const e = ui.amberPaste || (ui.amberPaste = { text: '' }); + return h('div', { class: 'card col amber-wait', style: 'gap:8px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))), + h('div', { class: 'small muted' }, t('nlAmberPasteHint')), + h('div', { class: 'row gap6' }, + h('input', { type: 'text', class: 'grow amber-paste', placeholder: t('nlAmberPastePh'), value: e.text, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + onInput: (ev) => { e.text = ev.target.value; } }), + h('button', { class: 'btn-sm', type: 'button', onClick: () => { if (amberDeliver(e.text)) e.text = ''; else { ui.nostrLoginError = t('nlAmberBadPaste'); render(); } } }, t('nlAmberPasteBtn'))), + h('button', { class: 'btn-ghost btn-sm', type: 'button', onClick: () => amberCancel() }, t('cancel'))); + } function externalButtons(run, big = false) { const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; const pad = big ? 'padding:14px' : ''; @@ -423,6 +441,13 @@ export function nostrLoginFeature(ctx) { onClick: () => run(() => extensionSigner()) }, t('nlExtension')) : null, nostrConnectSection(run), + // On Android, the signer app straight through an intent (NIP-55): works + // with Amber offline, no bunker, no relay — one trip through the app + // per signature. + amberAvailable() + ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => run(() => amberSigner()) }, t('nlAmber')) + : null, + amberWaitCard(), // These are alternatives, not steps: without the divider the field under // the button reads as something you also have to fill in. h('div', { class: 'or-split' }, h('span', {}, t('or'))), @@ -585,6 +610,9 @@ export function nostrLoginFeature(ctx) { if (s.pubkey === st.pubkey) return (live = s); } catch {} } + // an Android signer app: the stored pubkey is the whole session, and + // coming back needs no round trip (the first signature will be one) + if (st.session && st.session.amber === st.pubkey) return (live = await amberSigner({ pubkey: st.pubkey })); if (st.session) { try { const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }); @@ -604,7 +632,7 @@ export function nostrLoginFeature(ctx) { // Which door this account came through, for the reconnect surfaces. State // saved before `via` existed: a stored session means a bunker; without one // it could be extension, key, or passkey — offer everything. - const reconnectViaOf = (st) => st.via || (st.session ? 'bunker' : 'legacy'); + const reconnectViaOf = (st) => st.via || (st.session && st.session.amber ? 'amber' : st.session ? 'bunker' : 'legacy'); // Fire-and-forget resume for render paths: heal the badge without anyone // tapping, repaint when it lands. resumeLogin's own gating keeps this from diff --git a/src/i18n.js b/src/i18n.js index bab913d..165dc6c 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -985,6 +985,12 @@ const STR = { nlSignInNostr: 'Sign in with Nostr', nlDesc: 'Use your Nostr account to open your wallet — on any device, with the same account.', nlExtension: 'Use browser extension', + nlAmber: 'Sign in with Amber (Android)', + nlAmberWait: 'Waiting for your signer app… approve there, then come back here.', + nlAmberPasteHint: 'The answer is read from the clipboard when you return. If nothing happens, paste it here:', + nlAmberPastePh: 'Paste the signer’s answer', + nlAmberPasteBtn: 'Use it', + nlAmberBadPaste: 'That doesn’t look like the answer to this request.', nlGoogle: 'Sign in with Google', nlPasskey: 'Sign in with a passkey', nlKeyOrBunker: 'nsec, private key, or bunker://…', diff --git a/src/nostr-login.js b/src/nostr-login.js index 6666ed7..085d985 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -39,6 +39,7 @@ import { wordlist } from '@scure/bip39/wordlists/english'; import { getPublicKey, finalizeEvent, nip44 } from './nostr.js'; import { SimplePool } from 'nostr-tools/pool'; import { generateSecretKey } from 'nostr-tools/pure'; +import { decode as nip19decode } from 'nostr-tools/nip19'; // NIP-46 loads lazily from dist/nip46.js (same pattern as the QR decoder): // bunker logins are rare, and the module is heavy. Tests running outside a @@ -304,6 +305,135 @@ export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://n } // A pasted key: signing happens locally, and the key is never persisted. +// ---- NIP-55: an Android signer app (Amber), by intent -------------------- +// Amber without a bunker (offline, no relays) can still sign for a web +// page: the page opens a `nostrsigner:` URL and the app answers. A web page +// cannot receive an intent result, so the answer comes back one of two +// ways — appended to a callback URL, which NAVIGATES the tab (our TWA would +// reload, losing the promise that asked), or copied to the clipboard when +// no callback is given. We take the clipboard: the tab stays alive, every +// caller's await survives, and when the user comes back here the answer is +// read off the clipboard. If the browser won't hand the clipboard over, +// the login card offers a box to paste it into (amberDeliver). Each request +// is a round trip through the app — NIP-55 is honest that this means a +// popup per signature, which is why it is a login door, not the default. +export const amberAvailable = () => typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent || ''); +let amberReq = null; // { type, ts, resolve, reject, timer } +let amberWatch = null; +const amberListeners = new Set(); +export const onAmberChange = (fn) => { amberListeners.add(fn); return () => amberListeners.delete(fn); }; +const amberChanged = () => { for (const fn of amberListeners) { try { fn(); } catch {} } }; +export const amberPending = () => (amberReq ? { type: amberReq.type, since: amberReq.ts } : null); +const HEX64 = /^[0-9a-f]{64}$/i; +// What an answer of this type looks like — anything else on the clipboard +// is somebody's grocery list, not a signature. +function amberParse(type, text, payload) { + const v = String(text || '').trim(); + if (!v || v === payload) return null; + if (type === 'get_public_key') { + if (HEX64.test(v)) return v.toLowerCase(); + if (/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(v)) { try { const d = nip19decode(v); if (d.type === 'npub') return d.data; } catch {} } + try { const j = JSON.parse(v); if (j && HEX64.test(j.pubkey || '')) return j.pubkey.toLowerCase(); } catch {} + return null; + } + if (type === 'sign_event') { + try { const ev = JSON.parse(v); return ev && ev.sig && ev.id && HEX64.test(ev.pubkey || '') ? ev : null; } catch { return null; } + } + // the clipboard still holds the LAST answer (an npub, say) when the next + // request goes out: an answer must look like its own kind, and never + // repeat the one before + if (v === amberLastAnswer) return null; + if (type === 'nip44_encrypt') return /^Ag[A-Za-z0-9+/]{100,}={0,2}$/.test(v) ? v : null; // version byte 2, nonce, body, mac + if (type === 'nip04_encrypt') return /^[A-Za-z0-9+/=]+\?iv=[A-Za-z0-9+/=]+$/.test(v) ? v : null; + if (/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(v) || HEX64.test(v)) return null; // a key is never a plaintext + return v; // a decrypt: the caller knows what it expects and will say if this isn't it +} +let amberLastAnswer = null; +// Resolve the waiting request with an answer, however it arrived. +export function amberDeliver(text) { + if (!amberReq) return false; + const got = amberParse(amberReq.type, text, amberReq.payload); + if (got == null) return false; + amberLastAnswer = String(text || '').trim(); + const r = amberReq; + amberStop(); + r.resolve(got); + return true; +} +export function amberCancel(reason = 'signer request cancelled') { + if (!amberReq) return; + const r = amberReq; + amberStop(); + r.reject(new Error(reason)); +} +function amberStop() { + if (amberReq) clearTimeout(amberReq.timer); + amberReq = null; + if (amberWatch) { amberWatch(); amberWatch = null; } + amberChanged(); +} +// Read the clipboard whenever the tab comes back to the front, and every +// so often while it is — Amber copies its answer just before the user +// switches back here. +function amberWatchClipboard() { + const read = async () => { + if (!amberReq || typeof navigator === 'undefined' || !navigator.clipboard) return; + try { // a hidden or unfocused page is simply refused by the browser + const txt = await navigator.clipboard.readText(); + if (amberReq && amberDeliver(txt)) return; + } catch {} // no permission: the paste box is the way in + }; + const onVis = () => { if (document.visibilityState === 'visible') setTimeout(read, 250); }; + document.addEventListener('visibilitychange', onVis); + window.addEventListener('focus', onVis); + const iv = setInterval(read, 800); + setTimeout(read, 300); + return () => { document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onVis); clearInterval(iv); }; +} +function amberLaunch(url) { + if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') { window.__coinosAmberLaunch(url); return; } // tests + window.location.href = url; +} +// One trip at a time: the app fires several signatures at once right after a +// sign-in (the backup, the relay list, the follows), and Amber can only be +// asked for one; the rest wait their turn in this chain. +let amberQueue = Promise.resolve(); +function amberRequest(type, payload, params = {}, { timeoutMs = 180_000 } = {}) { + const q = new URLSearchParams({ type, compressionType: 'none', returnType: 'event' }); + if (params.pubkey) q.set('pubkey', params.pubkey); + const url = 'nostrsigner:' + encodeURIComponent(payload || '') + '?' + q.toString(); + const run = () => new Promise((resolve, reject) => { + amberReq = { type, payload: payload || '', ts: Date.now(), resolve, reject, + timer: setTimeout(() => amberCancel('signer did not answer'), timeoutMs) }; + amberWatch = amberWatchClipboard(); + amberChanged(); + amberLaunch(url); + }); + const p = amberQueue.then(run, run); + amberQueue = p.catch(() => {}); + return p; +} +export async function amberSigner({ pubkey = null } = {}) { + if (!pubkey) pubkey = await amberRequest('get_public_key', ''); + return amberAdapter(pubkey); +} +function amberAdapter(pubkey) { + return { + kind: 'amber', pubkey, label: 'Amber', interactive: true, // every call is a trip through the app: nothing in the background may use it + session: { amber: pubkey }, // the pubkey is all a later boot needs; no round trip to come back + signEvent: async (e) => { + const ev = await amberRequest('sign_event', JSON.stringify({ ...e, pubkey }), { pubkey }); + if (ev.pubkey !== pubkey) throw new Error('the signer answered with a different account'); + return ev; + }, + encryptSelf: (txt) => amberRequest('nip44_encrypt', txt, { pubkey }), + decryptSelf: (ct) => amberRequest('nip44_decrypt', ct, { pubkey }), + encryptTo: (peer, txt) => amberRequest('nip44_encrypt', txt, { pubkey: peer }), + decryptFrom: (peer, ct) => amberRequest('nip44_decrypt', ct, { pubkey: peer }), + ping: async () => true, + }; +} + export function keySigner(sk) { const pubkey = getPublicKey(sk); const conv = nip44.getConversationKey(sk, pubkey); diff --git a/tools/amber-login-test.js b/tools/amber-login-test.js new file mode 100644 index 0000000..7439f0d --- /dev/null +++ b/tools/amber-login-test.js @@ -0,0 +1,93 @@ +// NIP-55: on Android, "Sign in with Amber" opens a nostrsigner: intent for the +// pubkey; the answer is read from the clipboard when the tab comes back (or +// pasted); creating the wallet then makes an encrypt and a sign round trip. +// The intent launcher and the clipboard are stubbed; relays answer nothing. +// Run: bun tools/amber-login-test.js +import puppeteer from 'puppeteer-core'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { npubEncode } from 'nostr-tools/nip19'; +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); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5308, 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.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(() => { + window.__amberUrls = []; + window.__coinosAmberLaunch = (u) => { window.__amberUrls.push(u); }; + window.__clip = ''; + Object.defineProperty(navigator, 'clipboard', { value: { readText: async () => window.__clip, writeText: async () => {} }, configurable: true }); + 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') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') { window.__published = (window.__published || []).concat([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); +}); +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 waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const urls = () => page.evaluate(() => window.__amberUrls); +const lastType = async () => { const u = await urls(); const last = u[u.length - 1] || ''; return (last.match(/[?&]type=([a-z_0-9]+)/) || [])[1] || null; }; +const deliver = (text) => page.evaluate((t) => { window.__clip = t; document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('focus')); }, text); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5308/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(500); + console.log('\n[the Amber door]'); + check('the front door offers Nostr sign-in', await click('sign in with nostr') || await click('nostr')); + await sleep(500); + check('on Android, the login offers Amber', await waitText('sign in with amber', 5000)); + await click('sign in with amber'); await sleep(400); + check('tapping it opens a get_public_key intent', (await lastType()) === 'get_public_key', JSON.stringify(await urls())); + check('...and shows the waiting card with a paste box', await waitText('waiting for your signer', 2000) && !!(await page.$('.amber-paste'))); + await deliver(npubEncode(PK)); + // a fresh account: the wallet is minted and its backup goes to the app to be encrypted + let t0 = Date.now(); while (Date.now() - t0 < 15000 && (await lastType()) !== 'nip44_encrypt') await sleep(250); + check('the pubkey read off the clipboard signs the account in; the seed goes to the app for encryption', (await lastType()) === 'nip44_encrypt', JSON.stringify((await urls()).map((u) => (u.match(/[?&]type=([a-z_0-9]+)/) || [])[1]))); + check('...and the npub left on the clipboard is not mistaken for the ciphertext', await page.evaluate(() => !!document.querySelector('.amber-paste'))); + + console.log('\n[creating the wallet: encrypt, then sign, each a round trip]'); + const encUrl = (await urls()).pop(); + check('...addressed to the user\'s own key', new URL(encUrl.replace('nostrsigner:', 'http://x/')).searchParams.get('pubkey') === PK); + // this time the clipboard is not readable: the answer is pasted + await page.evaluate(() => { navigator.clipboard.readText = async () => { throw new Error('denied'); }; }); + const fakeCipher = 'Ag' + btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(96)))).replace(/=+$/, '').replace(/[^A-Za-z0-9+/]/g, 'a'); + await page.type('.amber-paste', fakeCipher); + await click('use it'); await sleep(800); + check('the pasted ciphertext moves things on to a signature', (await lastType()) === 'sign_event', await lastType()); + const signUrl = (await urls()).pop(); + const unsigned = JSON.parse(decodeURIComponent(signUrl.slice('nostrsigner:'.length).split('?')[0])); + check('...of an event under the signed-in pubkey', unsigned.pubkey === PK && typeof unsigned.kind === 'number', JSON.stringify({ kind: unsigned.kind, pk: unsigned.pubkey.slice(0, 8) })); + const signed = finalizeEvent({ kind: unsigned.kind, created_at: unsigned.created_at, tags: unsigned.tags, content: unsigned.content }, SK); + await page.evaluate(() => { navigator.clipboard.readText = async () => window.__clip; }); + await deliver(JSON.stringify(signed)); await sleep(2500); + const pub = await page.evaluate(() => (window.__published || []).map((e) => e.kind)); + check('the signed backup is published and the new identity walks into onboarding', pub.includes(30078) && await waitText('write down your recovery phrase', 10000), 'published kinds ' + JSON.stringify(pub)); + // the rest of the first run: every further signature (relay list, follows) + // is one more trip through Amber, taken in turn, while the words screen is + // walked through + const signedKinds = []; + let signedUrls = 1; + for (let i = 0; i < 80 && !(await page.evaluate(() => document.body.innerText.toLowerCase().includes('receive'))); i++) { + const us = await urls(); + const pend = us.filter((u) => /type=sign_event/.test(u)); + if (pend.length > signedUrls) { + const u = pend[signedUrls]; signedUrls++; + const ev = JSON.parse(decodeURIComponent(u.slice('nostrsigner:'.length).split('?')[0])); + signedKinds.push(ev.kind); + await deliver(JSON.stringify(finalizeEvent({ kind: ev.kind, created_at: ev.created_at, tags: ev.tags, content: ev.content }, SK))); + } else { await click('later') || await click('skip verification') || await click("i've written it down") || await click('continue'); } + await sleep(500); + } + if (!(await page.evaluate(() => document.body.innerText.toLowerCase().includes('receive')))) console.log(' DEBUG loop end:', JSON.stringify(await page.evaluate(() => ({ btns: [...document.querySelectorAll('button')].map((b) => b.textContent.trim()).filter(Boolean).slice(0, 12), text: document.body.innerText.slice(0, 160).replace(/\n+/g, ' | ') })))); + check('each later signature (follows, relays) was its own trip, and the wallet opens on Receive', await waitText('receive', 5000) && signedKinds.length > 0, 'signed kinds after the backup ' + JSON.stringify(signedKinds)); + check('no request cancelled another', !(await page.evaluate(() => document.body.innerText)).includes('took its place')); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ Amber signs in and signs, one trip at a time' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
