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-unit-test.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tools/lnurl-auth-unit-test.js (limited to 'tools/lnurl-auth-unit-test.js') 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