aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/zaps.js69
-rw-r--r--src/i18n.js14
-rw-r--r--src/lnurl.js45
3 files changed, 125 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) };
+}
+