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 --- tools/lnurl-auth-test.js | 102 ++++++++++++++++++++++++++++++++++++++++++ tools/lnurl-auth-unit-test.js | 42 +++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tools/lnurl-auth-test.js create mode 100644 tools/lnurl-auth-unit-test.js (limited to 'tools') diff --git a/tools/lnurl-auth-test.js b/tools/lnurl-auth-test.js new file mode 100644 index 0000000..767e85c --- /dev/null +++ b/tools/lnurl-auth-test.js @@ -0,0 +1,102 @@ +// A site's login lnurl (LNURL-auth) pasted into Send is a login, not a +// payment: the wallet asks first, naming the site, then signs the challenge +// and tells the site; the site's refusal is shown as its own words. Against +// a stand-in login server that checks the signature the way a real one does. +// Run: bun tools/lnurl-auth-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hexToBytes } from '@noble/hashes/utils'; +import { encodeLnurl } from '../src/lnurl.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const html = await buildHtml({ minify: true, pwa: false }); +const logins = []; // what the stand-in site was told +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url), p = u.pathname; + if (p === '/lnauth') { + const sig = u.searchParams.get('sig'), key = u.searchParams.get('key'), k1 = u.searchParams.get('k1'); + if (!sig) return Response.json({ status: 'ERROR', reason: 'no sig query variable provided' }); + let good = false; + try { good = secp256k1.verify(secp256k1.Signature.fromDER(sig), hexToBytes(k1), hexToBytes(key)); } catch {} + logins.push({ k1, key, good }); + return Response.json(good ? { status: 'OK' } : { status: 'ERROR', reason: 'bad signature' }); + } + if (p === '/lnauth-closed') return Response.json({ status: 'ERROR', reason: 'registrations are closed' }); + if (p.startsWith('/punks') || p === '/verify-worker.js' || p.startsWith('/locales') || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const k1 = 'c0ffee'.repeat(10) + 'abcd'; +const loginLnurl = encodeLnurl(server.url.origin + '/lnauth?tag=login&k1=' + k1 + '&action=login'); +const closedLnurl = encodeLnurl(server.url.origin + '/lnauth-closed?tag=login&k1=' + k1 + '&action=register'); +const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const ctx = browser.defaultBrowserContext(); +await ctx.overridePermissions(server.url.origin, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); +const page = await browser.newPage(); +const errs = []; page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === x || n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 8000) => { for (let i = 0; i < ms / 200; i++) { if ((await text()).includes(x)) return true; await sleep(200); } return false; }; +const freshSend = async () => { + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Receive')?.click()); await sleep(300); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Send')?.click()); + await page.waitForSelector('textarea.send-dest', { timeout: 10000 }); + await page.$eval('textarea.send-dest', (e) => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await sleep(300); + await page.focus('textarea.send-dest'); +}; +const paste = async (s) => { await page.evaluate((v) => navigator.clipboard.writeText(v), s); await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400); + await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet'); + for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) { + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /^(not now|skip|continue|later)$/i.test(b.textContent.trim()))?.click()); + await sleep(400); + } + await sleep(3000); + const host = new URL(server.url.origin).hostname; + + console.log('[a login lnurl pasted into Send]'); + await freshSend(); + await paste(loginLnurl); + check('the wallet asks first, naming the site', await waitText('Log in with Lightning') && (await text()).includes(host + ' is asking you to log in'), (await text()).slice(0, 200)); + check('…says what it is about to do, and that nothing is paid', /key it keeps for this site alone/.test(await text()) && /No payment is made/.test(await text())); + check('…no amount field, no payment flow', !(await page.$('input[type=number]'))); + check('nothing was sent to the site before the user agreed', logins.length === 0); + await click('log in'); + check('the site accepts the signed challenge', await waitText('Logged in to ' + host, 8000), (await text()).slice(0, 300)); + check('…it was told once, with a signature that verifies under the key', logins.length === 1 && logins[0].good && logins[0].k1 === k1, JSON.stringify(logins)); + const key1 = logins[0].key; + await click('done'); + check('Done returns to a blank Send form', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false) && (await page.$eval('textarea.send-dest', (e) => e.value)) === ''); + + console.log('[the same site again: the same key]'); + await freshSend(); + await paste(loginLnurl); + await waitText('Log in with Lightning'); await click('log in'); + await waitText('Logged in to ' + host, 8000); + check('the wallet is the same user on the site next time', logins.length === 2 && logins[1].key === key1, JSON.stringify(logins.map((l) => l.key.slice(0, 12)))); + await click('done'); + + console.log('[a site that says no]'); + await freshSend(); + await paste(closedLnurl); + check('a register link asks to create an account', await waitText('asking you to create an account')); + await click('create account'); + check("the site's reason is shown", await waitText('registrations are closed', 8000), (await text()).slice(0, 300)); + check('…with a way to try again, and back', (await text()).includes('Try again') && (await text()).includes('Back')); + await click('back'); + check('Back leaves the login', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false)); + check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300)); +} catch (e) { + check('run completed', false, e.stack); +} finally { + await browser.close(); server.stop(true); +} +console.log(ok ? '\n✅ a login lnurl logs in' : '\n❌ failures above'); +process.exit(ok ? 0 : 1); diff --git a/tools/lnurl-auth-unit-test.js b/tools/lnurl-auth-unit-test.js new file mode 100644 index 0000000..0707a8e --- /dev/null +++ b/tools/lnurl-auth-unit-test.js @@ -0,0 +1,42 @@ +// LNURL-auth (LUD-04/05) without a browser: a login lnurl is told from a +// pay one; the linking key is the same for a domain every time and different +// per domain; the signature over k1 verifies under the key sent. +// Run: bun tools/lnurl-auth-unit-test.js +import assert from 'node:assert/strict'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { encodeLnurl, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin } from '../src/lnurl.js'; +const k1 = 'a'.repeat(64); +const loginUrl = 'https://stacker.news/api/lnauth?tag=login&k1=' + k1 + '&action=login'; +const a = parseLnurlAuth(encodeLnurl(loginUrl)); +assert.deepEqual(a, { url: loginUrl, domain: 'stacker.news', k1, action: 'login' }); +assert.equal(parseLnurlAuth('lightning:' + encodeLnurl(loginUrl).toUpperCase()).domain, 'stacker.news', 'a lightning: prefix and upper case are fine'); +assert.equal(parseLnurlAuth(encodeLnurl('https://stacker.news/api/lnauth?tag=login&k1=zz')), null, 'a k1 that is not 32 bytes is not a login'); +assert.equal(parseLnurlAuth(encodeLnurl('https://coinos.io/.well-known/lnurlp/adam')), null, 'a pay lnurl is not a login'); +assert.equal(parseLnurlAuth('adam@coinos.io'), null); +assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=register'))).action, 'register'); +assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=what'))).action, 'login', 'an unknown action is a login'); +const master = HDKey.fromMasterSeed(mnemonicToSeedSync('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about')); +const k = lnurlAuthKey(master, 'stacker.news'); +assert.equal(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'stacker.news').publicKey), 'the same key for a domain every time'); +assert.notEqual(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'coinos.io').publicKey), 'another domain, another key'); +assert.equal(k.publicKey.length, 33, 'a compressed public key'); +assert.notEqual(bytesToHex(lnurlAuthKey(HDKey.fromMasterSeed(mnemonicToSeedSync('zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong')), 'stacker.news').publicKey), bytesToHex(k.publicKey), 'another wallet, another user'); +// the callback, with a server that checks the signature the way theirs does +let got = null; +globalThis.fetch = async (url) => { + const u = new URL(url); + got = { sig: u.searchParams.get('sig'), key: u.searchParams.get('key'), k1: u.searchParams.get('k1'), tag: u.searchParams.get('tag') }; + const okSig = secp256k1.verify(secp256k1.Signature.fromDER(got.sig), hexToBytes(got.k1), hexToBytes(got.key)); + return new Response(JSON.stringify(okSig ? { status: 'OK' } : { status: 'ERROR', reason: 'bad sig' }), { headers: { 'content-type': 'application/json' } }); +}; +const r = await lnurlAuthLogin(a, k); +assert.equal(got.tag, 'login'); assert.equal(got.k1, k1); +assert.equal(got.key, bytesToHex(k.publicKey), 'the key sent is the linking key'); +assert.equal(r.key, got.key); +assert.match(got.sig, /^30[0-9a-f]+$/, 'a DER signature'); +globalThis.fetch = async () => new Response(JSON.stringify({ status: 'ERROR', reason: 'no such user' }), { headers: { 'content-type': 'application/json' } }); +await assert.rejects(lnurlAuthLogin(a, k), /no such user/, "the server's reason is the error"); +console.log('✓ lnurl-auth: a login is told from a payment, one key per site per wallet, the signature verifies, the server has the last word'); -- cgit v1.2.3