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/lnurl.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) (limited to 'src/lnurl.js') 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