From 70b02f00c63ba6f6cae939a5ebdedac6e3552eb6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 2 Aug 2026 23:19:23 -0700 Subject: Nostr login: connect a signer app by deep link or QR (nostrconnect://) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-initiated NIP-46 flow: coinos mints a nostrconnect:// URI — rendered as a tappable deep link (launches Amber & friends directly, no bunker URL to copy) and a QR for cross-device scanning — then waits on the relay; when the signer app answers with the secret, login continues by itself. One live attempt at a time, cancellable, three-minute timeout, and kind 24133 joins the relay whitelist so our own relay carries the handshake. The bunker:// paste path stays for signers that prefer it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/nostrlogin.js | 54 +++++++++++++++++++++++++++++++++++++++++++--- src/i18n.js | 3 +++ src/nostr-login.js | 33 +++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index c7254d3..6b55bbf 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -11,17 +11,17 @@ import { newMnemonic } from '../wallet.js'; import { npubOf } from '../nostr.js'; import { t } from '../i18n.js'; +import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, - walletForSigner, publishWalletBackup, -} from '../nostr-login.js'; + walletForSigner, publishWalletBackup, nostrConnect} from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = ''; export function nostrLoginFeature(ctx) { - const { h, ui, render, wallet, toast } = ctx; + const { h, ui, render, wallet, toast, copyBtn } = ctx; const load = () => wallet.loadFeatureState('nostrlogin', {}); const save = (st) => wallet.saveFeatureState('nostrlogin', st); @@ -104,6 +104,52 @@ export function nostrLoginFeature(ctx) { // ---- UI --------------------------------------------------------------- + // Client-initiated remote signing: one live connect attempt at a time. The + // URI renders as a deep link (tap launches the signer app) and a QR (scan + // from another device); when the app connects, login continues by itself. + let nc = null; + let ncRun = null; + function startNostrConnect(run) { + if (nc) nc.cancel(); + ncRun = run; + nc = nostrConnect(); + const mine = nc; + mine.ready.then((signer) => { + if (nc !== mine || !ncRun) return; + ui.nostrConnectOpen = false; + ncRun(() => Promise.resolve(signer)); + nc = null; + }).catch(() => { + if (nc === mine) { nc = null; if (ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } } + }); + } + function stopNostrConnect() { + if (nc) { nc.cancel(); nc = null; } + ncRun = null; + } + + function nostrConnectSection(run) { + if (!ui.nostrConnectOpen) { + return h('button', { + class: 'btn-block', disabled: ui.nostrLoginBusy, + onClick: () => { ui.nostrConnectOpen = true; startNostrConnect(run); render(); }, + }, t('nlConnectApp')); + } + if (!nc) return null; + return h('div', { class: 'col', style: 'gap:10px;align-items:center' }, + h('a', { + class: 'btn btn-block btn-primary', href: nc.uri, + style: 'text-align:center;display:block;text-decoration:none', + }, t('nlOpenSigner')), + h('div', { html: qrSvg(nc.uri) }), + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'spinner sm' }), + h('span', { class: 'small muted' }, t('nlWaitingSigner'))), + h('div', { class: 'row gap6' }, + copyBtn(nc.uri, t('copy')), + h('button', { class: 'btn-sm', onClick: () => { stopNostrConnect(); ui.nostrConnectOpen = false; render(); } }, t('cancel')))); + } + // The three ways in, as buttons. `run` receives a signer factory. function signerButtons(run) { const hasExt = typeof window !== 'undefined' && !!window.nostr; @@ -112,6 +158,7 @@ export function nostrLoginFeature(ctx) { ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => run(() => extensionSigner()) }, t('nlExtension')) : null, + nostrConnectSection(run), h('div', { class: 'row gap6' }, h('input', { type: 'password', placeholder: t('nlKeyOrBunker'), style: 'flex:1', @@ -190,6 +237,7 @@ export function nostrLoginFeature(ctx) { return { id: 'nostrlogin', + stop() { stopNostrConnect(); }, // The nostr identity this session is logged in as, for features that // should speak as the user (the payment address defaults to this npub). nostrLoginIdentity() { diff --git a/src/i18n.js b/src/i18n.js index 268ac3d..9db02d4 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -479,6 +479,9 @@ const STR = { arkLnRefunded: 'Payment failed — your money is back in Spending.', arkLnPaidTitle: 'Paid!', nlSignIn: 'Sign in with Nostr', + nlConnectApp: '📱 Connect a signer app', + nlOpenSigner: 'Open signer app', + nlWaitingSigner: 'Waiting for your signer to connect…', nlTitle: 'Log in with Nostr', nlDesc: 'Use your Nostr account to open your wallet — on any device, with the same account.', nlExtension: 'Use browser extension', diff --git a/src/nostr-login.js b/src/nostr-login.js index 077d9a6..b142dd7 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -38,7 +38,7 @@ import { entropyToMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { getPublicKey, finalizeEvent, nip44 } from './nostr.js'; import { SimplePool } from 'nostr-tools/pool'; -import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'; +import { BunkerSigner, parseBunkerInput, createNostrConnectURI } from 'nostr-tools/nip46'; import { generateSecretKey } from 'nostr-tools/pure'; const SEED_DTAG = 'coinos:wallet:v1'; @@ -102,6 +102,10 @@ export async function bunkerSigner(uri, { onAuth } = {}) { }); await signer.connect(); const pubkey = await signer.getPublicKey(); + return bunkerAdapter(signer, pubkey); +} + +function bunkerAdapter(signer, pubkey) { return { kind: 'bunker', pubkey, label: 'remote signer', signEvent: (e) => signer.signEvent(e), @@ -113,6 +117,33 @@ export async function bunkerSigner(uri, { onAuth } = {}) { }; } +// Client-initiated NIP-46: WE mint a nostrconnect:// URI; a signer app +// (Amber, nsec.app, …) opens or scans it and connects back to us over the +// 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 function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000 } = {}) { + const local = generateSecretKey(); + const secret = hex.encode(crypto.getRandomValues(new Uint8Array(16))); + const uri = createNostrConnectURI({ + clientPubkey: getPublicKey(local), + relays, + secret, + name: 'coinos', + url: 'https://v3.coinos.io', + perms: ['get_public_key', 'sign_event', 'nip44_encrypt', 'nip44_decrypt'], + }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), timeoutMs); + const ready = (async () => { + const signer = await BunkerSigner.fromURI(local, uri, {}, controller.signal); + const pubkey = await signer.getPublicKey(); + return bunkerAdapter(signer, pubkey); + })(); + ready.finally(() => clearTimeout(timer)).catch(() => {}); + return { uri, ready, cancel: (reason) => controller.abort(reason || new Error('cancelled')) }; +} + // A pasted key: signing happens locally, and the key is never persisted. export function keySigner(sk) { const pubkey = getPublicKey(sk); -- cgit v1.2.3