aboutsummaryrefslogtreecommitdiffstats
path: root/tools/lnurl-auth-unit-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/lnurl-auth-unit-test.js')
-rw-r--r--tools/lnurl-auth-unit-test.js63
1 files changed, 63 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..c9989be
--- /dev/null
+++ b/tools/lnurl-auth-unit-test.js
@@ -0,0 +1,63 @@
+// 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');
+
+// ---- LNURL-withdraw (LUD-03) ----
+const { parseZapTarget, withdrawParamsOf, fetchWithdrawParams, submitWithdraw, fetchPayParams } = await import('../src/lnurl.js');
+const w = parseZapTarget('lnurlw://pay.example.com/withdraw?k=1');
+assert.equal(w.url, 'https://pay.example.com/withdraw?k=1', 'lnurlw:// is the URL over https');
+assert.equal(parseZapTarget('LIGHTNING:lnurlp://pay.example.com/.well-known/lnurlp/bob').url, 'https://pay.example.com/.well-known/lnurlp/bob');
+assert.equal(parseZapTarget('lnurlw://abc.onion/w').url, 'http://abc.onion/w', 'an onion over http');
+const terms = { tag: 'withdrawRequest', callback: 'https://pay.example.com/cb', k1: 'k1k1', minWithdrawable: 1000, maxWithdrawable: 50000, defaultDescription: 'faucet' };
+assert.deepEqual(withdrawParamsOf(terms), { callback: 'https://pay.example.com/cb', k1: 'k1k1', minMsat: 1000, maxMsat: 50000, description: 'faucet', domain: 'pay.example.com' });
+assert.equal(withdrawParamsOf({ tag: 'payRequest', callback: 'x' }), null);
+assert.equal(withdrawParamsOf({ ...terms, maxWithdrawable: 0 }), null, 'nothing to withdraw is no withdraw');
+globalThis.fetch = async () => Response.json(terms);
+assert.deepEqual(await fetchWithdrawParams('https://pay.example.com/w'), withdrawParamsOf(terms));
+await assert.rejects(fetchPayParams('https://pay.example.com/w'), (e) => e.withdraw && e.withdraw.k1 === 'k1k1', 'a pay fetch that finds a withdraw says so');
+let cb = null;
+globalThis.fetch = async (url) => { const u = new URL(url); cb = { path: u.pathname, k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }; return Response.json({ status: 'OK' }); };
+assert.equal(await submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), true);
+assert.deepEqual(cb, { path: '/cb', k1: 'k1k1', pr: 'lnbc1invoice' }, 'the callback gets k1 and the invoice');
+globalThis.fetch = async () => Response.json({ status: 'ERROR', reason: 'already claimed' });
+await assert.rejects(submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), /already claimed/);
+console.log('✓ lnurl-withdraw: lnurlw:// and lnurlp:// schemes, the terms, a pay fetch that finds a withdraw, the callback, the refusal');