handle lnurl auth manually (#950)

This commit is contained in:
Blake Kaufman
2026-06-21 08:47:04 -04:00
committed by GitHub
parent 27979d5e23
commit 40bec4a276
4 changed files with 216 additions and 40 deletions
@@ -0,0 +1,107 @@
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/hashes/utils';
import {
deriveLinkingKey,
lnurlAuth,
} from '../../../app/functions/lnurl/lnurlAuth';
const MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
describe('deriveLinkingKey (LUD-04)', () => {
test('derives a stable linking key for a domain', async () => {
const node = await deriveLinkingKey(MNEMONIC, 'lnurl.example.com');
// Regression vector: changing the derivation would break existing logins.
expect(bytesToHex(secp256k1.getPublicKey(node.privateKey, true))).toBe(
'02597a2a6f8fd40d89fde61e42147cdabc86aa927edc49baac05fb212af2a0c4f3',
);
});
test('different domains yield different linking keys', async () => {
const a = await deriveLinkingKey(MNEMONIC, 'site-a.com');
const b = await deriveLinkingKey(MNEMONIC, 'site-b.com');
expect(bytesToHex(a.privateKey)).not.toBe(bytesToHex(b.privateKey));
});
});
describe('lnurlAuth (LUD-04)', () => {
const k1 = 'e2af6254a8df433264fa23f67eb8188635d15ce883e8fc020989d5f82ae6f11e';
afterEach(() => {
global.fetch.mockRestore?.();
});
test('signs k1, preserves existing params, and verifies against the sent key', async () => {
let calledUrl;
global.fetch = jest.fn(async url => {
calledUrl = new URL(url);
return { json: async () => ({ status: 'OK' }) };
});
const result = await lnurlAuth({
k1,
callback: `https://lnurl.example.com/auth?tag=login&k1=${k1}&action=login`,
mnemonic: MNEMONIC,
});
expect(result).toEqual({ status: 'OK' });
// Existing query params are preserved.
expect(calledUrl.searchParams.get('tag')).toBe('login');
expect(calledUrl.searchParams.get('action')).toBe('login');
expect(calledUrl.searchParams.get('k1')).toBe(k1);
// sig must be a valid DER signature of the raw k1 under the sent key.
const sig = calledUrl.searchParams.get('sig');
const key = calledUrl.searchParams.get('key');
expect(sig).toMatch(/^30/); // DER sequence
const verified = secp256k1.verify(
Uint8Array.from(Buffer.from(sig, 'hex')),
Uint8Array.from(Buffer.from(k1, 'hex')),
Uint8Array.from(Buffer.from(key, 'hex')),
{ prehash: false, format: 'der' },
);
expect(verified).toBe(true);
});
test('throws a flagged service rejection on ERROR status', async () => {
global.fetch = jest.fn(async () => ({
json: async () => ({ status: 'ERROR', reason: 'expired k1' }),
}));
await expect(
lnurlAuth({
k1,
callback: `https://lnurl.example.com/auth?k1=${k1}`,
mnemonic: MNEMONIC,
}),
).rejects.toMatchObject({
message: 'expired k1',
isServiceRejection: true,
});
});
test('rejects a malformed (non 32-byte hex) k1 without calling fetch', async () => {
global.fetch = jest.fn();
await expect(
lnurlAuth({
k1: 'not-hex',
callback: 'https://lnurl.example.com/auth?k1=not-hex',
mnemonic: MNEMONIC,
}),
).rejects.toThrow('Invalid k1 challenge');
expect(global.fetch).not.toHaveBeenCalled();
});
test('rejects non-HTTPS clearnet callbacks', async () => {
global.fetch = jest.fn();
await expect(
lnurlAuth({
k1,
callback: `http://lnurl.example.com/auth?k1=${k1}`,
mnemonic: MNEMONIC,
}),
).rejects.toThrow('LNURL must use HTTPS');
expect(global.fetch).not.toHaveBeenCalled();
});
});
@@ -1,53 +1,48 @@
import {
lnurlAuth,
LnUrlCallbackStatusVariant,
} from '@breeztech/react-native-breez-sdk-liquid';
import { crashlyticsLogReport } from '../../../../../functions/crashlyticsLogs';
import {
ensureLiquidConnection,
isLiquidNodeConnected,
} from '../../../../../functions/breezLiquid/liquidNodeManager';
import { lnurlAuth } from '../../../../../functions/lnurl/lnurlAuth';
export default async function processLNUrlAuth(input, context) {
const { navigate, setLoadingMessage, t, accountMnemoinc } = context;
if (!isLiquidNodeConnected()) {
console.log('Liquid node not connected, waiting for connection...');
const resposne = await ensureLiquidConnection(accountMnemoinc);
if (!resposne) throw new Error(t('errormessages.tryAgain'));
}
crashlyticsLogReport('Hanlding LURL auth');
crashlyticsLogReport('Handling LNURL auth');
setLoadingMessage(
t('wallet.sendPages.handlingAddressErrors.lnurlAuthStartMeessage'),
);
const result = await lnurlAuth(input.data);
if (result.type === LnUrlCallbackStatusVariant.OK) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
navigate.reset({
index: 0, // The top-level route index
routes: [
{
name: 'HomeAdmin', // Navigate to HomeAdmin
params: {
screen: 'Home',
},
},
{
name: 'ConfirmTxPage',
params: {
useLNURLAuth: true,
},
},
],
});
});
try {
await lnurlAuth({
k1: input.data.k1,
callback: input.data.callback,
mnemonic: accountMnemoinc,
});
} else {
} catch (err) {
console.log('LNURL auth error', err);
throw new Error(
t('wallet.sendPages.handlingAddressErrors.lnurlFailedAuthMessage'),
err.isServiceRejection
? t('wallet.sendPages.handlingAddressErrors.lnurlFailedAuthMessage')
: t('errormessages.tryAgain'),
);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
navigate.reset({
index: 0, // The top-level route index
routes: [
{
name: 'HomeAdmin', // Navigate to HomeAdmin
params: {
screen: 'Home',
},
},
{
name: 'ConfirmTxPage',
params: {
useLNURLAuth: true,
},
},
],
});
});
});
}
+73
View File
@@ -0,0 +1,73 @@
import { HDKey } from '@scure/bip32';
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha2';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/hashes/utils';
import { mnemonicToSeedAsync } from '../nostrCompatability';
import { isHTTPS } from './ishttps';
// LUD-04: derive the service-specific linking key pair for a given domain.
export async function deriveLinkingKey(mnemonic, domain) {
const seed = await mnemonicToSeedAsync(mnemonic);
const root = HDKey.fromMasterSeed(seed);
// Private hashingKey derived at m/138'/0
const hashingKey = root.derive("m/138'/0");
const mac = hmac(
sha256,
hashingKey.privateKey,
new TextEncoder().encode(domain),
);
// First 16 bytes -> four big-endian uint32 -> m/138'/<i0>/<i1>/<i2>/<i3>.
// Indices with the high bit set are hardened automatically by deriveChild.
const dv = new DataView(mac.buffer, mac.byteOffset, 16);
let node = root.derive("m/138'");
for (let i = 0; i < 4; i++) {
node = node.deriveChild(dv.getUint32(i * 4, false));
}
return node;
}
// LUD-04: sign the k1 challenge with the domain linking key and call the service.
// Resolves with the service response ({ status: 'OK' }) or throws with the reason.
export async function lnurlAuth({ k1, callback, mnemonic }) {
const callbackUrl = new URL(callback);
// FQDN of the LN SERVICE (trailing dot omitted), used as the HMAC message.
const domain = callbackUrl.hostname;
// LUD-01/LUD-17: clearnet must use HTTPS; allow HTTP only for .onion services.
if (!isHTTPS(callback) && !domain.endsWith('.onion')) {
throw new Error('LNURL must use HTTPS');
}
if (!/^[0-9a-fA-F]{64}$/.test(k1 || '')) {
throw new Error('Invalid k1 challenge');
}
const linkingKey = await deriveLinkingKey(mnemonic, domain);
// k1 is a 32-byte hex challenge that is signed as-is (no prehash).
const k1Bytes = Uint8Array.from(Buffer.from(k1, 'hex'));
const signature = secp256k1.sign(k1Bytes, linkingKey.privateKey, {
prehash: false,
});
callbackUrl.searchParams.set('sig', bytesToHex(signature.toBytes('der')));
callbackUrl.searchParams.set(
'key',
bytesToHex(secp256k1.getPublicKey(linkingKey.privateKey, true)),
);
// LUD-01: ignore HTTP status/headers, parse the JSON body.
const response = await fetch(callbackUrl.toString());
const data = await response.json();
if (data.status === 'ERROR') {
// Genuine rejection by the service (vs. a transient network failure above).
const error = new Error(data.reason || 'LNURL auth failed');
error.isServiceRejection = true;
throw error;
}
return data;
}
+1
View File
@@ -66,6 +66,7 @@ jest.mock('react-native-quick-crypto', () => {
createDecipheriv: (...args) => nodeCrypto.createDecipheriv(...args),
createHash: (...args) => nodeCrypto.createHash(...args),
createHmac: (...args) => nodeCrypto.createHmac(...args),
pbkdf2: (...args) => nodeCrypto.pbkdf2(...args),
pbkdf2Sync: (...args) => nodeCrypto.pbkdf2Sync(...args),
argon2: (_variant, opts, cb) => {
const msg =