From f0678b7435e3c3c0d02782cdd4e33c0f46723717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Sj=C3=B6berg?= Date: Mon, 27 Nov 2023 19:33:21 +0100 Subject: [PATCH] LNURL-pay: strict pubkey check for LN Address @domain.com --- src/state/LNURL.ts | 8 ++ src/utils/index.ts | 4 + src/windows/LNURL/PayRequest.tsx | 130 ++++++++++++------- src/windows/LNURL/PayRequest/PaymentCard.tsx | 46 ++++++- src/windows/LNURL/PayRequest/style.ts | 11 +- 5 files changed, 136 insertions(+), 63 deletions(-) diff --git a/src/state/LNURL.ts b/src/state/LNURL.ts index db68b99e9..cffb434b6 100644 --- a/src/state/LNURL.ts +++ b/src/state/LNURL.ts @@ -224,11 +224,13 @@ export interface ILNUrlModel { >; setLNUrlStr: Action; + setLightningAddress: Action; setType: Action; setLNUrlObject: Action; setPayRequestResponse: Action; lnUrlStr?: string; + lightningAddress?: string; type?: LNURLType; lnUrlObject: LNUrlRequest | undefined; payRequestResponse: ILNUrlPayResponse | undefined; @@ -630,6 +632,9 @@ export const lnUrl: ILNUrlModel = { setLNUrlStr: action((state, payload) => { state.lnUrlStr = payload; }), + setLightningAddress: action((state, payload) => { + state.lightningAddress = payload; + }), setType: action((state, payload) => { state.type = payload; }), @@ -642,6 +647,7 @@ export const lnUrl: ILNUrlModel = { clear: action((state) => { state.lnUrlStr = undefined; + state.lightningAddress = undefined; state.type = undefined; state.lnUrlObject = undefined; state.payRequestResponse = undefined; @@ -664,6 +670,7 @@ export const lnUrl: ILNUrlModel = { // Normal LNURL fetch request follows: const lnurlPayUrl = `https://${domain}/.well-known/lnurlp/${username}`; actions.setLNUrlStr(lnurlPayUrl); + actions.setLightningAddress(lightningAddress.toLowerCase()); let lnurlObject: ILNUrlPayRequest | ILNUrlPayResponseError; try { @@ -695,6 +702,7 @@ export const lnUrl: ILNUrlModel = { }), lnUrlStr: undefined, + lightningAddress: undefined, type: undefined, lnUrlObject: undefined, payRequestResponse: undefined, diff --git a/src/utils/index.ts b/src/utils/index.ts index 7be7aa83a..be40a16c7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -203,3 +203,7 @@ export function uint8ArrayToUnicodeString(ua: Uint8Array) { }); return decodeURIComponent(escstr); } + +export function isValidNodePubkey(pubKeyStr: string | undefined) { + return /^[0-9a-fA-F]{66}$/.test(pubKeyStr ?? ""); +} diff --git a/src/windows/LNURL/PayRequest.tsx b/src/windows/LNURL/PayRequest.tsx index 801d49932..c10748d64 100644 --- a/src/windows/LNURL/PayRequest.tsx +++ b/src/windows/LNURL/PayRequest.tsx @@ -24,7 +24,7 @@ export interface IPayRequestProps { } export default function LNURLPayRequest({ navigation, route }: IPayRequestProps) { const t = useTranslation(namespaces.LNURL.payRequest).t; - const callback = (route?.params?.callback) ?? (() => {}); + const callback = route?.params?.callback ?? (() => {}); const [preimage, setPreimage] = useState(); const lnurlStr = useStoreState((store) => store.lnUrl.lnUrlStr); const lnUrlObject = useStoreState((store) => store.lnUrl.lnUrlObject); @@ -32,18 +32,22 @@ export default function LNURLPayRequest({ navigation, route }: IPayRequestProps) const payRequestResponse = useStoreState((store) => store.lnUrl.payRequestResponse); const domain = getDomainFromURL(lnurlStr ?? ""); const syncContact = useStoreActions((actions) => actions.contacts.syncContact); - const getContactByLightningAddress = useStoreState((actions) => actions.contacts.getContactByLightningAddress); + const getContactByLightningAddress = useStoreState( + (actions) => actions.contacts.getContactByLightningAddress, + ); const getContactByLnUrlPay = useStoreState((actions) => actions.contacts.getContactByLnUrlPay); useEffect(() => clear, []); try { - if (domain === "" || (!lnUrlObject || lnUrlObject.tag !== "payRequest")) { - return (<>); + if (domain === "" || !lnUrlObject || lnUrlObject.tag !== "payRequest") { + return <>; } const metadata = JSON.parse(lnUrlObject.metadata) as ILNUrlPayRequestMetadata; - const lightningAddress = metadata?.find((item) => item[0] === "text/identifier" || item[0] === "text/email"); + const lightningAddress = metadata?.find( + (item) => item[0] === "text/identifier" || item[0] === "text/email", + ); const paidCallback = (preimage: Uint8Array) => { setPreimage(preimage); @@ -55,7 +59,7 @@ export default function LNURLPayRequest({ navigation, route }: IPayRequestProps) const onPressLightningAddress = () => { navigation.navigate("PayRequestAboutLightningAddress"); - } + }; const promptLightningAddressContact = () => { if (!lightningAddress?.[1]) { @@ -63,46 +67,51 @@ export default function LNURLPayRequest({ navigation, route }: IPayRequestProps) } if (getContactByLightningAddress(lightningAddress[1])) { - Alert.alert("", t("lightningAddress.alreadyExists.msg", {lightningAddress: lightningAddress[1] })); + Alert.alert( + "", + t("lightningAddress.alreadyExists.msg", { lightningAddress: lightningAddress[1] }), + ); } else { Alert.alert( t("lightningAddress.add.title"), t("lightningAddress.add.msg", { lightningAddress: lightningAddress[1] }), - [{ - text: t("buttons.no", { ns:namespaces.common }), - style: "cancel", - }, { - text: t("buttons.yes", { ns:namespaces.common }), - style: "default", - onPress: async () => { - const domain = lightningAddress[1].split("@")[1] ?? ""; - - syncContact({ - type: "PERSON", - domain, - lnUrlPay: null, - lnUrlWithdraw: null, - lightningAddress: lightningAddress[1], - lud16IdentifierMimeType: "text/identifier", - note: "", - }) + [ + { + text: t("buttons.no", { ns: namespaces.common }), + style: "cancel", }, - }], + { + text: t("buttons.yes", { ns: namespaces.common }), + style: "default", + onPress: async () => { + const domain = lightningAddress[1].split("@")[1] ?? ""; + + syncContact({ + type: "PERSON", + domain, + lnUrlPay: null, + lnUrlWithdraw: null, + lightningAddress: lightningAddress[1], + lud16IdentifierMimeType: "text/identifier", + note: "", + }); + }, + }, + ], ); } }; const promptLnUrlPayContact = () => { if (getContactByLnUrlPay(lnurlStr ?? "")) { - Alert.alert("",t("payContact.alreadyExists.msg", { domain })); + Alert.alert("", t("payContact.alreadyExists.msg", { domain })); } else { - Alert.alert( - t("payContact.add.title"), - t("payContact.add.msg", { domain }), - [{ - text: t("buttons.no", { ns:namespaces.common }), - }, { - text: t("buttons.yes", { ns:namespaces.common }), + Alert.alert(t("payContact.add.title"), t("payContact.add.msg", { domain }), [ + { + text: t("buttons.no", { ns: namespaces.common }), + }, + { + text: t("buttons.yes", { ns: namespaces.common }), onPress: async () => { syncContact({ type: "SERVICE", @@ -112,15 +121,16 @@ export default function LNURLPayRequest({ navigation, route }: IPayRequestProps) lightningAddress: null, lud16IdentifierMimeType: null, note: "", - }) - } - }], - ); + }); + }, + }, + ]); } }; - const disposableIsFalse = /*lnUrlObject.disposable === false ||*/ (preimage && payRequestResponse?.disposable) === false; - + const disposableIsFalse = + /*lnUrlObject.disposable === false ||*/ (preimage && payRequestResponse?.disposable) === + false; const KeyboardAvoid = PLATFORM === "ios" ? KeyboardAvoidingView : View; @@ -128,37 +138,57 @@ export default function LNURLPayRequest({ navigation, route }: IPayRequestProps) - {__DEV__ && + {__DEV__ && ( - } + )} -

- {!preimage ? "Pay" : "Paid"} -

+

{!preimage ? "Pay" : "Paid"}

{lightningAddress?.[1] !== undefined && ( - {lightningAddress[1]} + + + {lightningAddress[1]} + + - + )} {lightningAddress?.[1] === undefined && disposableIsFalse && ( - + )}
- {!preimage && } + {!preimage && ( + + )} {preimage && }
@@ -172,6 +202,6 @@ export default function LNURLPayRequest({ navigation, route }: IPayRequestProps) Alert.alert(`${t("unableToPay")}:\n\n${error.message}`); callback(null); navigation.goBack(); - return (<>); + return <>; } } diff --git a/src/windows/LNURL/PayRequest/PaymentCard.tsx b/src/windows/LNURL/PayRequest/PaymentCard.tsx index 687381b08..671bbec05 100644 --- a/src/windows/LNURL/PayRequest/PaymentCard.tsx +++ b/src/windows/LNURL/PayRequest/PaymentCard.tsx @@ -1,16 +1,18 @@ import { Button, Text, View } from "native-base"; +import { Image, Keyboard, Vibration } from "react-native"; +import React, { useState } from "react"; +import { useNavigation } from "@react-navigation/core"; +import { useTranslation } from "react-i18next"; + +import { convertBitcoinToFiat, formatBitcoin } from "../../../utils/bitcoin-units"; import { ILNUrlPayRequest, ILNUrlPayRequestMetadata, ILNUrlPayResponsePayerData, } from "../../../state/LNURL"; -import { Image, Keyboard, Vibration } from "react-native"; -import React, { useState } from "react"; -import { convertBitcoinToFiat, formatBitcoin } from "../../../utils/bitcoin-units"; -import { getDomainFromURL, hexToUint8Array, toast } from "../../../utils"; +import { getDomainFromURL, hexToUint8Array, isValidNodePubkey, toast } from "../../../utils"; import { identifyService, lightningServices } from "../../../utils/lightning-services"; import { useStoreActions, useStoreState } from "../../../state/store"; - import { Alert } from "../../../utils/alert"; import ButtonSpinner from "../../../components/ButtonSpinner"; import Input from "../../../components/Input"; @@ -23,8 +25,7 @@ import { setupDescription } from "../../../utils/NameDesc"; import style from "./style"; import useBalance from "../../../hooks/useBalance"; import useLightningReadyToSend from "../../../hooks/useLightingReadyToSend"; -import { useNavigation } from "@react-navigation/core"; -import { useTranslation } from "react-i18next"; +import { decodePayReq } from "../../../lndmobile"; export interface IPaymentCardProps { onPaid: (preimage: Uint8Array) => void; @@ -41,6 +42,7 @@ export default function PaymentCard({ onPaid, lnUrlObject, callback }: IPaymentC const doPayRequest = useStoreActions((store) => store.lnUrl.doPayRequest); const lnurlStr = useStoreState((store) => store.lnUrl.lnUrlStr); + const originalLightningAddress = useStoreState((store) => store.lnUrl.lightningAddress); // This is the one we came from. May not match the one in the metadata const currentRate = useStoreState((store) => store.fiat.currentRate); const sendPayment = useStoreActions((actions) => actions.send.sendPayment); @@ -151,6 +153,36 @@ export default function PaymentCard({ onPaid, lnUrlObject, callback }: IPaymentC payerData: sendPayerData ? payerData : undefined, }); console.log(paymentRequestResponse); + + // If the Lightning Address Identifier is a pubkey, + // then check if it matches with the invoice destination pubkey. + const lud16WellKnownPubkey = lnurlStr?.split("/")?.slice(-1)[0]; + const lightningAddressPubkey = originalLightningAddress?.split("@")[0]; + if (originalLightningAddress) { + console.log("lightningAddressPubkey", lightningAddressPubkey); + if (isValidNodePubkey(lightningAddressPubkey)) { + console.log( + `Valid pubkey "${lightningAddressPubkey}", doing strict invoice pubkey check`, + ); + const payreq = await decodePayReq(paymentRequestResponse.pr); + if (payreq.destination !== lightningAddressPubkey) { + throw new Error( + "Unable to proceed. The pubkey in the invoice does not match the pubkey in the Lightning Address", + ); + } + } + } + // Also do it for the well-known service URL: + else if (isValidNodePubkey(lud16WellKnownPubkey)) { + console.log(`Valid pubkey "${lud16WellKnownPubkey}", doing strict invoice pubkey check`); + const payreq = await decodePayReq(paymentRequestResponse.pr); + if (payreq.destination !== lud16WellKnownPubkey) { + throw new Error( + "Unable to proceed. The pubkey in the invoice does not match the pubkey in the Lightning Address", + ); + } + } + const response = await sendPayment(); const preimage = hexToUint8Array(response.paymentPreimage); diff --git a/src/windows/LNURL/PayRequest/style.ts b/src/windows/LNURL/PayRequest/style.ts index 167d92d2c..8f80f1152 100644 --- a/src/windows/LNURL/PayRequest/style.ts +++ b/src/windows/LNURL/PayRequest/style.ts @@ -24,8 +24,8 @@ export default StyleSheet.create({ marginBottom: 10, flexDirection: "row", justifyContent: "space-between", - alignItems:"center", - width: "100%" + alignItems: "center", + width: "100%", }, header: { fontWeight: "bold", @@ -40,7 +40,7 @@ export default StyleSheet.create({ alignItems: "center", }, lightningAddress: { - fontSize: 13, + fontSize: 11, }, contactAddIcon: { fontSize: 25, @@ -61,8 +61,7 @@ export default StyleSheet.create({ boldText: { fontWeight: "bold", }, - iconText: { - }, + iconText: {}, icon: { fontSize: 18, }, @@ -89,7 +88,7 @@ export default StyleSheet.create({ top: 4, padding: 0, justifyContent: "center", - height: 20 + height: 20, }, metadataSection: { marginBottom: 32,