diff options
| author | Adam Soltys <[email protected]> | 2026-10-15 15:13:12 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-15 15:13:12 -0700 |
| commit | 46829a7a54bf21138d8bb068966f7f083ac8df4c (patch) | |
| tree | 0f7abb459e1861fdc3eca876130ee160910880a9 /src/features | |
| parent | 393d4d77f03b015e2c15be19642cdd01364a3153 (diff) | |
| download | coinosv3-46829a7a54bf21138d8bb068966f7f083ac8df4c.tar.gz coinosv3-46829a7a54bf21138d8bb068966f7f083ac8df4c.zip | |
Lightning login (LNURL-auth): a site's login lnurl in Send logs you in instead of trying to pay it. A community member pasted Stacker News's lightning-login lnurl into Send and got "stacker.news: no sig query variable provided" (2026-10-16): every lnurl1 was taken for LNURL-pay, their login endpoint was fetched as if for pay parameters, and its refusal came back as the error. Now an lnurl whose URL carries tag=login and a 32-byte k1 (parseLnurlAuth; lightning: prefix and upper case fine; action register|login|link|auth) is a login: the wallet asks first — the site's name, what it is asking (log in / create an account / link / confirm it's you), that the challenge is signed with a key kept for this site alone and that nothing is paid or shared — then signs k1 (ECDSA, DER) with the LUD-05 linking key (m/138'/0 as hashing key, HMAC-SHA256 of the domain read as four 32-bit child indexes under m/138'; the same wallet is the same user on a site every time and a different one elsewhere; from the mnemonic, or an imported xprv) and calls back with sig and key (lnurlAuthKey, lnurlAuthLogin). The site's OK shows "Logged in to <site>", its reason otherwise, with Try again and Back. It takes no balance (checked before canPay in zaps' matchSendText), so it works on an empty wallet and from an Android lightning: intent. tools/lnurl-auth-unit-test.js (login vs pay vs bad k1; one key per site per wallet; the DER signature verifies under the key sent; the server's reason is the error) and tools/lnurl-auth-test.js (a stand-in site that verifies the signature: the ask, nothing sent before Log in, OK, the same key next time, a register link, a refusal's words); send-dest-test passes.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/zaps.js | 69 |
1 files changed, 66 insertions, 3 deletions
diff --git a/src/features/zaps.js b/src/features/zaps.js index d327f9d..f29fe83 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -13,7 +13,9 @@ import { t } from '../i18n.js'; import { npubOf } from '../nostr.js'; -import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js'; +import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin } from '../lnurl.js'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; import { decodeNoffer } from '../noffer.js'; import { requestOfferInvoice } from '../clink.js'; import { decodeOffer } from '../bolt12.js'; @@ -290,6 +292,64 @@ export function zapsFeature(ctx) { } } + // ---- Lightning login (LNURL-auth) ---- + // A site's login lnurl pasted (or tapped) into Send: not a payment but a + // challenge to sign. Ask first — it is an identity act, naming the site — + // then sign with the key this wallet keeps for that site alone (LUD-05) + // and tell the site. (Adam, 2026-10-16: a Stacker News login lnurl in Send + // got "no sig query variable provided" — it was being paid.) + function beginLogin(auth) { + ui.zap = null; ui.sendError = ''; + ui.lnAuth = { auth, status: 'ask' }; + render(); + } + const masterKey = () => { + if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || '')); + if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv); + return null; + }; + async function doLogin(a) { + a.status = 'busy'; render(); + try { + const master = masterKey(); + if (!master) throw new Error(t('lnAuthNoKey')); + await lnurlAuthLogin(a.auth, lnurlAuthKey(master, a.auth.domain)); + if (ui.lnAuth !== a) return; + a.status = 'done'; + } catch (e) { + if (ui.lnAuth !== a) return; + a.status = 'error'; a.error = e.message || String(e); + } + render(); + } + function lnAuthView() { + const a = ui.lnAuth; + if (!a) return null; + const { domain, action } = a.auth; + const back = () => { ui.lnAuth = null; ui.sendError = ''; ui.send = blankSend(); render(); }; + const heading = '🔑 ' + t('lnAuthTitle'); + if (a.status === 'busy') { + return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, + h('span', { class: 'spinner' }), + h('p', { class: 'muted', style: 'margin:0' }, t('lnAuthSigning', { domain }))); + } + if (a.status === 'done') { + return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' }, + h('div', { class: 'check-badge' }, '✓'), + h('h3', { style: 'margin:0' }, t('lnAuthDoneTitle', { domain })), + h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthDoneHint')), + h('button', { class: 'btn-primary btn-block', onClick: back }, t('done'))); + } + const ask = t(action === 'register' ? 'lnAuthAskRegister' : action === 'link' ? 'lnAuthAskLink' : action === 'auth' ? 'lnAuthAskAuth' : 'lnAuthAskLogin', { domain }); + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, heading), + h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain), + h('p', { class: 'small muted', style: 'margin:0' }, ask), + h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthHow')), + a.status === 'error' ? h('div', { class: 'notice err' }, a.error || t('lnAuthFailed')) : null, + h('button', { class: 'btn-primary btn-block', onClick: () => doLogin(a) }, a.status === 'error' ? t('lnAuthRetry') : t(action === 'register' ? 'lnAuthGoRegister' : 'lnAuthGo')), + h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); + } function zapView() { const z = ui.zap; if (!z) return null; @@ -391,6 +451,9 @@ export function zapsFeature(ctx) { id: 'zaps', // A lightning address / lnurl (always), or an npub that Ark didn't claim. matchSendText(text, typed) { + // a login challenge needs no balance, only the wallet's key + const auth = parseLnurlAuth(text); + if (auth) { beginLogin(auth); return true; } if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; const tap = !typed; // scanned/pasted (a tap), not a keystroke const nof = maybeNoffer(text); @@ -472,8 +535,8 @@ export function zapsFeature(ctx) { begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); return true; }, - resetSend() { ui.zap = null; }, - sendView() { return zapView(); }, + resetSend() { ui.zap = null; ui.lnAuth = null; }, + sendView() { return lnAuthView() || zapView(); }, // Settings → Nostr: the one-tap zap amount. nostrSettingsCards() { const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; |
