diff --git a/src/extension/background-script/actions/lnurl/__tests__/auth.test.ts b/src/extension/background-script/actions/lnurl/__tests__/auth.test.ts index 6adbdc988f..8832b92524 100644 --- a/src/extension/background-script/actions/lnurl/__tests__/auth.test.ts +++ b/src/extension/background-script/actions/lnurl/__tests__/auth.test.ts @@ -1,39 +1,93 @@ import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings"; +import Mnemonic from "~/extension/background-script/mnemonic"; import state from "~/extension/background-script/state"; +import { btcFixture } from "~/fixtures/btc"; import type { LNURLDetails } from "~/types"; -import { authFunction } from "../auth"; +import { authFunction, getPathSuffix } from "../auth"; +let fetchedUrl: string; jest.mock("~/extension/background-script/state"); +jest.mock("axios", () => ({ + get: (requestUrl: string) => { + fetchedUrl = requestUrl; + return { data: { status: "OK" } }; + }, +})); -const mockState = { - settings: mockSettings, - getConnector: () => ({ - signMessage: () => - Promise.resolve({ - data: { - signature: - "rnu5pnhanjs3bfxz33fuyf9ywzrmkm1ns6jxdraxff1irq3hpxcbkce6zk34ee9bh7bamgd891tfy4gq1y119w53qg1ap5zodwi4u51n", - }, +const passwordMock = jest.fn; + +describe("auth with mnemonic", () => { + test("getPathSuffix matches test vector", () => { + expect( + getPathSuffix( + "site.com", + "7d417a6a5e9a6a4a879aeaba11a11838764c8fa2b959c242d43dea682b3e409b" + ) + ).toStrictEqual([1588488367, 2659270754, 38110259, 4136336762]); + }); + + test("matches LUD05 test vector", async () => { + const lnurlDetails: LNURLDetails = { + domain: "site.com", + k1: "dea6a5e410ae8db8872b30ed715d9c10bbaca1dda653396511a40bb353529572", + tag: "login", + url: "https://site.com/lnurl-login", + }; + + const mockState = { + password: passwordMock, + currentAccountId: "1e1e8ea6-493e-480b-9855-303d37506e97", + getAccount: () => ({ + mnemonic: btcFixture.mnemnoic, + bitcoinNetwork: "regtest", + useMnemonicForLnurlAuth: true, }), - }), -}; - -const lnurlDetails: LNURLDetails = { - domain: "lnurl.fiatjaf.com", - k1: "dea6a5e410ae8db8872b30ed715d9c10bbaca1dda653396511a40bb353529572", - tag: "login", - url: "https://lnurl.fiatjaf.com/lnurl-login", -}; - -// skip till this is solved: -// https://github.com/axios/axios/pull/5146 -// test works if we do not use: -// https://github.com/getAlby/lightning-browser-extension/blob/refactor/manifest-v3-support/src/extension/background-script/actions/lnurl/auth.ts#L93 + getMnemonic: () => new Mnemonic(btcFixture.mnemnoic), + getConnector: jest.fn(), + }; + + state.getState = jest.fn().mockReturnValue(mockState); + + expect(await authFunction({ lnurlDetails })).toStrictEqual({ + success: true, + status: "OK", + reason: undefined, + authResponseData: { status: "OK" }, + }); + + // ensure the public key is constant for the given mnemonic + expect(new URL(fetchedUrl).searchParams.get("key")).toBe( + "027da5d64331f61260eb8e2b356403446555f525bc7dc35b991ec1447e4f58991f" + ); + }); +}); + +// FIXME: this test is doing a real API call, and does not +// test if the user logs in to the correct account or not describe.skip("auth", () => { + const mockState = { + settings: mockSettings, + getConnector: () => ({ + signMessage: () => + Promise.resolve({ + data: { + signature: + "rnu5pnhanjs3bfxz33fuyf9ywzrmkm1ns6jxdraxff1irq3hpxcbkce6zk34ee9bh7bamgd891tfy4gq1y119w53qg1ap5zodwi4u51n", + }, + }), + }), + }; test("returns success response", async () => { state.getState = jest.fn().mockReturnValue(mockState); + const lnurlDetails: LNURLDetails = { + domain: "lnurl.fiatjaf.com", + k1: "dea6a5e410ae8db8872b30ed715d9c10bbaca1dda653396511a40bb353529572", + tag: "login", + url: "https://lnurl.fiatjaf.com/lnurl-login", + }; + expect(await authFunction({ lnurlDetails })).toStrictEqual({ success: true, status: "OK", diff --git a/src/extension/background-script/actions/lnurl/auth.ts b/src/extension/background-script/actions/lnurl/auth.ts index d32768b3e9..bf198043dc 100644 --- a/src/extension/background-script/actions/lnurl/auth.ts +++ b/src/extension/background-script/actions/lnurl/auth.ts @@ -1,6 +1,9 @@ +import * as secp256k1 from "@noble/secp256k1"; import fetchAdapter from "@vespaiach/axios-fetch-adapter"; import axios from "axios"; +import { Buffer } from "buffer"; import Hex from "crypto-js/enc-hex"; +import Utf8 from "crypto-js/enc-utf8"; import hmacSHA256 from "crypto-js/hmac-sha256"; import sha256 from "crypto-js/sha256"; import PubSub from "pubsub-js"; @@ -40,14 +43,35 @@ export async function authFunction({ throw new Error("LNURL-AUTH FAIL: no account selected"); } - let keyMaterialForSignature: string; + const url = new URL(lnurlDetails.url); + if (!url.host) { + throw new Error("Invalid input"); + } + + let linkingKeyPriv: string; // use mnemonic for LNURL auth if (account.mnemonic && account.useMnemonicForLnurlAuth) { const mnemonic = await state.getState().getMnemonic(); - keyMaterialForSignature = await mnemonic.signMessage( - LNURLAUTH_CANONICAL_PHRASE + // See https://github.com/lnurl/luds/blob/luds/05.md + const hashingKey = mnemonic.deriveKey("m/138'/0"); + const hashingPrivateKey = hashingKey.privateKey as Uint8Array; + + const pathSuffix = getPathSuffix( + url.host, + secp256k1.utils.bytesToHex(hashingPrivateKey) + ); + + // Derive key manually (rather than using mnemonic.deriveKey with full path) due to + // https://github.com/paulmillr/scure-bip32/issues/8 + let linkingKey = mnemonic.deriveKey("m/138'"); + for (const index of pathSuffix) { + linkingKey = linkingKey.deriveChild(index); + } + + linkingKeyPriv = secp256k1.utils.bytesToHex( + linkingKey.privateKey as Uint8Array ); } else { const connector = await state.getState().getConnector(); @@ -61,31 +85,28 @@ export async function authFunction({ }, }); - keyMaterialForSignature = signResponse.data.signature; - } + const keyMaterialForSignature = signResponse.data.signature; - // make sure we got a signature - if (!keyMaterialForSignature) { - throw new Error("Invalid Signature"); - } + // make sure we got a signature + if (!keyMaterialForSignature) { + throw new Error("Invalid Signature"); + } - const hashingKey = sha256(keyMaterialForSignature).toString(Hex); - const url = new URL(lnurlDetails.url); - if (!url.host || !hashingKey) { - throw new Error("Invalid input"); - } + const hashingKey = sha256(keyMaterialForSignature).toString(Hex); - let linkingKeyPriv; - const { settings } = state.getState(); - if (settings.isUsingLegacyLnurlAuthKey) { - linkingKeyPriv = hmacSHA256(url.host, hashingKey).toString(Hex); - } else { - linkingKeyPriv = hmacSHA256(url.host, Hex.parse(hashingKey)).toString(Hex); + const { settings } = state.getState(); + if (settings.isUsingLegacyLnurlAuthKey) { + linkingKeyPriv = hmacSHA256(url.host, hashingKey).toString(Hex); + } else { + linkingKeyPriv = hmacSHA256(url.host, Hex.parse(hashingKey)).toString( + Hex + ); + } } - // make sure we got a hashingKey and a linkingkey (just to be sure for whatever reason) - if (!hashingKey || !linkingKeyPriv) { - throw new Error("Invalid hashingKey/linkingKey"); + // make sure we got a linkingkey (just to be sure for whatever reason) + if (!linkingKeyPriv) { + throw new Error("Invalid linkingKey"); } const signer = new HashKeySigner(linkingKeyPriv); @@ -151,11 +172,27 @@ export async function authFunction({ origin, }); - throw new Error(e.message); + throw e; } } } +// see https://github.com/lnurl/luds/blob/luds/05.md +export function getPathSuffix(domain: string, privateKeyHex: string) { + const derivationMaterial = hmacSHA256( + Utf8.parse(domain), + Hex.parse(privateKeyHex) + ).toString(Hex); + + const buf = Buffer.from(derivationMaterial, "hex"); + + const pathSuffix = []; + for (let i = 0; i < 4; i++) { + pathSuffix.push(buf.readUint32BE(i * 4)); + } + return pathSuffix; +} + const auth = async (message: MessageLnurlAuth) => { const { lnurlDetails, origin } = message.args; const response = await authFunction({ lnurlDetails, origin });