diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/index.js | 2 | ||||
| -rw-r--r-- | src/features/names.js | 84 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 27 | ||||
| -rw-r--r-- | src/nostr-login.js | 28 |
4 files changed, 89 insertions, 52 deletions
diff --git a/src/features/index.js b/src/features/index.js index 8f3e5d1..70f8421 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -20,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), nostrLoginFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; } diff --git a/src/features/names.js b/src/features/names.js index 970a690..33bddd7 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -12,6 +12,7 @@ // record falls back to the Lightning-address flow (zaps feature). import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js'; +import { nip98Header } from '../nostr-login.js'; import { qrSvg } from '../qr.js'; import { isArkAddress } from './ark.js'; import { getNetwork } from '../api.js'; @@ -24,6 +25,25 @@ const AUTH_KIND = 21353; export function namesFeature(ctx) { const { h, ui, render, wallet, hook, toast, copyBtn } = ctx; + // The wallet's own key signs registrar requests: it's the one key that is + // always available — offline, in the background, with no signer attached. + const walletSigner = () => ({ + pubkey: wallet.nostrPubkey && wallet.nostrPubkey(), + signEvent: async (e) => wallet.nostrSign(e), + }); + + async function post(path, method, payload, signer) { + const url = `${REGISTRAR}${path}`; + const body = JSON.stringify(payload); + const auth = await nip98Header(signer || walletSigner(), url, method, body); + const r = await fetch(url, { + method, headers: { 'content-type': 'application/json', authorization: auth }, body, + }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } + const load = () => wallet.loadFeatureState('names', {}); const save = (st) => wallet.saveFeatureState('names', st); @@ -35,31 +55,19 @@ export function namesFeature(ctx) { return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null; } - async function claim(name) { + async function claim(name, { signer, manager } = {}) { const uri = await currentUri(); if (!uri) throw new Error(t('namesNeedArk')); - const auth = wallet.nostrSign({ - kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [], - content: JSON.stringify({ action: 'register', name, uri, domain: DOMAIN }), - }); - if (!auth) throw new Error('wallet cannot sign'); - const r = await fetch(`${REGISTRAR}/register`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ auth }), - }); - const j = await r.json(); - if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + const j = await post('/register', 'POST', { + name, uri, domain: DOMAIN, + // when the login identity claims its own npub name, it nominates the + // wallet key to keep the record updated afterwards + ...(manager ? { manager } : {}), + }, signer); const prev = load().name; save({ name, domain: DOMAIN, uri, updated: Date.now() }); if (prev && prev !== name) { - const bye = wallet.nostrSign({ - kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [], - content: JSON.stringify({ action: 'delete', name: prev, domain: DOMAIN }), - }); - fetch(`${REGISTRAR}/register`, { - method: 'DELETE', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ auth: bye }), - }).catch(() => {}); + post('/register', 'DELETE', { name: prev, domain: DOMAIN }).catch(() => {}); } return j; } @@ -67,30 +75,10 @@ export function namesFeature(ctx) { async function release() { const st = load(); if (!st.name) return; - const auth = wallet.nostrSign({ - kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [], - content: JSON.stringify({ action: 'delete', name: st.name, domain: st.domain || DOMAIN }), - }); - await fetch(`${REGISTRAR}/register`, { - method: 'DELETE', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ auth }), - }).catch(() => {}); + await post('/register', 'DELETE', { name: st.name, domain: st.domain || DOMAIN }).catch(() => {}); save({}); } - // An imported seed doesn't know its username — the registrar does. Ask by - // the wallet's nostr pubkey and adopt what it says before deciding whether - // onboarding needs to prompt. - let checked = false; // lookup completed (gate stays closed until then) - async function lookupMine() { - try { - const pk = wallet.nostrPubkey && wallet.nostrPubkey(); - if (!pk) return; - const r = await fetch(`${REGISTRAR}/pubkey/${pk}`).then((x) => x.json()); - if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN, uri: r.uri }); - } catch {} - } - // Everyone gets an address without being asked: the default username is a // prefix of the wallet's npub — unique, boring, and free. Custom names are // an optional change (and may cost something one day, to keep squatters @@ -101,8 +89,18 @@ export function namesFeature(ctx) { let st = load(); if (!st.name) { await lookupMine(); st = load(); } if (!st.name) { - const npub = wallet.nostrNpub && wallet.nostrNpub(); - if (npub) { await claim(npub.slice(0, 20)); st = load(); } + // The address people see should be the identity they know the user + // by: their real npub when a nostr account is linked, else the + // wallet's own. Both are claimed the same way; the registrar checks + // that an npub-shaped name really belongs to its claimant. + const linked = hook('nostrLoginIdentity'); + const npub = (linked && linked.npub) || (wallet.nostrNpub && wallet.nostrNpub()); + if (npub) { + await claim(npub.slice(0, 20), linked + ? { signer: linked.signer, manager: wallet.nostrPubkey() } + : {}); + st = load(); + } } checked = true; render(); diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 60e3759..c5369e9 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -22,6 +22,11 @@ export function nostrLoginFeature(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; + const busy = (v) => { ui.nostrLoginBusy = v; render(); }; const fail = (e) => { ui.nostrLoginError = e.message || String(e); busy(false); }; @@ -40,11 +45,16 @@ export function nostrLoginFeature(ctx) { busy(false); return; } - await ctx.hook('openMnemonic', res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); + // 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 = signer; + await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); + save({ ...load(), pubkey: signer.pubkey, linked: Date.now() }); 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() { @@ -55,7 +65,9 @@ export function nostrLoginFeature(ctx) { const mnemonic = newMnemonic(); await publishWalletBackup(st.signer, { mnemonic }); ui.nostrLoginNew = null; - await ctx.hook('openMnemonic', mnemonic, '', { nostrPubkey: st.signer.pubkey }); + live = st.signer; + await ctx.openMnemonic(mnemonic, '', { nostrPubkey: st.signer.pubkey }); + save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now() }); toast(t('nlCreated')); busy(false); } catch (e) { fail(e); } @@ -75,11 +87,11 @@ export function nostrLoginFeature(ctx) { throw new Error(t('nlAlreadyLinked')); } await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); + live = signer; 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 --------------------------------------------------------------- @@ -157,6 +169,13 @@ export function nostrLoginFeature(ctx) { return { id: 'nostrlogin', + // 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() { + const st = load(); + if (!st.pubkey) return null; + return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live }; + }, unlockExtra() { return unlockExtra(); }, settingsCards() { return [settingsCard()]; }, }; diff --git a/src/nostr-login.js b/src/nostr-login.js index 6f23e2a..00106c4 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -150,12 +150,32 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase }, rela // The whole login: given a signer, produce the wallet mnemonic to open. // `mode` reports what happened so the UI can be honest about it. +// +// ONE flow for all three signer kinds, because an identity must open the +// SAME wallet however you log into it: look for the published association +// FIRST (that's where the money already is), and only then fall back to +// deriving (key in hand) or generating (signer only). export async function walletForSigner(signer) { - // A pasted key derives its wallet — no lookup, no storage, no exposure. - if (signer.secret) { - return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived' }; - } const found = await fetchWalletBackup(signer); if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, mode: 'restored' }; + if (signer.secret) { + // Publishing the derived seed costs nothing: the derivation is public, + // so anyone holding this key could compute it anyway. It buys the thing + // that matters — an extension login later finds this same wallet. + return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived', publish: true }; + } return { mnemonic: null, mode: 'new' }; // caller decides + confirms publishing } + +// NIP-98 HTTP Auth (kind 27235): the standard way to authenticate an HTTP +// request with a nostr key. `u` and `method` bind the signature to this exact +// request and `payload` to its body, so a captured header can't be replayed +// against a different endpoint or a tampered body. +export async function nip98Header(signer, url, method, bodyText) { + const tags = [['u', url], ['method', method.toUpperCase()]]; + if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]); + const evt = await signer.signEvent({ + kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '', + }); + return 'Nostr ' + btoa(JSON.stringify(evt)); +} |
