diff options
Diffstat (limited to 'src/lnurl.js')
| -rw-r--r-- | src/lnurl.js | 79 |
1 files changed, 79 insertions, 0 deletions
diff --git a/src/lnurl.js b/src/lnurl.js index 3f1b39b..bfebf0d 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(); @@ -43,6 +47,9 @@ export function lnAddressToUrl(addr) { export function parseZapTarget(input, { pk = null } = {}) { const s = (input || '').trim().replace(/^lightning:/i, ''); if (!s) return null; + // LUD-17: lnurlp:// (pay) and lnurlw:// (withdraw) are the URL itself over https (http on an onion) + const scheme = /^(lnurlp|lnurlw):\/\/(.+)$/i.exec(s); + if (scheme) { const url = (/^[^/]+\.onion(\/|$)/i.test(scheme[2]) ? 'http://' : 'https://') + scheme[2]; return { kind: 'lnurl', url, lnurlBech32: safeEncode(url), pk }; } // An npub/hex pubkey — the caller resolves lud16/lud06 from the profile and // calls fromProfile() below; here we just report the shape. const npk = parseNostrPubkey(s); @@ -92,10 +99,14 @@ async function fetchJson(url, ms = 8000) { } // GET the LNURL-pay params. Normalizes the bits we use; keeps `callback`. +// An lnurl that turns out to be a withdraw (LUD-03) is not an error here: +// the thrown error carries its terms (`withdraw`) for the caller to take up. export async function fetchPayParams(url) { const d = await fetchJson(url); if (!d || d.tag !== 'payRequest' || !d.callback) { if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error'); + const w = withdrawParamsOf(d); + if (w) { const e = new Error('a Lightning withdraw, not a payment'); e.withdraw = w; throw e; } throw new Error('not a Lightning pay endpoint'); } return { @@ -134,3 +145,71 @@ 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) }; +} + +// ---- LNURL-withdraw (LUD-03): the other side asks to PAY us ---- +// Their terms: an amount range (msat), a callback and k1. We make an invoice +// for the amount chosen and hand it to the callback; they pay it. +export function withdrawParamsOf(d) { + if (!d || d.tag !== 'withdrawRequest' || !d.callback || !d.k1) return null; + const min = Math.max(0, Number(d.minWithdrawable) || 0), max = Number(d.maxWithdrawable) || 0; + if (!(max > 0)) return null; + let domain = ''; + try { domain = new URL(d.callback).hostname; } catch {} + return { callback: d.callback, k1: String(d.k1), minMsat: min, maxMsat: Math.max(min, max), description: String(d.defaultDescription || '').slice(0, 200), domain }; +} +export async function fetchWithdrawParams(url) { + const d = await fetchJson(url); + const w = withdrawParamsOf(d); + if (!w) { if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error'); throw new Error('not a Lightning withdraw'); } + return w; +} +// Hand them the invoice to pay. Their OK means they have it, not that it is paid. +export async function submitWithdraw(params, invoice) { + const url = new URL(params.callback); + url.searchParams.set('k1', params.k1); + url.searchParams.set('pr', invoice); + const d = await fetchJson(url.href); + if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'withdraw refused'); + return true; +} + |
