From 46829a7a54bf21138d8bb068966f7f083ac8df4c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 15 Oct 2026 15:13:12 -0700 Subject: 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 ", 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/zaps.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++--- src/i18n.js | 14 +++++++++++ src/lnurl.js | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) (limited to 'src') 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; diff --git a/src/i18n.js b/src/i18n.js index 278d136..4cccefd 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1477,6 +1477,20 @@ const STR = { destPlaceholder: 'Address or Lightning invoice', lnQuoting: 'Getting a quote…', lnPayTitle: 'Pay over Lightning', + lnAuthTitle: 'Log in with Lightning', + lnAuthAskLogin: '{domain} is asking you to log in with your wallet.', + lnAuthAskRegister: '{domain} is asking you to create an account with your wallet.', + lnAuthAskLink: '{domain} is asking to link your wallet to your account there.', + lnAuthAskAuth: '{domain} is asking you to confirm it’s you, with your wallet.', + lnAuthHow: 'Your wallet signs their challenge with a key it keeps for this site alone. No payment is made, and nothing else about you is shared.', + lnAuthGo: 'Log in', + lnAuthGoRegister: 'Create account', + lnAuthRetry: 'Try again', + lnAuthSigning: 'Signing in to {domain}…', + lnAuthDoneTitle: 'Logged in to {domain}', + lnAuthDoneHint: 'Go back to their page — it should have let you in.', + lnAuthFailed: 'They didn’t accept the login.', + lnAuthNoKey: 'This account has no wallet key to log in with.', lnPayAmount: 'Amount', lnPayBoltzFee: 'Swap fee', lnPayNetworkFee: 'Network fee', diff --git a/src/lnurl.js b/src/lnurl.js index 3f1b39b..cf851b5 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -11,6 +11,10 @@ // that broadcasts the receipt; we only sign the request. import { bech32 } from '@scure/base'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { parseNostrPubkey } from './nostr.js'; const enc = new TextEncoder(); @@ -134,3 +138,44 @@ export async function requestInvoice(params, { amountMsat, zapRequest = null, ln if (!d.pr || typeof d.pr !== 'string') throw new Error('no invoice returned'); return d.pr; } + +// ---- LNURL-auth (LUD-04): a login challenge, not a payment ---- +// An lnurl whose URL carries tag=login and a 32-byte k1. Pasted into Send +// and taken for a payment, their server answered "no sig query variable +// provided" (Stacker News, 2026-10-16). action: login | register | link | auth. +export function parseLnurlAuth(input) { + const s = (input || '').trim().replace(/^lightning:/i, ''); + if (!/^lnurl1[ac-hj-np-z02-9]+$/i.test(s)) return null; + let url; + try { url = new URL(decodeLnurl(s)); } catch { return null; } + if (url.searchParams.get('tag') !== 'login') return null; + const k1 = (url.searchParams.get('k1') || '').toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(k1)) return null; + const action = /^(register|login|link|auth)$/.test(url.searchParams.get('action') || '') ? url.searchParams.get('action') : 'login'; + return { url: url.href, domain: url.hostname, k1, action }; +} +// The linking key for a domain (LUD-05): from the wallet's master key, +// m/138'/0 is the hashing key; its HMAC-SHA256 of the domain, read as four +// 32-bit numbers, names the path m/138'////. The same +// wallet is the same user on a site, every time, and a different one on +// every other site. +export function lnurlAuthKey(master, domain) { + const hashing = master.derive("m/138'/0").privateKey; + const mac = hmac(sha256, hashing, new TextEncoder().encode(domain)); + const dv = new DataView(mac.buffer, mac.byteOffset, 16); + let key = master.derive("m/138'"); + for (const o of [0, 4, 8, 12]) key = key.deriveChild(dv.getUint32(o)); + return key; +} +// Sign the challenge (ECDSA over k1, DER-encoded) and call the URL back with +// sig and key. Resolves on the server's OK; throws its reason otherwise. +export async function lnurlAuthLogin(auth, key) { + const sig = secp256k1.sign(hexToBytes(auth.k1), key.privateKey); + const url = new URL(auth.url); + url.searchParams.set('sig', sig.toDERHex()); + url.searchParams.set('key', bytesToHex(key.publicKey)); + const d = await fetchJson(url.href); + if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'login refused'); + return { key: bytesToHex(key.publicKey) }; +} + -- cgit v1.2.3