// "Log in with Nostr" — open (or create) a wallet from a nostr identity, and // link an existing wallet to one after the fact. // // The mechanics and their tradeoffs live in ../nostr-login.js. In short: a // pasted key DERIVES its wallet deterministically; an extension or remote // signer ASSOCIATES one via a seed published encrypted to the user's own key. // The second is what makes "log in on a new device" work without the key // ever reaching us, and it is spelled out in the UI before anything is // published, because it means the nostr key can recover the money. 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, fetchWalletBackup, nostrConnect, resumeBunker } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = ''; export const NOSTR_MARK = OSTRICH; export function nostrLoginFeature(ctx) { const { h, ui, render, wallet, toast, copyBtn } = ctx; const load = () => wallet.loadFeatureState('nostrlogin', {}); const save = (st) => wallet.saveFeatureState('nostrlogin', st); // The signer that opened this session, kept so the names feature can claim // the user's real npub as their payment address (and nominate the wallet // key as manager, so later updates work without the signer). let live = null; let resuming = null; let resumeFailedAt = 0; // A remote signer's connection can die quietly — the app is left holding a // signer object that will never answer again, and every send after that // fails until the page is reloaded. Notice the first failure and drop it, so // the next attempt resumes a fresh connection instead. const selfHealing = (signer) => { if (!signer || signer.kind !== 'bunker') return signer; const guard = (fn) => async (...args) => { try { return await fn(...args); } catch (e) { if (live === wrapped) live = null; throw e; } }; const wrapped = { ...signer, signEvent: guard(signer.signEvent.bind(signer)), encryptSelf: guard(signer.encryptSelf.bind(signer)), decryptSelf: guard(signer.decryptSelf.bind(signer)), encryptTo: guard(signer.encryptTo.bind(signer)), decryptFrom: guard(signer.decryptFrom.bind(signer)), }; return wrapped; }; // What it takes to be this identity again after a reload. Without it the app // shows your face in the header but signs everything else with the wallet's // own key — a different pubkey, which is exactly the mismatch people notice // when their chat messages come out under a stranger's name. // // A remote signer stores its NIP-46 client key: the signer authorised that // key for this app, and it cannot sign anything on its own. // // A pasted key stores NOTHING. The UI promises it is used once and never // kept, and that promise is worth more than saving someone a paste after a // reload — they reconnect from the nostr settings card instead. const sessionOf = (signer) => { if (!signer) return {}; return { session: signer.session || null }; }; const busy = (v) => { ui.nostrLoginBusy = v; render(); }; const fail = (e) => { ui.nostrLoginError = e.message || String(e); busy(false); }; // Open the wallet a signer identifies. New accounts confirm first, because // publishing an encrypted seed to relays deserves an explicit yes. async function loginWith(makeSigner) { ui.nostrLoginError = ''; busy(true); let signer; try { signer = await makeSigner(); const res = await walletForSigner(signer); if (res.mode === 'new') { // no wallet on this identity yet — ask before publishing one ui.nostrLoginNew = { signer, npub: npubOf(signer.pubkey) }; busy(false); return; } // Same wallet however you log in: a key-derived seed is published too, // so an extension login later finds this wallet instead of making a new // one. Costs nothing — anyone with the key could derive it anyway. if (res.publish) await publishWalletBackup(signer, { mnemonic: res.mnemonic }).catch(() => {}); live = selfHealing(signer); await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), ...sessionOf(signer) }); // claim the real npub as the payment address while this signer is live ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); ctx.onbNostrLogin(res.mode === 'restored'); toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored')); busy(false); } catch (e) { fail(e); } } async function createForSigner() { const st = ui.nostrLoginNew; if (!st) return; busy(true); try { // One last look before publishing a fresh seed: if a backup surfaces // now (a relay that was unreachable a moment ago), open that wallet // instead of replacing it on the relays that can see it. const existing = await fetchWalletBackup(st.signer).catch(() => null); const mnemonic = existing ? existing.mnemonic : newMnemonic(); if (!existing) await publishWalletBackup(st.signer, { mnemonic }); ui.nostrLoginNew = null; live = selfHealing(st.signer); await ctx.openMnemonic(mnemonic, (existing && existing.passphrase) || '', { nostrPubkey: st.signer.pubkey }); save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now(), ...sessionOf(st.signer) }); ctx.hook('namesAdoptIdentity', st.signer, npubOf(st.signer.pubkey))?.catch?.(() => {}); ctx.onbNostrLogin(!!existing); toast(existing ? t('nlOpenedRestored') : t('nlCreated')); busy(false); } catch (e) { fail(e); } } // Link the wallet that's already open to a nostr identity, so logging in // with it on another device opens this same wallet. async function linkOpenWallet(makeSigner) { ui.nostrLoginError = ''; busy(true); let signer; try { signer = await makeSigner(); if (!wallet.mnemonic) throw new Error(t('nlNeedSeed')); const existing = await walletForSigner(signer); if (existing.mnemonic && existing.mnemonic !== wallet.mnemonic) { throw new Error(t('nlAlreadyLinked')); } await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); live = selfHealing(signer); save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), ...sessionOf(signer) }); ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); toast(t('nlLinked')); busy(false); } catch (e) { fail(e); } } // ---- 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; return h('div', { class: 'col', style: 'gap:8px' }, hasExt ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => run(() => extensionSigner()) }, t('nlExtension')) : null, nostrConnectSection(run), // 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'))), h('div', { class: 'row gap6' }, h('input', { type: 'password', placeholder: t('nlKeyOrBunker'), style: 'flex:1', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.nostrLoginInput || '', onInput: (e) => { ui.nostrLoginInput = e.target.value.trim(); }, }), h('button', { class: 'btn-sm', disabled: ui.nostrLoginBusy, onClick: () => { const v = (ui.nostrLoginInput || '').trim(); const sk = parseNostrSecret(v); if (sk) return run(() => keySigner(sk)); if (/^bunker:\/\//i.test(v)) { return run(() => bunkerSigner(v, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } })); } ui.nostrLoginError = t('nlUnrecognized'); render(); } }, ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlGo'))), h('div', { class: 'small faint' }, t('nlHint')), ui.nostrLoginAuthUrl ? h('a', { href: ui.nostrLoginAuthUrl, target: '_blank', class: 'small' }, t('nlApprove')) : null, ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null); } // Shown under the create/import tabs on the unlock screen. function unlockExtra() { if (ui.nostrLoginNew) { return h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('nlNewTitle')), h('div', { class: 'small muted' }, ui.nostrLoginNew.npub || ''), h('p', { class: 'small muted', style: 'margin:0' }, t('nlNewDesc')), h('div', { class: 'notice info small' }, t('nlBackupWarning')), ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { if (ui.nostrLoginNew.signer.close) ui.nostrLoginNew.signer.close(); ui.nostrLoginNew = null; render(); } }, t('cancel')), h('button', { class: 'btn-primary grow', disabled: ui.nostrLoginBusy, onClick: createForSigner }, ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlCreateBtn')))); } // Collapsed by default: one button, expanded only when asked for. if (!ui.nostrLoginOpen) { // NB .btn-block forces display:block, so the flex centering lives on an // inner wrapper rather than the button itself. return h('button', { class: 'btn-block', onClick: () => { ui.nostrLoginOpen = true; ui.nostrLoginError = ''; render(); }, }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, h('span', { style: 'display:flex;flex-shrink:0', html: OSTRICH }), t('nlSignIn'))); } return h('div', { class: 'card col', style: 'gap:10px' }, h('div', { class: 'row between' }, h('h3', { style: 'margin:0' }, t('nlTitle')), h('span', { class: 'linklike small', onClick: () => { ui.nostrLoginOpen = false; render(); } }, t('cancel'))), h('p', { class: 'small muted', style: 'margin:0' }, t('nlDesc')), signerButtons(loginWith)); } // Re-attach a signer for the account already linked here. Deliberately not // linkOpenWallet: this must not become a way to quietly swap identities, so // a different account is refused rather than linked. async function reconnectSigner(makeSigner) { ui.nostrLoginError = ''; busy(true); try { const st = load(); const signer = await makeSigner(); if (signer.pubkey !== st.pubkey) { if (signer.close) signer.close(); throw new Error(t('nlWrongAccount', { npub: (npubOf(st.pubkey) || '').slice(0, 16) + '…' })); } live = selfHealing(signer); save({ ...load(), ...sessionOf(signer) }); // No toast: the badge flips to "connected" and the reconnect controls // disappear, which says it better than a pill floating over the footer. busy(false); } catch (e) { fail(e); } } function settingsCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; const st = load(); if (st.pubkey) { return h('div', { class: 'card col' }, h('div', { class: 'row between' }, h('h3', {}, t('nlLinkTitle')), h('span', { class: 'badge dot' + (live ? ' live' : ' off') }, live ? t('nlConnected') : t('nlDisconnected'))), h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey), h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc')), // Signing needs a live signer, and a reload always drops a remote one. // Without this the app can tell you it isn't connected and offer you // nowhere to fix it. live ? null : h('div', { class: 'col', style: 'gap:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')), signerButtons(reconnectSigner))); } return h('div', { class: 'card col' }, h('h3', {}, t('nlLinkTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')), h('div', { class: 'notice info small' }, t('nlBackupWarning')), signerButtons(linkOpenWallet)); } 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() { // A just-connected signer counts immediately — the persisted link lands // only after the wallet opens, and the header avatar shouldn't show the // wallet key's face in the gap. if (live) return { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live }; const st = load(); if (!st.pubkey) return null; return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live }; }, // A page reload loses the signer. An installed extension can usually be // re-attached without prompting, which is what lets the payment address // stay tied to the user's real identity across reloads. Returns null for // signers we cannot silently reattach (pasted keys, bunkers). async nostrLoginResume() { if (live) return live; const st = load(); if (!st.pubkey) return null; // One attempt at a time, and a pause after a failure: identity() asks on // every send, and a signer that isn't answering shouldn't mean a new // relay connection per keystroke. if (resuming) return resuming; if (Date.now() - resumeFailedAt < 20_000) return null; resuming = (async () => { if (typeof window !== 'undefined' && window.nostr) { try { const s = await extensionSigner(); if (s.pubkey === st.pubkey) return (live = s); } catch {} } if (st.session) { try { const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }); if (s && s.pubkey === st.pubkey) return (live = selfHealing(s)); } catch {} } return null; })(); try { const s = await resuming; if (!s) resumeFailedAt = Date.now(); return s; } finally { resuming = null; } }, unlockExtra() { return unlockExtra(); }, nostrSettingsCards() { return [settingsCard()]; }, }; }