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 /tools/lnurl-auth-unit-test.js | |
| 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 'tools/lnurl-auth-unit-test.js')
| -rw-r--r-- | tools/lnurl-auth-unit-test.js | 42 |
1 files changed, 42 insertions, 0 deletions
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('[email protected]'), 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'); |
