diff options
| -rw-r--r-- | build.js | 2 | ||||
| -rw-r--r-- | names/server.js | 82 | ||||
| -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 | ||||
| -rw-r--r-- | tools/nostr-login-test.js | 17 |
7 files changed, 165 insertions, 77 deletions
@@ -181,7 +181,7 @@ export async function buildSwNwc({ minify = true } = {}) { // feature's code (and its network endpoints) never enters the bundle. // NB order matters and must match src/features/index.js — nwc sits after ark // because it drives ark's headless pay/balance seam. -const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', nostrlogin: 'nostrLoginFeature', sync: 'syncFeature' }; +const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'nostrLoginFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', sync: 'syncFeature' }; export function enabledFeatures(spec = process.env.HAL_FEATURES) { if (spec == null) return Object.keys(ALL_FEATURES); diff --git a/names/server.js b/names/server.js index a43191e..bd693b2 100644 --- a/names/server.js +++ b/names/server.js @@ -1,6 +1,8 @@ // BIP-353 name registrar for halwallet.app — gives every hal user a // ₿[email protected] payment address. // +// Auth on every write is NIP-98 (HTTP Auth, kind 27235). +// // A name is a DNSSEC-signed TXT record at // <name>.user._bitcoin-payment.halwallet.app // whose content is a BIP-21 `bitcoin:` URI (for hal users: an ark address, @@ -16,6 +18,8 @@ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { verifyEvent } from 'nostr-tools/pure'; +import { npubEncode } from 'nostr-tools/nip19'; +import { createHash } from 'node:crypto'; import { HDKey } from '@scure/bip32'; import { mnemonicToSeedSync } from '@scure/bip39'; import { lnBackend } from '../bridge/ln.js'; @@ -31,8 +35,6 @@ const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } }; const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json'); -const AUTH_KIND = 21353; // arbitrary custom kind for signed claims -const MAX_SKEW_SEC = 600; // claim events must be fresh const TTL = 300; // record TTL: BIP-353 wallets cache by this const log = (...a) => console.log(new Date().toISOString(), ...a); @@ -182,15 +184,35 @@ setInterval(async () => { // claims // --------------------------------------------------------------------------- -// The claim is a signed nostr event: proof the caller controls the wallet's -// nostr key, which becomes (or must match) the name's owner. -function checkAuth(auth) { - if (!auth || auth.kind !== AUTH_KIND) return 'bad auth kind'; - if (Math.abs(Date.now() / 1000 - (auth.created_at || 0)) > MAX_SKEW_SEC) return 'auth event too old'; - if (!verifyEvent(auth)) return 'bad signature'; - return null; +// NIP-98 HTTP Auth (kind 27235). The signature covers this exact URL, method +// and body hash, so a captured header is useless anywhere else. +async function checkNip98(req, url, bodyText) { + const h = req.headers.get('authorization') || ''; + const m = h.match(/^Nostr\s+(.+)$/i); + if (!m) return { error: 'missing Nostr authorization' }; + let evt; + try { evt = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; } + if (evt.kind !== 27235) return { error: 'auth must be kind 27235' }; + if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth event expired' }; + if (!verifyEvent(evt)) return { error: 'bad signature' }; + const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1]; + if ((tag('method') || '').toUpperCase() !== req.method) return { error: 'method mismatch' }; + let got; + try { got = new URL(tag('u') || ''); } catch { return { error: 'bad url tag' }; } + if (got.pathname !== url.pathname) return { error: 'url mismatch' }; + if (bodyText) { + const want = createHash('sha256').update(bodyText).digest('hex'); + if (tag('payload') !== want) return { error: 'payload hash mismatch' }; + } + return { pubkey: evt.pubkey }; } +// npub-prefix names are the free default everyone gets, so they must not be +// squattable: a name that looks like one may only be claimed by that very +// identity — or by a key that identity nominated as its manager (the wallet +// key, which is always available, even offline and in the service worker). +const npubPrefixOwner = (name) => (/^npub1[a-z0-9]+$/.test(name) ? name : null); + // coinos.io names that belong to existing coinos users are reserved for // them until the migration gives those users a way to claim their own. async function takenByCoinosUser(domain, name) { @@ -271,24 +293,33 @@ Bun.serve({ if (url.pathname === '/register' && req.method === 'POST') { if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); - const body = await req.json().catch(() => null); - const auth = body?.auth; - const authErr = checkAuth(auth); - if (authErr) return json({ error: authErr }, 400); + const bodyText = await req.text(); + const a = await checkNip98(req, url, bodyText); + if (a.error) return json({ error: a.error }, 401); let claim; - try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); } + try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } + const auth = { pubkey: a.pubkey }; const name = String(claim.name || '').toLowerCase(); const domain = String(claim.domain || 'halwallet.app').toLowerCase(); if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400); if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400); if (RESERVED.has(name)) return json({ error: 'name is reserved' }, 400); - if (claim.action !== 'register') return json({ error: 'wrong action' }, 400); if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400); const key = `${name}@${domain}`; const existing = state.names[key]; - if (existing && existing.pubkey !== auth.pubkey) return json({ error: 'name is taken' }, 409); + // owner OR the manager key the owner nominated may update a record + if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) { + return json({ error: 'name is taken' }, 409); + } if (!existing && await takenByCoinosUser(domain, name)) return json({ error: 'name is taken' }, 409); + // an npub-shaped name belongs to that identity alone + if (npubPrefixOwner(name)) { + const managerOk = existing && existing.manager === auth.pubkey; + if (!npubEncode(auth.pubkey).startsWith(name) && !managerOk) { + return json({ error: 'that name belongs to another Nostr identity' }, 403); + } + } if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400); // Every name also gets a static Lightning offer on our node; payments @@ -299,7 +330,11 @@ Bun.serve({ const recordId = await cfWrite(name, domain, published, existing?.recordId); state.names[key] = { - pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(), domain, + pubkey: existing?.pubkey || auth.pubkey, + // the owner may nominate a key that manages the record from here on + manager: (claim.manager && /^[0-9a-f]{64}$/.test(claim.manager) && auth.pubkey !== claim.manager) + ? claim.manager : existing?.manager, + uri: published, recordId, updated: Date.now(), domain, offerId: offer?.offerId, bolt12: offer?.bolt12, }; persist(); @@ -309,19 +344,18 @@ Bun.serve({ if (url.pathname === '/register' && req.method === 'DELETE') { if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); - const body = await req.json().catch(() => null); - const auth = body?.auth; - const authErr = checkAuth(auth); - if (authErr) return json({ error: authErr }, 400); + const bodyText = await req.text(); + const a = await checkNip98(req, url, bodyText); + if (a.error) return json({ error: a.error }, 401); let claim; - try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); } + try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } + const auth = { pubkey: a.pubkey }; const name = String(claim.name || '').toLowerCase(); const domain = String(claim.domain || 'halwallet.app').toLowerCase(); - if (claim.action !== 'delete') return json({ error: 'wrong action' }, 400); const key = `${name}@${domain}`; const existing = state.names[key]; if (!existing) return json({ error: 'unknown name' }, 404); - if (existing.pubkey !== auth.pubkey) return json({ error: 'not your name' }, 403); + if (existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) return json({ error: 'not your name' }, 403); await cfDelete(domain, existing.recordId).catch(() => {}); if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {}); delete state.names[key]; 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)); +} diff --git a/tools/nostr-login-test.js b/tools/nostr-login-test.js index 039ff84..42580fe 100644 --- a/tools/nostr-login-test.js +++ b/tools/nostr-login-test.js @@ -56,5 +56,22 @@ let denied = false; try { await stranger.decryptSelf(ct); } catch { denied = true; } check('another account cannot decrypt it', denied); +console.log('\n[NIP-98 auth]'); +{ + const { nip98Header } = await import('../src/nostr-login.js'); + const { verifyEvent } = await import('nostr-tools/pure'); + const { sha256: sha } = await import('@noble/hashes/sha256'); + const body = JSON.stringify({ name: 'x' }); + const hdr = await nip98Header(keySigner(sk), 'https://names.coinos.io/register', 'POST', body); + check('header uses the Nostr scheme', hdr.startsWith('Nostr ')); + const evt = JSON.parse(Buffer.from(hdr.slice(6), 'base64').toString('utf8')); + check('is kind 27235', evt.kind === 27235); + check('signature verifies', verifyEvent(evt)); + const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1]; + check('binds the url', tag('u') === 'https://names.coinos.io/register'); + check('binds the method', tag('method') === 'POST'); + check('binds the body hash', tag('payload') === hex.encode(sha(new TextEncoder().encode(body)))); +} + console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ nostr login behaves'); process.exit(fails ? 1 : 0); |
