aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/zaps.js69
-rw-r--r--src/i18n.js14
-rw-r--r--src/lnurl.js45
-rw-r--r--tools/lnurl-auth-test.js102
-rw-r--r--tools/lnurl-auth-unit-test.js42
5 files changed, 269 insertions, 3 deletions
diff --git a/src/features/zaps.js b/src/features/zaps.js
index d327f9d..f29fe83 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -13,7 +13,9 @@
import { t } from '../i18n.js';
import { npubOf } from '../nostr.js';
-import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js';
+import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin } from '../lnurl.js';
+import { HDKey } from '@scure/bip32';
+import { mnemonicToSeedSync } from '@scure/bip39';
import { decodeNoffer } from '../noffer.js';
import { requestOfferInvoice } from '../clink.js';
import { decodeOffer } from '../bolt12.js';
@@ -290,6 +292,64 @@ export function zapsFeature(ctx) {
}
}
+ // ---- Lightning login (LNURL-auth) ----
+ // A site's login lnurl pasted (or tapped) into Send: not a payment but a
+ // challenge to sign. Ask first — it is an identity act, naming the site —
+ // then sign with the key this wallet keeps for that site alone (LUD-05)
+ // and tell the site. (Adam, 2026-10-16: a Stacker News login lnurl in Send
+ // got "no sig query variable provided" — it was being paid.)
+ function beginLogin(auth) {
+ ui.zap = null; ui.sendError = '';
+ ui.lnAuth = { auth, status: 'ask' };
+ render();
+ }
+ const masterKey = () => {
+ if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || ''));
+ if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv);
+ return null;
+ };
+ async function doLogin(a) {
+ a.status = 'busy'; render();
+ try {
+ const master = masterKey();
+ if (!master) throw new Error(t('lnAuthNoKey'));
+ await lnurlAuthLogin(a.auth, lnurlAuthKey(master, a.auth.domain));
+ if (ui.lnAuth !== a) return;
+ a.status = 'done';
+ } catch (e) {
+ if (ui.lnAuth !== a) return;
+ a.status = 'error'; a.error = e.message || String(e);
+ }
+ render();
+ }
+ function lnAuthView() {
+ const a = ui.lnAuth;
+ if (!a) return null;
+ const { domain, action } = a.auth;
+ const back = () => { ui.lnAuth = null; ui.sendError = ''; ui.send = blankSend(); render(); };
+ const heading = '🔑 ' + t('lnAuthTitle');
+ if (a.status === 'busy') {
+ return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' },
+ h('span', { class: 'spinner' }),
+ h('p', { class: 'muted', style: 'margin:0' }, t('lnAuthSigning', { domain })));
+ }
+ if (a.status === 'done') {
+ return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' },
+ h('div', { class: 'check-badge' }, '✓'),
+ h('h3', { style: 'margin:0' }, t('lnAuthDoneTitle', { domain })),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthDoneHint')),
+ h('button', { class: 'btn-primary btn-block', onClick: back }, t('done')));
+ }
+ const ask = t(action === 'register' ? 'lnAuthAskRegister' : action === 'link' ? 'lnAuthAskLink' : action === 'auth' ? 'lnAuthAskAuth' : 'lnAuthAskLogin', { domain });
+ return h('div', { class: 'card col', style: 'gap:12px' },
+ h('h3', {}, heading),
+ h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain),
+ h('p', { class: 'small muted', style: 'margin:0' }, ask),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthHow')),
+ a.status === 'error' ? h('div', { class: 'notice err' }, a.error || t('lnAuthFailed')) : null,
+ h('button', { class: 'btn-primary btn-block', onClick: () => doLogin(a) }, a.status === 'error' ? t('lnAuthRetry') : t(action === 'register' ? 'lnAuthGoRegister' : 'lnAuthGo')),
+ h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
+ }
function zapView() {
const z = ui.zap;
if (!z) return null;
@@ -391,6 +451,9 @@ export function zapsFeature(ctx) {
id: 'zaps',
// A lightning address / lnurl (always), or an npub that Ark didn't claim.
matchSendText(text, typed) {
+ // a login challenge needs no balance, only the wallet's key
+ const auth = parseLnurlAuth(text);
+ if (auth) { beginLogin(auth); return true; }
if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false;
const tap = !typed; // scanned/pasted (a tap), not a keystroke
const nof = maybeNoffer(text);
@@ -472,8 +535,8 @@ export function zapsFeature(ctx) {
begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
return true;
},
- resetSend() { ui.zap = null; },
- sendView() { return zapView(); },
+ resetSend() { ui.zap = null; ui.lnAuth = null; },
+ sendView() { return lnAuthView() || zapView(); },
// Settings → Nostr: the one-tap zap amount.
nostrSettingsCards() {
const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
diff --git a/src/i18n.js b/src/i18n.js
index 278d136..4cccefd 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1477,6 +1477,20 @@ const STR = {
destPlaceholder: 'Address or Lightning invoice',
lnQuoting: 'Getting a quote…',
lnPayTitle: 'Pay over Lightning',
+ lnAuthTitle: 'Log in with Lightning',
+ lnAuthAskLogin: '{domain} is asking you to log in with your wallet.',
+ lnAuthAskRegister: '{domain} is asking you to create an account with your wallet.',
+ lnAuthAskLink: '{domain} is asking to link your wallet to your account there.',
+ lnAuthAskAuth: '{domain} is asking you to confirm it’s you, with your wallet.',
+ lnAuthHow: 'Your wallet signs their challenge with a key it keeps for this site alone. No payment is made, and nothing else about you is shared.',
+ lnAuthGo: 'Log in',
+ lnAuthGoRegister: 'Create account',
+ lnAuthRetry: 'Try again',
+ lnAuthSigning: 'Signing in to {domain}…',
+ lnAuthDoneTitle: 'Logged in to {domain}',
+ lnAuthDoneHint: 'Go back to their page — it should have let you in.',
+ lnAuthFailed: 'They didn’t accept the login.',
+ lnAuthNoKey: 'This account has no wallet key to log in with.',
lnPayAmount: 'Amount',
lnPayBoltzFee: 'Swap fee',
lnPayNetworkFee: 'Network fee',
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'/<n1>/<n2>/<n3>/<n4>. 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) };
+}
+
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('[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');