From d28b716ae791f924d7dea901b9a9711e44bd7312 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 2 Sep 2026 22:18:16 -0700 Subject: The Amethyst handshake survives its own launch: opening the signer app freezes the tab (relay sockets die as the approval publishes) and its callbackUrl then RELOADS the page, discarding the client key mid-handshake — so the pending connect now persists to sessionStorage, the wait races the live subscription against a stored-event poller kicked on every return to visibility, and a fresh boot resumes the handshake instead of shrugging. Proven both ways with a scripted signer: approval after reload, and approval stored while the page was gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr --- src/features/nostrlogin.js | 22 +++++++-- src/nostr-login.js | 114 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index b891572..ec230e5 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -14,7 +14,7 @@ import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, - walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, resumeBunker } from '../nostr-login.js'; + walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = ''; @@ -209,12 +209,12 @@ export function nostrLoginFeature(ctx) { let nc = null; let ncRun = null; let ncToken = null; // guards the async gap while the nip46 module loads - async function startNostrConnect(run) { + async function startNostrConnect(run, resume = null) { if (nc) nc.cancel(); ncRun = run; const token = (ncToken = {}); let mine; - try { mine = await nostrConnect(); } catch { + try { mine = await nostrConnect({ resume }); } catch { if (token === ncToken && ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } return; } @@ -236,6 +236,22 @@ export function nostrLoginFeature(ctx) { ncToken = null; } + // A reload mid-nostrconnect (the signer app's callbackUrl navigating the + // tab home, or Android discarding the frozen tab) used to strand the + // login: the approval lands on the relays and nobody is waiting. On boot, + // pick the persisted handshake back up — the poller finds the stored + // approval and the login finishes on its own. Deferred a beat so the boot + // restore has decided whether this tab is even logged out. + setTimeout(() => { + const pending = !nc && !wallet.mnemonic && ui.screen === 'unlock' && nostrConnectPending(); + if (!pending) return; + if (ui.onb && ['welcome', 'signin'].includes(ui.onb.step)) ui.onb = { step: 'signin' }; + ui.nostrLoginOpen = true; + ui.nostrConnectOpen = true; + startNostrConnect(loginWith, pending); + render(); + }, 400); + function nostrConnectSection(run) { if (!ui.nostrConnectOpen) { return h('button', { diff --git a/src/nostr-login.js b/src/nostr-login.js index 86c6b79..56eb195 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -172,11 +172,33 @@ function bunkerAdapter(signer, pubkey, local) { // relay — no bunker URL to copy. Returns the URI (render it as a deep link // and a QR), a promise that resolves to a signer adapter when the app // answers, and a cancel. -export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000 } = {}) { +// +// SURVIVAL: launching the signer app on a phone FREEZES this tab — its relay +// sockets die exactly when the approval is being published, and a limit:0 +// subscription never replays what it slept through. Worse, a signer's +// callbackUrl can NAVIGATE the tab home, reloading the page and discarding +// the client key mid-handshake. So the pending handshake is persisted +// (sessionStorage) and the wait runs two paths: the library's live +// subscription, raced against a stored-event poller that re-queries the +// relays (kicked on every return to visibility) — whichever sees the +// approval first wins. `nostrConnectPending()` hands a fresh boot the +// persisted handshake so a reloaded page resumes instead of shrugging. +const NC_PENDING_KEY = 'nc-pending'; +export function nostrConnectPending(timeoutMs = 180_000) { + try { + const p = JSON.parse(sessionStorage.getItem(NC_PENDING_KEY) || 'null'); + if (p && p.local && p.secret && p.uri && Date.now() - p.ts < timeoutMs) return p; + if (p) sessionStorage.removeItem(NC_PENDING_KEY); + } catch {} + return null; +} +export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000, resume = null } = {}) { const { BunkerSigner, createNostrConnectURI } = await loadNip46(); - const local = generateSecretKey(); - const secret = hex.encode(crypto.getRandomValues(new Uint8Array(16))); - const uri = createNostrConnectURI({ + const local = resume ? hex.decode(resume.local) : generateSecretKey(); + const secret = resume ? resume.secret : hex.encode(crypto.getRandomValues(new Uint8Array(16))); + const startedAt = resume ? resume.ts : Date.now(); + if (resume && resume.relays) relays = resume.relays; + const uri = resume ? resume.uri : createNostrConnectURI({ clientPubkey: getPublicKey(local), relays, secret, @@ -184,15 +206,87 @@ export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://n url: 'https://v3.coinos.io', perms: ['get_public_key', 'sign_event', 'nip44_encrypt', 'nip44_decrypt'], }) + '&callbackUrl=' + encodeURIComponent('https://v3.coinos.io/'); + try { + sessionStorage.setItem(NC_PENDING_KEY, JSON.stringify({ + local: hex.encode(local), secret, relays, uri, ts: startedAt, + })); + } catch {} + const clientPub = getPublicKey(local); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), timeoutMs); + const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), + Math.max(5_000, timeoutMs - (Date.now() - startedAt))); + + // Path 1: the library's live wait — instant while the tab stays awake. + const live = (async () => BunkerSigner.fromURI(local, uri, {}, controller.signal))(); + live.catch(() => {}); + + // Path 2: the replay poller — finds an approval published while we slept. + let visKick = null; + const onVis = () => { if (document.visibilityState === 'visible' && visKick) visKick(); }; + document.addEventListener('visibilitychange', onVis); + const replay = (async () => { + const seen = new Set(); + while (!controller.signal.aborted) { + await new Promise((r) => { + visKick = r; + const t2 = setTimeout(r, 5000); + controller.signal.addEventListener('abort', () => { clearTimeout(t2); r(); }, { once: true }); + }); + visKick = null; + if (controller.signal.aborted) break; + const pool = new SimplePool(); + try { + const evs = await Promise.any(relays.map(async (url) => { + await pool.ensureRelay(url, { connectionTimeout: 4000 }); + const got = await pool.querySync([url], { + kinds: [24133], '#p': [clientPub], since: Math.floor(startedAt / 1000) - 120, + }, { maxWait: 4500 }); + if (!got || !got.length) throw new Error('nothing yet'); + return got; + })).catch(() => []); + for (const ev of evs || []) { + if (seen.has(ev.id)) continue; + seen.add(ev.id); + try { + const key = nip44.getConversationKey(local, ev.pubkey); + const resp = JSON.parse(nip44.decrypt(ev.content, key)); + // some signers echo the secret, others answer a bare "ack" + if (resp.result === secret || resp.result === 'ack') { + return BunkerSigner.fromBunker(local, { pubkey: ev.pubkey, relays, secret }); + } + } catch {} + } + } catch {} finally { try { pool.close(relays); } catch {} } + } + throw controller.signal.reason || new Error('cancelled'); + })(); + replay.catch(() => {}); + const ready = (async () => { - const signer = await BunkerSigner.fromURI(local, uri, {}, controller.signal); - const pubkey = await signer.getPublicKey(); - return bunkerAdapter(signer, pubkey, local); + try { + const signer = await Promise.any([live, replay]); + const pubkey = await signer.getPublicKey(); + try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} + return bunkerAdapter(signer, pubkey, local); + } catch (e) { + // an aborted wait names its real cause (timeout, user cancel); a raw + // AggregateError from the race names nothing useful + throw (controller.signal.aborted && controller.signal.reason) + || (e && e.errors && e.errors[0]) || e; + } finally { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVis); + controller.abort(new Error('settled')); + } })(); - ready.finally(() => clearTimeout(timer)).catch(() => {}); - return { uri, ready, cancel: (reason) => controller.abort(reason || new Error('cancelled')) }; + ready.catch(() => {}); + return { + uri, ready, + cancel: (reason) => { + try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} + controller.abort(reason || new Error('cancelled')); + }, + }; } // A pasted key: signing happens locally, and the key is never persisted. -- cgit v1.2.3