Skip to content

Commit

Permalink
LNURL-pay: strict pubkey check for LN Address <pubkey>@domain.com
Browse files Browse the repository at this point in the history
  • Loading branch information
hsjoberg committed Nov 27, 2023
1 parent 8fbd833 commit f0678b7
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 63 deletions.
8 changes: 8 additions & 0 deletions src/state/LNURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,13 @@ export interface ILNUrlModel {
>;

setLNUrlStr: Action<ILNUrlModel, string>;
setLightningAddress: Action<ILNUrlModel, string>;
setType: Action<ILNUrlModel, LNURLType>;
setLNUrlObject: Action<ILNUrlModel, LNUrlRequest>;
setPayRequestResponse: Action<ILNUrlModel, ILNUrlPayResponse>;

lnUrlStr?: string;
lightningAddress?: string;
type?: LNURLType;
lnUrlObject: LNUrlRequest | undefined;
payRequestResponse: ILNUrlPayResponse | undefined;
Expand Down Expand Up @@ -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;
}),
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -695,6 +702,7 @@ export const lnUrl: ILNUrlModel = {
}),

lnUrlStr: undefined,
lightningAddress: undefined,
type: undefined,
lnUrlObject: undefined,
payRequestResponse: undefined,
Expand Down
4 changes: 4 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
}
130 changes: 80 additions & 50 deletions src/windows/LNURL/PayRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,30 @@ 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<Uint8Array | undefined>();
const lnurlStr = useStoreState((store) => store.lnUrl.lnUrlStr);
const lnUrlObject = useStoreState((store) => store.lnUrl.lnUrlObject);
const clear = useStoreActions((store) => store.lnUrl.clear);
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);
Expand All @@ -55,54 +59,59 @@ export default function LNURLPayRequest({ navigation, route }: IPayRequestProps)

const onPressLightningAddress = () => {
navigation.navigate("PayRequestAboutLightningAddress");
}
};

const promptLightningAddressContact = () => {
if (!lightningAddress?.[1]) {
return;
}

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",
Expand All @@ -112,53 +121,74 @@ 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;

return (
<Blurmodal useModalComponent={false} goBackByClickingOutside={false}>
<KeyboardAvoid behavior={"padding"} keyboardVerticalOffset={60}>
<View style={style.keyboardContainer}>
{__DEV__ &&
{__DEV__ && (
<View style={{ position: "absolute", top: 50, right: 0, zIndex: 10000 }}>
<Button small={true} onPress={viewMetadata}>
<Text style={{ fontSize: 7.5 }}>View metadata</Text>
</Button>
</View>
}
)}
<Card style={style.card}>
<CardItem style={style.cardItem}>
<Body style={{ flex: 1, height: "100%" }}>
<View style={style.headerContainer}>
<H1 style={style.header}>
{!preimage ? "Pay" : "Paid"}
</H1>
<H1 style={style.header}>{!preimage ? "Pay" : "Paid"}</H1>
{lightningAddress?.[1] !== undefined && (
<View style={style.contactContainer}>
<TouchableOpacity onPress={onPressLightningAddress}><Text style={style.lightningAddress}>{lightningAddress[1]}</Text></TouchableOpacity>
<TouchableOpacity onPress={onPressLightningAddress}>
<Text style={style.lightningAddress} numberOfLines={1}>
{lightningAddress[1]}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={promptLightningAddressContact}>
<Icon style={style.contactAddIcon} type="AntDesign" name={getContactByLightningAddress(lightningAddress[1]) !== undefined ? "check" : "adduser"} />
<Icon
style={style.contactAddIcon}
type="AntDesign"
name={
getContactByLightningAddress(lightningAddress[1]) !== undefined
? "check"
: "adduser"
}
/>
</TouchableOpacity>
</View>
)}
{lightningAddress?.[1] === undefined && disposableIsFalse && (
<View style={style.contactContainer}>
<TouchableOpacity onPress={promptLnUrlPayContact}>
<Icon style={style.contactAddIcon} type="AntDesign" name={getContactByLnUrlPay(lnurlStr ?? "") ? "check" : "pluscircle"} />
<Icon
style={style.contactAddIcon}
type="AntDesign"
name={getContactByLnUrlPay(lnurlStr ?? "") ? "check" : "pluscircle"}
/>
</TouchableOpacity>
</View>
)}
</View>
{!preimage && <PaymentCard onPaid={paidCallback} lnUrlObject={lnUrlObject} callback={callback} />}
{!preimage && (
<PaymentCard
onPaid={paidCallback}
lnUrlObject={lnUrlObject}
callback={callback}
/>
)}
{preimage && <PaymentDone preimage={preimage} callback={callback} />}
</Body>
</CardItem>
Expand All @@ -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 <></>;
}
}
46 changes: 39 additions & 7 deletions src/windows/LNURL/PayRequest/PaymentCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
11 changes: 5 additions & 6 deletions src/windows/LNURL/PayRequest/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -40,7 +40,7 @@ export default StyleSheet.create({
alignItems: "center",
},
lightningAddress: {
fontSize: 13,
fontSize: 11,
},
contactAddIcon: {
fontSize: 25,
Expand All @@ -61,8 +61,7 @@ export default StyleSheet.create({
boldText: {
fontWeight: "bold",
},
iconText: {
},
iconText: {},
icon: {
fontSize: 18,
},
Expand All @@ -89,7 +88,7 @@ export default StyleSheet.create({
top: 4,
padding: 0,
justifyContent: "center",
height: 20
height: 20,
},
metadataSection: {
marginBottom: 32,
Expand Down

1 comment on commit f0678b7

@vercel
Copy link

@vercel vercel bot commented on f0678b7 Nov 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

blixt-wallet – ./

blixt-wallet-hsjoberg.vercel.app
blixt-wallet-git-master-hsjoberg.vercel.app

Please sign in to comment.