mirror of
https://github.com/BlueWallet/BlueWallet.git
synced 2025-01-19 05:45:15 +01:00
ADD lnurl-auth support (@gocedoko)
This commit is contained in:
parent
2b4ae67b7b
commit
f105acb0b6
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -667,6 +667,10 @@ export class LightningCustodianWallet extends LegacyWallet {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
authenticate(lnurl) {
|
||||
return lnurl.authenticate(this.secret);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
16
loc/en.json
16
loc/en.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
195
screen/lnd/lnurlAuth.js
Normal 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(),
|
||||
});
|
@ -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');
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user