handle lnurl auth manually (#950)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user