diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/index.js | 3 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 163 |
2 files changed, 165 insertions, 1 deletions
diff --git a/src/features/index.js b/src/features/index.js index e34de3d..8f3e5d1 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -5,6 +5,7 @@ // bundle. See featureIndexSource() in build.js. import { giftsFeature } from './gifts.js'; +import { nostrLoginFeature } from './nostrlogin.js'; import { arkFeature } from './ark.js'; import { namesFeature } from './names.js'; import { zapsFeature } from './zaps.js'; @@ -19,5 +20,5 @@ import { nwcFeature } from './nwc.js'; // names sits between ark and zaps: a pasted user@domain resolves over DNS // (BIP-353) before the zaps feature treats it as a plain Lightning address. export function buildFeatures(ctx) { - return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), nostrLoginFeature(ctx), syncFeature(ctx)]; } diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js new file mode 100644 index 0000000..60e3759 --- /dev/null +++ b/src/features/nostrlogin.js @@ -0,0 +1,163 @@ +// "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 { + extensionSigner, bunkerSigner, keySigner, parseNostrSecret, + walletForSigner, publishWalletBackup, +} from '../nostr-login.js'; + +export function nostrLoginFeature(ctx) { + const { h, ui, render, wallet, toast } = ctx; + + const load = () => wallet.loadFeatureState('nostrlogin', {}); + const save = (st) => wallet.saveFeatureState('nostrlogin', st); + + 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; + } + await ctx.hook('openMnemonic', res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); + toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored')); + busy(false); + } catch (e) { fail(e); } + finally { if (signer && signer.close && !ui.nostrLoginNew) signer.close(); } + } + + async function createForSigner() { + const st = ui.nostrLoginNew; + if (!st) return; + busy(true); + try { + const mnemonic = newMnemonic(); + await publishWalletBackup(st.signer, { mnemonic }); + ui.nostrLoginNew = null; + await ctx.hook('openMnemonic', mnemonic, '', { nostrPubkey: st.signer.pubkey }); + toast(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 || '' }); + save({ ...load(), pubkey: signer.pubkey, linked: Date.now() }); + toast(t('nlLinked')); + busy(false); + } catch (e) { fail(e); } + finally { if (signer && signer.close) signer.close(); } + } + + // ---- UI --------------------------------------------------------------- + + // 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, + 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')))); + } + return h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('nlTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('nlDesc')), + signerButtons(loginWith)); + } + + function settingsCard() { + if (wallet.watchOnly || !wallet.mnemonic) return null; + const st = load(); + if (st.pubkey) { + return h('div', { class: 'card col' }, + h('h3', {}, t('nlLinkTitle')), + h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey), + h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc'))); + } + 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', + unlockExtra() { return unlockExtra(); }, + settingsCards() { return [settingsCard()]; }, + }; +} |
