ADD lnurl-auth support (@gocedoko)

This commit is contained in:
Overtorment 2022-02-11 14:18:56 +00:00 committed by GitHub
parent 2b4ae67b7b
commit f105acb0b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 320 additions and 0 deletions

View File

@ -83,6 +83,7 @@ import LdkInfo from './screen/lnd/ldkInfo';
import LNDViewAdditionalInvoiceInformation from './screen/lnd/lndViewAdditionalInvoiceInformation';
import LnurlPay from './screen/lnd/lnurlPay';
import LnurlPaySuccess from './screen/lnd/lnurlPaySuccess';
import LnurlAuth from './screen/lnd/lnurlAuth';
import UnlockWith from './UnlockWith';
import DrawerList from './screen/wallets/drawerList';
import { isDesktop, isTablet, isHandset } from './blue_modules/environment';
@ -152,6 +153,7 @@ const WalletsRoot = () => {
<WalletsStack.Screen name="IsItMyAddress" component={IsItMyAddress} options={IsItMyAddress.navigationOptions(theme)} />
<WalletsStack.Screen name="LnurlPay" component={LnurlPay} options={LnurlPay.navigationOptions(theme)} />
<WalletsStack.Screen name="LnurlPaySuccess" component={LnurlPaySuccess} options={LnurlPaySuccess.navigationOptions(theme)} />
<WalletsStack.Screen name="LnurlAuth" component={LnurlAuth} options={LnurlAuth.navigationOptions(theme)} />
<WalletsStack.Screen
name="Success"
component={Success}

View File

@ -1,6 +1,9 @@
import { bech32 } from 'bech32';
import bolt11 from 'bolt11';
import { isTorDaemonDisabled } from '../blue_modules/environment';
import { parse } from 'url'; // eslint-disable-line node/no-deprecated-api
import { createHmac } from 'crypto';
import secp256k1 from 'secp256k1';
const CryptoJS = require('crypto-js');
const createHash = require('create-hash');
const torrific = require('../blue_modules/torrific');
@ -12,6 +15,7 @@ const ONION_REGEX = /^(http:\/\/[^/:@]+\.onion(?::\d{1,5})?)(\/.*)?$/; // regex
export default class Lnurl {
static TAG_PAY_REQUEST = 'payRequest'; // type of LNURL
static TAG_WITHDRAW_REQUEST = 'withdrawRequest'; // type of LNURL
static TAG_LOGIN_REQUEST = 'login'; // type of LNURL
constructor(url, AsyncStorage) {
this._lnurl = url;
@ -285,6 +289,37 @@ export default class Lnurl {
return this?._lnurlPayServicePayload?.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed) : false;
}
authenticate(secret) {
return new Promise((resolve, reject) => {
if (!this._lnurl) throw new Error('this._lnurl is not set');
const url = parse(Lnurl.getUrlFromLnurl(this._lnurl), true); // eslint-disable-line node/no-deprecated-api
const hmac = createHmac('sha256', secret);
hmac.on('readable', async () => {
try {
const privateKey = hmac.read();
if (!privateKey) return;
const privateKeyBuf = Buffer.from(privateKey, 'hex');
const publicKey = secp256k1.publicKeyCreate(privateKeyBuf);
const signatureObj = secp256k1.sign(Buffer.from(url.query.k1, 'hex'), privateKeyBuf);
const derSignature = secp256k1.signatureExport(signatureObj.signature);
const reply = await this.fetchGet(`${url.href}&sig=${derSignature.toString('hex')}&key=${publicKey.toString('hex')}`);
if (reply.status === 'OK') {
resolve();
} else {
reject(reply.reason);
}
} catch (err) {
reject(err);
}
});
hmac.write(url.hostname);
hmac.end();
});
}
static isLightningAddress(address) {
// ensure only 1 `@` present:
if (address.split('@').length !== 2) return false;

View File

@ -667,6 +667,10 @@ export class LightningCustodianWallet extends LegacyWallet {
return false;
}
authenticate(lnurl) {
return lnurl.authenticate(this.secret);
}
}
/*

View File

@ -658,5 +658,21 @@
"type_receive": "Empfang",
"type_used": "Verwendet",
"transactions": "Transaktionen"
},
"lnurl_auth": {
"register_question_part_1": "Möchten Sie ein Konto bei",
"register_question_part_2": "mit Ihrem LN-Wallet registrieren?",
"register_answer": "Konto bei {hostname} erfolgreich registriert!",
"login_question_part_1": "Möchten Sie sich mit Ihrem LN-Wallet auf",
"login_question_part_2": "anmelden?",
"login_answer": "Erfolgreich angemeldet bei {hostname}!",
"link_question_part_1": "Möchten Sie Ihr Konto auf",
"link_question_part_2": "mit Ihrem LN-Wallet verknüpfen?",
"link_answer": "Ihr LN-Wallet wurde erfolgreich mit Ihrem Konto bei {hostname} verknüpft!",
"auth_question_part_1": "Möchten Sie sich mit Ihrem LN-Wallet bei",
"auth_question_part_2": "authentifizieren?",
"auth_answer": "Erfolgreich authentifiziert bei {hostname}!",
"could_not_auth": "Authentifizierung bei {hostname} fehlgeschlagen.",
"authenticate": "Authentifizieren"
}
}

View File

@ -658,5 +658,21 @@
"type_receive": "Receive",
"type_used": "Used",
"transactions": "Transactions"
},
"lnurl_auth": {
"register_question_part_1": "Do you want to register an account at ",
"register_question_part_2": "using your LN wallet?",
"register_answer": "Sucessfully registered an account at {hostname}!",
"login_question_part_1": "Do you want to login at ",
"login_question_part_2": "using your LN wallet?",
"login_answer": "Sucessfully logged in at {hostname}!",
"link_question_part_1": "Link your account at ",
"link_question_part_2": "to your LN wallet?",
"link_answer": "Your LN wallet was sucessfully linked to your account at {hostname}!",
"auth_question_part_1": "Do you want to authenticate at ",
"auth_question_part_2": "using your LN wallet?",
"auth_answer": "Sucessfully authenticated at {hostname}!",
"could_not_auth": "Could not authenticate to {hostname}.",
"authenticate": "Authenticate"
}
}

View File

@ -28,6 +28,7 @@ import Lnurl from '../../class/lnurl';
import { BlueStorageContext } from '../../blue_modules/storage-context';
import Notifications from '../../blue_modules/notifications';
import alert from '../../components/Alert';
import { parse } from 'url'; // eslint-disable-line node/no-deprecated-api
const currency = require('../../blue_modules/currency');
const torrific = require('../../blue_modules/torrific');
@ -223,6 +224,15 @@ const LNDCreateInvoice = () => {
// decoding the lnurl
const url = Lnurl.getUrlFromLnurl(data);
const { query } = parse(url, true); // eslint-disable-line node/no-deprecated-api
if (query.tag === Lnurl.TAG_LOGIN_REQUEST) {
navigate('LnurlAuth', {
lnurl: data,
walletID: walletID ?? wallet.current.getID(),
});
return;
}
// calling the url
let reply;

195
screen/lnd/lnurlAuth.js Normal file
View File

@ -0,0 +1,195 @@
import React, { useState, useContext, useCallback, useMemo } from 'react';
import { I18nManager, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Icon } from 'react-native-elements';
import { BlueButton, BlueCard, BlueLoading, BlueSpacing20, BlueSpacing40, BlueText, SafeBlueArea } from '../../BlueComponents';
import navigationStyle from '../../components/navigationStyle';
import Lnurl from '../../class/lnurl';
import { Chain } from '../../models/bitcoinUnits';
import loc from '../../loc';
import { BlueStorageContext } from '../../blue_modules/storage-context';
import { useNavigation, useRoute, useTheme } from '@react-navigation/native';
import selectWallet from '../../helpers/select-wallet';
import LottieView from 'lottie-react-native';
import url from 'url';
const AuthState = {
USER_PROMPT: 0,
IN_PROGRESS: 1,
SUCCESS: 2,
ERROR: 3,
};
const LnurlAuth = () => {
const { wallets } = useContext(BlueStorageContext);
const { name } = useRoute();
const { walletID, lnurl } = useRoute().params;
const wallet = useMemo(() => wallets.find(w => w.getID() === walletID), [wallets, walletID]);
const LN = useMemo(() => new Lnurl(lnurl), [lnurl]);
const parsedLnurl = useMemo(
() => (lnurl ? url.parse(Lnurl.getUrlFromLnurl(lnurl), true) : {}), // eslint-disable-line node/no-deprecated-api
[lnurl],
);
const [authState, setAuthState] = useState(AuthState.USER_PROMPT);
const [errMsg, setErrMsg] = useState('');
const { setParams, navigate } = useNavigation();
const { colors } = useTheme();
const stylesHook = StyleSheet.create({
root: {
backgroundColor: colors.background,
},
walletWrapLabel: {
color: colors.buttonAlternativeTextColor,
},
});
const showSelectWalletScreen = useCallback(() => {
selectWallet(navigate, name, Chain.OFFCHAIN).then(wallet => setParams({ walletID: wallet.getID() }));
}, [navigate, name, setParams]);
const authenticate = useCallback(() => {
wallet
.authenticate(LN)
.then(() => {
setAuthState(AuthState.SUCCESS);
setErrMsg('');
})
.catch(err => {
setAuthState(AuthState.ERROR);
setErrMsg(err);
});
}, [wallet, LN]);
if (!parsedLnurl || !wallet || authState === AuthState.IN_PROGRESS)
return (
<View style={[styles.root, stylesHook.root]}>
<BlueLoading />
</View>
);
const renderWalletSelectionButton = authState === AuthState.USER_PROMPT && (
<View style={styles.walletSelectRoot}>
{authState !== AuthState.IN_PROGRESS && (
<TouchableOpacity accessibilityRole="button" style={styles.walletSelectTouch} onPress={showSelectWalletScreen}>
<Text style={styles.walletSelectText}>{loc.wallets.select_wallet.toLowerCase()}</Text>
<Icon name={I18nManager.isRTL ? 'angle-left' : 'angle-right'} size={18} type="font-awesome" color="#9aa0aa" />
</TouchableOpacity>
)}
<View style={styles.walletWrap}>
<TouchableOpacity accessibilityRole="button" style={styles.walletWrapTouch} onPress={showSelectWalletScreen}>
<Text style={[styles.walletWrapLabel, stylesHook.walletWrapLabel]}>{wallet.getLabel()}</Text>
</TouchableOpacity>
</View>
</View>
);
return (
<SafeBlueArea style={styles.root}>
{authState === AuthState.USER_PROMPT && (
<>
<ScrollView>
<BlueCard>
<BlueText style={styles.alignSelfCenter}>{loc.lnurl_auth[`${parsedLnurl.query.action || 'auth'}_question_part_1`]}</BlueText>
<BlueText style={styles.domainName}>{parsedLnurl.hostname}</BlueText>
<BlueText style={styles.alignSelfCenter}>{loc.lnurl_auth[`${parsedLnurl.query.action || 'auth'}_question_part_2`]}</BlueText>
<BlueSpacing40 />
<BlueButton title={loc.lnurl_auth.authenticate} onPress={authenticate} />
<BlueSpacing40 />
</BlueCard>
</ScrollView>
{renderWalletSelectionButton}
</>
)}
{authState === AuthState.SUCCESS && (
<BlueCard>
<View style={styles.iconContainer}>
<LottieView style={styles.icon} source={require('../../img/bluenice.json')} autoPlay loop={false} />
</View>
<BlueSpacing20 />
<BlueText style={styles.alignSelfCenter}>
{loc.formatString(loc.lnurl_auth[`${parsedLnurl.query.action || 'auth'}_answer`], { hostname: parsedLnurl.hostname })}
</BlueText>
<BlueSpacing20 />
</BlueCard>
)}
{authState === AuthState.ERROR && (
<BlueCard>
<BlueSpacing20 />
<BlueText style={styles.alignSelfCenter}>
{loc.formatString(loc.lnurl_auth.could_not_auth, { hostname: parsedLnurl.hostname })}
</BlueText>
<BlueText style={styles.alignSelfCenter}>{errMsg}</BlueText>
<BlueSpacing20 />
</BlueCard>
)}
</SafeBlueArea>
);
};
export default LnurlAuth;
const styles = StyleSheet.create({
alignSelfCenter: {
alignSelf: 'center',
},
domainName: {
alignSelf: 'center',
fontWeight: 'bold',
fontSize: 25,
paddingVertical: 10,
},
root: {
flex: 1,
justifyContent: 'center',
},
iconContainer: {
backgroundColor: '#ccddf9',
width: 120,
height: 120,
maxWidth: 120,
maxHeight: 120,
padding: 0,
borderRadius: 60,
alignSelf: 'center',
justifyContent: 'center',
alignItems: 'center',
},
icon: {
width: 400,
height: 400,
},
walletSelectRoot: {
alignItems: 'center',
justifyContent: 'flex-end',
},
walletSelectTouch: {
flexDirection: 'row',
alignItems: 'center',
},
walletSelectText: {
color: '#9aa0aa',
fontSize: 14,
marginRight: 8,
},
walletWrap: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 4,
},
walletWrapTouch: {
flexDirection: 'row',
alignItems: 'center',
},
walletWrapLabel: {
fontSize: 14,
},
});
LnurlAuth.navigationOptions = navigationStyle({
title: '',
closeButton: true,
closeButtonFunc: ({ navigation }) => navigation.dangerouslyGetParent().popToTop(),
});

View File

@ -226,6 +226,48 @@ describe('lightning address', function () {
assert.ok(!Lnurl.isLightningAddress('a@'));
});
it('can authenticate', async () => {
const LN = new Lnurl(
'LNURL1DP68GURN8GHJ7MRFVA58GMNFDENKCMM8D9HZUMRFWEJJ7MR0VA5KU0MTXY7NYVFEX93X2DFK8P3KVEFKVSEXZWR98PSNJVRRV5CRGCE3X4JKGE3HXPNXGCMPV5MXXVTZ89NXZENXXCURGCTRV93RVE35XQCXVCFSVSN8GCT884KX7EMFDCDKKXQ0',
);
// poor-man's mock:
LN._fetchGet = LN.fetchGet;
let requestedUri = -1;
LN.fetchGet = actuallyRequestedUri => {
requestedUri = actuallyRequestedUri;
return {
status: 'OK',
};
};
await assert.doesNotReject(LN.authenticate('lndhub://dc56b8cf8ef3b60060cf:94eac57510de2738451d'));
assert.strictEqual(
requestedUri,
'https://lightninglogin.live/login?k1=2191be568cfe6d2a8e8a90ce04c15edf70fdcae6c1b9faff684acab6f400fa0d&tag=login&sig=304502210093ab4ead8dd619f2ddb3d52bd4bb01725badcb2a3daa3870fb41a38096f9a37d0220464a32e94e13dcec20ea94b94df0fa52f45cd88b01d7247042136ad0c71752d2&key=03e7b61e57efff1925ab9082625400cae2c8ad88a984e7aa4987abb77818570018',
);
});
it('returns the server error response as the reject error from lnurl-auth', async () => {
const LN = new Lnurl(
'LNURL1DP68GURN8GHJ7MRFVA58GMNFDENKCMM8D9HZUMRFWEJJ7MR0VA5KU0MTXY7NYVFEX93X2DFK8P3KVEFKVSEXZWR98PSNJVRRV5CRGCE3X4JKGE3HXPNXGCMPV5MXXVTZ89NXZENXXCURGCTRV93RVE35XQCXVCFSVSN8GCT884KX7EMFDCDKKXQ0',
);
// poor-man's mock:
LN._fetchGet = LN.fetchGet;
LN.fetchGet = () => {
return {
reason: 'Invalid signature',
status: 'ERROR',
};
};
await assert.rejects(LN.authenticate('lndhub://dc56b8cf8ef3b60060cf:94eac57510de2738451d'), err => {
assert.strictEqual(err, 'Invalid signature');
return true;
});
});
it('works', async () => {
const LN = new Lnurl('lnaddress@zbd.gg');