mirror of
https://github.com/selfxyz/self.git
synced 2026-04-05 03:00:53 -04:00
Fix: recovery phrase (#201)
This commit is contained in:
committed by
GitHub
parent
c2d90fb5ad
commit
2155bf95a5
@@ -10,6 +10,7 @@ import { TamaguiProvider, createTamagui } from 'tamagui';
|
||||
|
||||
import App from './App';
|
||||
import { name as appName } from './app.json';
|
||||
import './src/utils/ethers';
|
||||
|
||||
const tamaguiConfig = createTamagui(config);
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"react-native-localize": "^3.4.1",
|
||||
"react-native-nfc-manager": "^3.15.1",
|
||||
"react-native-passport-reader": "^1.0.3",
|
||||
"react-native-quick-crypto": "^0.7.12",
|
||||
"react-native-safe-area-context": "^5.2.0",
|
||||
"react-native-screens": "^4.6.0",
|
||||
"react-native-svg": "^15.11.1",
|
||||
|
||||
24
app/src/hooks/useMnemonic.ts
Normal file
24
app/src/hooks/useMnemonic.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
import { useAuth } from '../stores/authProvider';
|
||||
|
||||
export default function useMnemonic() {
|
||||
const { getOrCreateMnemonic } = useAuth();
|
||||
const [mnemonic, setMnemonic] = useState<string[]>();
|
||||
|
||||
const loadMnemonic = useCallback(async () => {
|
||||
const storedMnemonic = await getOrCreateMnemonic();
|
||||
if (!storedMnemonic) {
|
||||
return;
|
||||
}
|
||||
const { entropy } = storedMnemonic.data;
|
||||
setMnemonic(ethers.Mnemonic.fromEntropy(entropy).phrase.split(' '));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loadMnemonic,
|
||||
mnemonic,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import RestoreAccountSvg from '../../images/icons/restore_account.svg';
|
||||
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
||||
import { useAuth } from '../../stores/authProvider';
|
||||
import { useSettingStore } from '../../stores/settingStore';
|
||||
import { STORAGE_NAME, useBackupPrivateKey } from '../../utils/cloudBackup';
|
||||
import { STORAGE_NAME, useBackupMnemonic } from '../../utils/cloudBackup';
|
||||
import { black, slate500, slate600, white } from '../../utils/colors';
|
||||
|
||||
interface AccountRecoveryChoiceScreenProps {}
|
||||
@@ -21,11 +21,11 @@ interface AccountRecoveryChoiceScreenProps {}
|
||||
const AccountRecoveryChoiceScreen: React.FC<
|
||||
AccountRecoveryChoiceScreenProps
|
||||
> = ({}) => {
|
||||
const { restoreAccountFromPrivateKey } = useAuth();
|
||||
const { restoreAccountFromMnemonic } = useAuth();
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
|
||||
useSettingStore();
|
||||
const { download } = useBackupPrivateKey();
|
||||
const { download } = useBackupMnemonic();
|
||||
|
||||
const onRestoreFromCloudNext = useHapticNavigation('AccountVerifiedSuccess');
|
||||
const onEnterRecoveryPress = useHapticNavigation('RecoverWithPhrase');
|
||||
@@ -33,8 +33,8 @@ const AccountRecoveryChoiceScreen: React.FC<
|
||||
const onRestoreFromCloudPress = useCallback(async () => {
|
||||
setRestoring(true);
|
||||
try {
|
||||
const restoredPrivKey = await download();
|
||||
await restoreAccountFromPrivateKey(restoredPrivKey);
|
||||
const mnemonic = await download();
|
||||
await restoreAccountFromMnemonic(mnemonic.phrase);
|
||||
if (!cloudBackupEnabled) {
|
||||
toggleCloudBackupEnabled();
|
||||
}
|
||||
@@ -47,7 +47,7 @@ const AccountRecoveryChoiceScreen: React.FC<
|
||||
}, [
|
||||
cloudBackupEnabled,
|
||||
download,
|
||||
restoreAccountFromPrivateKey,
|
||||
restoreAccountFromMnemonic,
|
||||
onRestoreFromCloudNext,
|
||||
]);
|
||||
|
||||
@@ -77,7 +77,8 @@ const AccountRecoveryChoiceScreen: React.FC<
|
||||
onPress={onRestoreFromCloudPress}
|
||||
disabled={restoring || !biometricsAvailable}
|
||||
>
|
||||
Restore from {STORAGE_NAME}
|
||||
{restoring ? 'Restoring' : 'Restore'} from {STORAGE_NAME}
|
||||
{restoring ? '…' : ''}
|
||||
</PrimaryButton>
|
||||
<XStack gap={64} ai="center" justifyContent="space-between">
|
||||
<Separator flexGrow={1} />
|
||||
|
||||
@@ -45,6 +45,7 @@ const RecoverWithPhraseScreen: React.FC<
|
||||
return;
|
||||
}
|
||||
const result = await restoreAccountFromMnemonic(slimMnemonic);
|
||||
|
||||
if (!result) {
|
||||
console.warn('Failed to restore account');
|
||||
// TODO SOMETHING ELSE?
|
||||
@@ -52,7 +53,7 @@ const RecoverWithPhraseScreen: React.FC<
|
||||
return;
|
||||
}
|
||||
setRestoring(false);
|
||||
navigation.navigate('Home');
|
||||
navigation.navigate('AccountVerifiedSuccess');
|
||||
}, [mnemonic, restoreAccountFromMnemonic]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { findBestLanguageTag } from 'react-native-localize';
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
import Mnemonic from '../../components/Mnemonic';
|
||||
import { PrimaryButton } from '../../components/buttons/PrimaryButton';
|
||||
@@ -10,8 +7,8 @@ import { Caption } from '../../components/typography/Caption';
|
||||
import Description from '../../components/typography/Description';
|
||||
import { Title } from '../../components/typography/Title';
|
||||
import useHapticNavigation from '../../hooks/useHapticNavigation';
|
||||
import useMnemonic from '../../hooks/useMnemonic';
|
||||
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
||||
import { useAuth } from '../../stores/authProvider';
|
||||
import { STORAGE_NAME } from '../../utils/cloudBackup';
|
||||
import { black, slate400, white } from '../../utils/colors';
|
||||
|
||||
@@ -20,33 +17,14 @@ interface SaveRecoveryPhraseScreenProps {}
|
||||
const SaveRecoveryPhraseScreen: React.FC<
|
||||
SaveRecoveryPhraseScreenProps
|
||||
> = ({}) => {
|
||||
const { getOrCreatePrivateKey } = useAuth();
|
||||
const [mnemonic, setMnemonic] = useState<string[]>();
|
||||
const [userHasSeenMnemonic, setUserHasSeenMnemonic] = useState(false);
|
||||
const { mnemonic, loadMnemonic } = useMnemonic();
|
||||
|
||||
const onRevealWords = useCallback(async () => {
|
||||
await loadMnemonic();
|
||||
setUserHasSeenMnemonic(true);
|
||||
}, []);
|
||||
|
||||
const loadMnemonic = useCallback(async () => {
|
||||
const privKey = await getOrCreatePrivateKey();
|
||||
if (!privKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { languageTag } = findBestLanguageTag(
|
||||
Object.keys(ethers.wordlists),
|
||||
) || { languageTag: 'en' };
|
||||
|
||||
const words = ethers.Mnemonic.entropyToPhrase(
|
||||
privKey.data,
|
||||
ethers.wordlists[languageTag],
|
||||
);
|
||||
|
||||
setMnemonic(words.trim().split(' '));
|
||||
}, []);
|
||||
|
||||
const onCloudBackupPress = useHapticNavigation('CloudBackupSettings', {
|
||||
params: { nextScreen: 'SaveRecoveryPhrase' },
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import Cloud from '../../images/icons/logo_cloud_backup.svg';
|
||||
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
||||
import { useAuth } from '../../stores/authProvider';
|
||||
import { useSettingStore } from '../../stores/settingStore';
|
||||
import { STORAGE_NAME, useBackupPrivateKey } from '../../utils/cloudBackup';
|
||||
import { STORAGE_NAME, useBackupMnemonic } from '../../utils/cloudBackup';
|
||||
import { black, white } from '../../utils/colors';
|
||||
import { buttonTap, confirmTap } from '../../utils/haptic';
|
||||
|
||||
@@ -32,10 +32,10 @@ interface CloudBackupScreenProps
|
||||
const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
route: { params },
|
||||
}) => {
|
||||
const { getOrCreatePrivateKey, loginWithBiometrics } = useAuth();
|
||||
const { getOrCreateMnemonic, loginWithBiometrics } = useAuth();
|
||||
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
|
||||
useSettingStore();
|
||||
const { upload, disableBackup } = useBackupPrivateKey();
|
||||
const { upload, disableBackup } = useBackupMnemonic();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const { showModal } = useModal(
|
||||
@@ -70,17 +70,17 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
|
||||
setPending(true);
|
||||
|
||||
const privKey = await getOrCreatePrivateKey();
|
||||
if (!privKey) {
|
||||
const storedMnemonic = await getOrCreateMnemonic();
|
||||
if (!storedMnemonic) {
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
await upload(privKey.data);
|
||||
await upload(storedMnemonic.data);
|
||||
toggleCloudBackupEnabled();
|
||||
setPending(false);
|
||||
}, [
|
||||
cloudBackupEnabled,
|
||||
getOrCreatePrivateKey,
|
||||
getOrCreateMnemonic,
|
||||
upload,
|
||||
toggleCloudBackupEnabled,
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { Platform, TextInput } from 'react-native';
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import {
|
||||
@@ -8,10 +9,23 @@ import {
|
||||
IterationCw,
|
||||
VenetianMask,
|
||||
} from '@tamagui/lucide-icons';
|
||||
import { Adapt, Button, Fieldset, Label, Select, Sheet, YStack } from 'tamagui';
|
||||
import {
|
||||
Adapt,
|
||||
Button,
|
||||
Fieldset,
|
||||
Label,
|
||||
Select,
|
||||
Sheet,
|
||||
Text,
|
||||
YStack,
|
||||
} from 'tamagui';
|
||||
|
||||
import { genMockPassportData } from '../../../../common/src/utils/passports/genMockPassportData';
|
||||
import { RootStackParamList } from '../../Navigation';
|
||||
import {
|
||||
unsafe_clearSecrets,
|
||||
unsafe_getPrivateKey,
|
||||
} from '../../stores/authProvider';
|
||||
import {
|
||||
storePassportData,
|
||||
usePassport,
|
||||
@@ -20,6 +34,22 @@ import { borderColor, textBlack } from '../../utils/colors';
|
||||
|
||||
interface DevSettingsScreenProps {}
|
||||
|
||||
function SelectableText({ children, ...props }: PropsWithChildren) {
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<TextInput multiline editable={false} {...props}>
|
||||
{children}
|
||||
</TextInput>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Text selectable {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const items = [
|
||||
'DevSettings',
|
||||
'Splash',
|
||||
@@ -103,14 +133,20 @@ const ScreenSelector = ({}) => {
|
||||
|
||||
const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
|
||||
const { clearPassportData } = usePassport();
|
||||
const [privateKey, setPrivateKey] = useState('Loading private key…');
|
||||
|
||||
const nav = useNavigation();
|
||||
|
||||
function handleRestart() {
|
||||
clearPassportData();
|
||||
async function handleRestart() {
|
||||
await clearPassportData();
|
||||
nav.navigate('Launch');
|
||||
}
|
||||
|
||||
async function deleteEverything() {
|
||||
await unsafe_clearSecrets();
|
||||
await handleRestart();
|
||||
}
|
||||
|
||||
function handleGenerateMockPassportData() {
|
||||
const passportData = genMockPassportData(
|
||||
'sha256',
|
||||
@@ -123,15 +159,14 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
|
||||
storePassportData(passportData);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
unsafe_getPrivateKey().then(setPrivateKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<YStack gap="$2" mt="$2" ai="center">
|
||||
<Fieldset gap="$4" horizontal>
|
||||
<Label
|
||||
color={textBlack}
|
||||
width={200}
|
||||
justifyContent="flex-end"
|
||||
htmlFor="restart"
|
||||
>
|
||||
<YStack gap="$3" mt="$2" ai="center">
|
||||
<Fieldset px="$4" horizontal width="100%" justifyContent="space-between">
|
||||
<Label color={textBlack} width={200} justifyContent="flex-end">
|
||||
Rescan passport
|
||||
</Label>
|
||||
<Button
|
||||
@@ -146,13 +181,8 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
|
||||
<IterationCw color={textBlack} />
|
||||
</Button>
|
||||
</Fieldset>
|
||||
<Fieldset gap="$4" horizontal>
|
||||
<Label
|
||||
color={textBlack}
|
||||
width={200}
|
||||
justifyContent="flex-end"
|
||||
htmlFor="restart"
|
||||
>
|
||||
<Fieldset px="$4" horizontal width="100%" justifyContent="space-between">
|
||||
<Label color={textBlack} width={200} justifyContent="flex-end">
|
||||
Generate mock passport data
|
||||
</Label>
|
||||
<Button
|
||||
@@ -168,13 +198,8 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
|
||||
</Button>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset gap="$4" mt="$1" horizontal marginBottom={30}>
|
||||
<Label
|
||||
color={textBlack}
|
||||
width={200}
|
||||
justifyContent="flex-end"
|
||||
htmlFor="skip"
|
||||
>
|
||||
<Fieldset px="$4" horizontal width="100%" justifyContent="space-between">
|
||||
<Label color={textBlack} width={200} justifyContent="flex-end">
|
||||
Delete passport data
|
||||
</Label>
|
||||
<Button
|
||||
@@ -184,18 +209,66 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
|
||||
borderWidth={1.2}
|
||||
size="$3.5"
|
||||
ml="$2"
|
||||
// onPress={}
|
||||
onPress={clearPassportData}
|
||||
>
|
||||
<Eraser color={textBlack} />
|
||||
</Button>
|
||||
</Fieldset>
|
||||
<Fieldset px="$4" horizontal width="100%" justifyContent="space-between">
|
||||
<Label color={textBlack} width={200} justifyContent="flex-end">
|
||||
Delete keychain secrets
|
||||
</Label>
|
||||
<Button
|
||||
bg="white"
|
||||
jc="center"
|
||||
borderColor={borderColor}
|
||||
borderWidth={1.2}
|
||||
size="$3.5"
|
||||
ml="$2"
|
||||
onPress={unsafe_clearSecrets}
|
||||
>
|
||||
<Eraser color={textBlack} />
|
||||
</Button>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset marginTop={30} gap="$4" mt="$1" horizontal>
|
||||
<Label color={textBlack} justifyContent="flex-end" htmlFor="skip">
|
||||
Shortcut
|
||||
<Fieldset px="$4" horizontal width="100%" justifyContent="space-between">
|
||||
<Label color={textBlack} width={200} justifyContent="flex-end">
|
||||
Delete everything
|
||||
</Label>
|
||||
<Button
|
||||
bg="white"
|
||||
jc="center"
|
||||
borderColor={borderColor}
|
||||
borderWidth={1.2}
|
||||
size="$3.5"
|
||||
ml="$2"
|
||||
onPress={deleteEverything}
|
||||
>
|
||||
<Eraser color={textBlack} />
|
||||
</Button>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset px="$4" horizontal width="100%" justifyContent="space-between">
|
||||
<Label color={textBlack} justifyContent="flex-end">
|
||||
Shortcuts
|
||||
</Label>
|
||||
<ScreenSelector />
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset px="$4" width="100%" mt={30} justifyContent="space-between">
|
||||
<Label color={textBlack} width={200} justifyContent="flex-end">
|
||||
Private key
|
||||
</Label>
|
||||
<SelectableText
|
||||
color={textBlack}
|
||||
width={300}
|
||||
justifyContent="flex-end"
|
||||
userSelect="all"
|
||||
style={{ fontFamily: 'monospace', fontWeight: 'bold' }}
|
||||
>
|
||||
{privateKey}
|
||||
</SelectableText>
|
||||
</Fieldset>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,42 +1,21 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { findBestLanguageTag } from 'react-native-localize';
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import Mnemonic from '../../components/Mnemonic';
|
||||
import Description from '../../components/typography/Description';
|
||||
import useMnemonic from '../../hooks/useMnemonic';
|
||||
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
||||
import { useAuth } from '../../stores/authProvider';
|
||||
|
||||
interface ShowRecoveryPhraseScreenProps {}
|
||||
|
||||
const ShowRecoveryPhraseScreen: React.FC<
|
||||
ShowRecoveryPhraseScreenProps
|
||||
> = ({}) => {
|
||||
const { getOrCreatePrivateKey } = useAuth();
|
||||
const [mnemonic, setMnemonic] = useState<string[]>();
|
||||
const { mnemonic, loadMnemonic } = useMnemonic();
|
||||
|
||||
const onRevealWords = useCallback(async () => {
|
||||
await loadMnemonic();
|
||||
}, []);
|
||||
|
||||
const loadMnemonic = useCallback(async () => {
|
||||
const privKey = await getOrCreatePrivateKey();
|
||||
if (!privKey) {
|
||||
return;
|
||||
}
|
||||
const { languageTag } = findBestLanguageTag(
|
||||
Object.keys(ethers.wordlists),
|
||||
) || { languageTag: 'en' };
|
||||
|
||||
const words = ethers.Mnemonic.entropyToPhrase(
|
||||
privKey.data,
|
||||
ethers.wordlists[languageTag],
|
||||
);
|
||||
|
||||
setMnemonic(words.trim().split(' '));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExpandableBottomLayout.Layout backgroundColor="white">
|
||||
<ExpandableBottomLayout.BottomSection
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import LottieView from 'lottie-react-native';
|
||||
|
||||
import splashAnimation from '../assets/animations/splash.json';
|
||||
import { loadSecret, useAuth } from '../stores/authProvider';
|
||||
import { hasSecretStored, useAuth } from '../stores/authProvider';
|
||||
import { loadPassportData } from '../stores/passportDataProvider';
|
||||
import { useSettingStore } from '../stores/settingStore';
|
||||
import { black } from '../utils/colors';
|
||||
@@ -30,7 +30,7 @@ const SplashScreen: React.FC = ({}) => {
|
||||
const handleAnimationFinish = useCallback(() => {
|
||||
setTimeout(async () => {
|
||||
impactLight();
|
||||
const secret = await loadSecret();
|
||||
const secret = await hasSecretStored();
|
||||
const passportData = await loadPassportData();
|
||||
|
||||
if (secret && passportData) {
|
||||
@@ -39,7 +39,7 @@ const SplashScreen: React.FC = ({}) => {
|
||||
navigation.navigate('Launch');
|
||||
}
|
||||
}, 1000);
|
||||
}, [loadSecret, loadPassportData, navigation]);
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<LottieView
|
||||
|
||||
@@ -11,6 +11,10 @@ import Keychain from 'react-native-keychain';
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
import { Mnemonic } from '../types/mnemonic';
|
||||
|
||||
const SERVICE_NAME = 'secret';
|
||||
|
||||
type SignedPayload<T> = { signature: string; data: T };
|
||||
const _getSecurely = async function <T>(
|
||||
fn: () => Promise<string | false>,
|
||||
@@ -69,34 +73,36 @@ async function createSigningKeyPair(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSecret() {
|
||||
const secretCreds = await Keychain.getGenericPassword({ service: 'secret' });
|
||||
return secretCreds === false ? false : secretCreds.password;
|
||||
}
|
||||
|
||||
async function restoreFromMnemonic(mnemonic: string) {
|
||||
if (!mnemonic || !ethers.Mnemonic.isValidMnemonic(mnemonic)) {
|
||||
throw new Error('Invalid mnemonic');
|
||||
}
|
||||
|
||||
const restoredWallet = ethers.Wallet.fromPhrase(mnemonic);
|
||||
return restoreFromPrivateKey(restoredWallet.privateKey);
|
||||
}
|
||||
|
||||
async function restoreFromPrivateKey(privateKey: string) {
|
||||
await Keychain.setGenericPassword('secret', privateKey, {
|
||||
service: 'secret',
|
||||
const data = JSON.stringify(restoredWallet.mnemonic);
|
||||
await Keychain.setGenericPassword('secret', data, {
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
return loadSecret();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadSecretOrCreateIt() {
|
||||
const secret = await loadSecret();
|
||||
if (secret) {
|
||||
return secret;
|
||||
async function loadOrCreateMnemonic() {
|
||||
const storedMnemonic = await Keychain.getGenericPassword({
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
if (storedMnemonic) {
|
||||
return storedMnemonic.password;
|
||||
}
|
||||
|
||||
console.log('No secret found, creating one');
|
||||
const randomWallet = ethers.Wallet.createRandom();
|
||||
const newSecret = randomWallet.privateKey;
|
||||
await Keychain.setGenericPassword('secret', newSecret, { service: 'secret' });
|
||||
return newSecret;
|
||||
const { mnemonic } = ethers.HDNodeWallet.fromMnemonic(
|
||||
ethers.Mnemonic.fromEntropy(ethers.randomBytes(32)),
|
||||
);
|
||||
const data = JSON.stringify(mnemonic);
|
||||
await Keychain.setGenericPassword('secret', data, {
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
const biometrics = new ReactNativeBiometrics({
|
||||
@@ -110,13 +116,10 @@ interface IAuthContext {
|
||||
isAuthenticating: boolean;
|
||||
loginWithBiometrics: () => Promise<void>;
|
||||
_getSecurely: typeof _getSecurely;
|
||||
getOrCreatePrivateKey: () => Promise<SignedPayload<string> | null>;
|
||||
getOrCreateMnemonic: () => Promise<SignedPayload<Mnemonic> | null>;
|
||||
restoreAccountFromMnemonic: (
|
||||
mnemonic: string,
|
||||
) => Promise<SignedPayload<string> | null>;
|
||||
restoreAccountFromPrivateKey: (
|
||||
privKey: string,
|
||||
) => Promise<SignedPayload<string> | null>;
|
||||
) => Promise<SignedPayload<boolean> | null>;
|
||||
createSigningKeyPair: () => Promise<boolean>;
|
||||
}
|
||||
export const AuthContext = createContext<IAuthContext>({
|
||||
@@ -124,9 +127,8 @@ export const AuthContext = createContext<IAuthContext>({
|
||||
isAuthenticating: false,
|
||||
loginWithBiometrics: () => Promise.resolve(),
|
||||
_getSecurely,
|
||||
getOrCreatePrivateKey: () => Promise.resolve(null),
|
||||
getOrCreateMnemonic: () => Promise.resolve(null),
|
||||
restoreAccountFromMnemonic: () => Promise.resolve(null),
|
||||
restoreAccountFromPrivateKey: () => Promise.resolve(null),
|
||||
createSigningKeyPair: () => Promise.resolve(false),
|
||||
});
|
||||
|
||||
@@ -174,24 +176,16 @@ export const AuthProvider = ({
|
||||
});
|
||||
}, [isAuthenticatingPromise]);
|
||||
|
||||
const getOrCreatePrivateKey = useCallback(
|
||||
() => _getSecurely<string>(loadSecretOrCreateIt, str => str),
|
||||
const getOrCreateMnemonic = useCallback(
|
||||
() => _getSecurely<Mnemonic>(loadOrCreateMnemonic, str => JSON.parse(str)),
|
||||
[],
|
||||
);
|
||||
|
||||
const restoreAccountFromMnemonic = useCallback(
|
||||
(mnemonic: string) =>
|
||||
_getSecurely<string>(
|
||||
_getSecurely<boolean>(
|
||||
() => restoreFromMnemonic(mnemonic),
|
||||
str => str,
|
||||
),
|
||||
[],
|
||||
);
|
||||
const restoreAccountFromPrivateKey = useCallback(
|
||||
(privKey: string) =>
|
||||
_getSecurely<string>(
|
||||
() => restoreFromPrivateKey(privKey),
|
||||
str => str,
|
||||
str => !!str,
|
||||
),
|
||||
[],
|
||||
);
|
||||
@@ -201,9 +195,8 @@ export const AuthProvider = ({
|
||||
isAuthenticated,
|
||||
isAuthenticating: !!isAuthenticatingPromise,
|
||||
loginWithBiometrics,
|
||||
getOrCreatePrivateKey,
|
||||
getOrCreateMnemonic,
|
||||
restoreAccountFromMnemonic,
|
||||
restoreAccountFromPrivateKey,
|
||||
createSigningKeyPair,
|
||||
_getSecurely,
|
||||
}),
|
||||
@@ -216,3 +209,24 @@ export const AuthProvider = ({
|
||||
export const useAuth = () => {
|
||||
return useContext(AuthContext);
|
||||
};
|
||||
|
||||
export async function hasSecretStored() {
|
||||
const seed = await Keychain.getGenericPassword({ service: SERVICE_NAME });
|
||||
return !!seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only reason this is exported without being locked behind user biometrics is to allow `loadPassportDataAndSecret`
|
||||
* to access both the privatekey and the passport data with the user only authenticating once
|
||||
*/
|
||||
export async function unsafe_getPrivateKey() {
|
||||
const mnemonic = JSON.parse(await loadOrCreateMnemonic()) as Mnemonic;
|
||||
const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic.phrase);
|
||||
return wallet.privateKey;
|
||||
}
|
||||
|
||||
export async function unsafe_clearSecrets() {
|
||||
if (__DEV__) {
|
||||
await Keychain.resetGenericPassword({ service: SERVICE_NAME });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import React, {
|
||||
import Keychain from 'react-native-keychain';
|
||||
|
||||
import { PassportData } from '../../../common/src/utils/types';
|
||||
import { loadSecretOrCreateIt } from '../stores/authProvider';
|
||||
import { unsafe_getPrivateKey } from '../stores/authProvider';
|
||||
import { useAuth } from './authProvider';
|
||||
|
||||
// TODO: refactor this as it shouldnt be used directly IMHO
|
||||
export async function loadPassportData() {
|
||||
const passportDataCreds = await Keychain.getGenericPassword({
|
||||
service: 'passportData',
|
||||
@@ -20,7 +21,7 @@ export async function loadPassportData() {
|
||||
|
||||
export async function loadPassportDataAndSecret() {
|
||||
const passportData = await loadPassportData();
|
||||
const secret = await loadSecretOrCreateIt();
|
||||
const secret = await unsafe_getPrivateKey();
|
||||
if (!secret || !passportData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
26
app/src/types/mnemonic.ts
Normal file
26
app/src/types/mnemonic.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface Mnemonic {
|
||||
/**
|
||||
* The mnemonic phrase of 12, 15, 18, 21 or 24 words.
|
||||
*
|
||||
* Use the [[wordlist]] ``split`` method to get the individual words.
|
||||
*/
|
||||
readonly phrase: string;
|
||||
|
||||
/**
|
||||
* The password used for this mnemonic. If no password is used this
|
||||
* is the empty string (i.e. ``""``) as per the specification.
|
||||
*/
|
||||
readonly password: string;
|
||||
|
||||
/**
|
||||
* The wordlist for this mnemonic.
|
||||
*/
|
||||
readonly wordlist: {
|
||||
readonly locale: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The underlying entropy which the mnemonic encodes.
|
||||
*/
|
||||
readonly entropy: string;
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
CloudStorageScope,
|
||||
} from 'react-native-cloud-storage';
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
import { name } from '../../../package.json';
|
||||
import { Mnemonic } from '../../types/mnemonic';
|
||||
import { googleSignIn } from './google';
|
||||
|
||||
const FOLDER = `/${name}`;
|
||||
@@ -44,7 +47,7 @@ async function withRetries<T>(
|
||||
);
|
||||
}
|
||||
|
||||
export function useBackupPrivateKey() {
|
||||
export function useBackupMnemonic() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
upload,
|
||||
@@ -68,10 +71,10 @@ async function addAccessTokenForGoogleDrive() {
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(privateKey: string) {
|
||||
if (!privateKey) {
|
||||
async function upload(mnemonic: Mnemonic) {
|
||||
if (!mnemonic || !mnemonic.phrase) {
|
||||
throw new Error(
|
||||
'Private key not set yet. Did the user see the recovery phrase?',
|
||||
'Mnemonic not set yet. Did the user see the recovery phrase?',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,17 +88,31 @@ async function upload(privateKey: string) {
|
||||
}
|
||||
}
|
||||
await withRetries(() =>
|
||||
CloudStorage.writeFile(ENCRYPTED_FILE_PATH, privateKey),
|
||||
CloudStorage.writeFile(ENCRYPTED_FILE_PATH, JSON.stringify(mnemonic)),
|
||||
);
|
||||
}
|
||||
|
||||
async function download() {
|
||||
await addAccessTokenForGoogleDrive();
|
||||
if (await CloudStorage.exists(ENCRYPTED_FILE_PATH)) {
|
||||
const privateKey = await withRetries(() =>
|
||||
const mnemonicString = await withRetries(() =>
|
||||
CloudStorage.readFile(ENCRYPTED_FILE_PATH),
|
||||
);
|
||||
return privateKey;
|
||||
|
||||
try {
|
||||
const mnemonic = JSON.parse(mnemonicString) as Mnemonic;
|
||||
if (
|
||||
!mnemonic.phrase ||
|
||||
!ethers.Mnemonic.isValidMnemonic(mnemonic.phrase)
|
||||
) {
|
||||
throw new Error();
|
||||
}
|
||||
return mnemonic;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Malformed mnemonic, expected JSON structure, got ${mnemonicString}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'Couldnt find the encrypted backup, did you back it up previously?',
|
||||
|
||||
27
app/src/utils/ethers.ts
Normal file
27
app/src/utils/ethers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// https://docs.ethers.org/v6/cookbook/react-native/
|
||||
import crypto from 'react-native-quick-crypto';
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
ethers.randomBytes.register(length => {
|
||||
return new Uint8Array(crypto.randomBytes(length));
|
||||
});
|
||||
|
||||
ethers.computeHmac.register((algo, key, data) => {
|
||||
return crypto.createHmac(algo, key).update(data).digest();
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
ethers.pbkdf2.register((passwd, salt, iter, keylen, algo) => {
|
||||
return crypto.pbkdf2Sync(passwd, salt, iter, keylen, algo);
|
||||
});
|
||||
|
||||
ethers.sha256.register(data => {
|
||||
// @ts-expect-error
|
||||
return crypto.createHash('sha256').update(data).digest();
|
||||
});
|
||||
|
||||
ethers.sha512.register(data => {
|
||||
// @ts-expect-error
|
||||
return crypto.createHash('sha512').update(data).digest();
|
||||
});
|
||||
@@ -1387,6 +1387,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@craftzdog/react-native-buffer@npm:^6.0.5":
|
||||
version: 6.0.5
|
||||
resolution: "@craftzdog/react-native-buffer@npm:6.0.5"
|
||||
dependencies:
|
||||
ieee754: "npm:^1.2.1"
|
||||
react-native-quick-base64: "npm:^2.0.5"
|
||||
checksum: 10c0/76923a9d982055e64e8a6e01fd5cb1cc291d32a95fa259be641fa0fe93da22adaf2d98b9ae2b960cc8c0a78112ec24903c9d80ce4d31b87e5d99c3f656c46c0c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@egjs/hammerjs@npm:^2.0.17":
|
||||
version: 2.0.17
|
||||
resolution: "@egjs/hammerjs@npm:2.0.17"
|
||||
@@ -8531,6 +8541,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"events@npm:^3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "events@npm:3.3.0"
|
||||
checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"execa@npm:^5.0.0, execa@npm:^5.1.1":
|
||||
version: 5.1.1
|
||||
resolution: "execa@npm:5.1.1"
|
||||
@@ -9394,6 +9411,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-arguments@npm:^1.0.4":
|
||||
version: 1.2.0
|
||||
resolution: "is-arguments@npm:1.2.0"
|
||||
dependencies:
|
||||
call-bound: "npm:^1.0.2"
|
||||
has-tostringtag: "npm:^1.0.2"
|
||||
checksum: 10c0/6377344b31e9fcb707c6751ee89b11f132f32338e6a782ec2eac9393b0cbd32235dad93052998cda778ee058754860738341d8114910d50ada5615912bb929fc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5":
|
||||
version: 3.0.5
|
||||
resolution: "is-array-buffer@npm:3.0.5"
|
||||
@@ -9540,7 +9567,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-generator-function@npm:^1.0.10":
|
||||
"is-generator-function@npm:^1.0.10, is-generator-function@npm:^1.0.7":
|
||||
version: 1.1.0
|
||||
resolution: "is-generator-function@npm:1.1.0"
|
||||
dependencies:
|
||||
@@ -9671,7 +9698,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15":
|
||||
"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3":
|
||||
version: 1.1.15
|
||||
resolution: "is-typed-array@npm:1.1.15"
|
||||
dependencies:
|
||||
@@ -11976,6 +12003,7 @@ __metadata:
|
||||
react-native-localize: "npm:^3.4.1"
|
||||
react-native-nfc-manager: "npm:^3.15.1"
|
||||
react-native-passport-reader: "npm:^1.0.3"
|
||||
react-native-quick-crypto: "npm:^0.7.12"
|
||||
react-native-safe-area-context: "npm:^5.2.0"
|
||||
react-native-screens: "npm:^4.6.0"
|
||||
react-native-svg: "npm:^15.11.1"
|
||||
@@ -12335,6 +12363,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"process@npm:^0.11.10":
|
||||
version: 0.11.10
|
||||
resolution: "process@npm:0.11.10"
|
||||
checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"promise-retry@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "promise-retry@npm:2.0.1"
|
||||
@@ -12633,6 +12668,31 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-quick-base64@npm:^2.0.5":
|
||||
version: 2.1.2
|
||||
resolution: "react-native-quick-base64@npm:2.1.2"
|
||||
dependencies:
|
||||
base64-js: "npm:^1.5.1"
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
react-native: "*"
|
||||
checksum: 10c0/716383251318f4eeeaec9845b9f6d3468bb9c9af58972a610d22a02961ccf839436d5fb7dac9cbb71275b7478ed6b766224d39ab20c122de3c6c70fa040d149f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-quick-crypto@npm:^0.7.12":
|
||||
version: 0.7.12
|
||||
resolution: "react-native-quick-crypto@npm:0.7.12"
|
||||
dependencies:
|
||||
"@craftzdog/react-native-buffer": "npm:^6.0.5"
|
||||
events: "npm:^3.3.0"
|
||||
readable-stream: "npm:^4.5.2"
|
||||
string_decoder: "npm:^1.3.0"
|
||||
util: "npm:^0.12.5"
|
||||
checksum: 10c0/acc7bf1da4f86845696a1afd3e4a3ac68eb3a333e65f6ab56499143e25fe13f435a86df3a2f6c32e9132e1f3ce0ba86d8d3e02876aff25ba2a30b6b2406d6b11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-safe-area-context@npm:^5.2.0":
|
||||
version: 5.2.0
|
||||
resolution: "react-native-safe-area-context@npm:5.2.0"
|
||||
@@ -12878,6 +12938,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:^4.5.2":
|
||||
version: 4.7.0
|
||||
resolution: "readable-stream@npm:4.7.0"
|
||||
dependencies:
|
||||
abort-controller: "npm:^3.0.0"
|
||||
buffer: "npm:^6.0.3"
|
||||
events: "npm:^3.3.0"
|
||||
process: "npm:^0.11.10"
|
||||
string_decoder: "npm:^1.3.0"
|
||||
checksum: 10c0/fd86d068da21cfdb10f7a4479f2e47d9c0a9b0c862fc0c840a7e5360201580a55ac399c764b12a4f6fa291f8cee74d9c4b7562e0d53b3c4b2769f2c98155d957
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:~2.3.6":
|
||||
version: 2.3.8
|
||||
resolution: "readable-stream@npm:2.3.8"
|
||||
@@ -13883,7 +13956,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"string_decoder@npm:^1.1.1":
|
||||
"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "string_decoder@npm:1.3.0"
|
||||
dependencies:
|
||||
@@ -14556,6 +14629,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"util@npm:^0.12.5":
|
||||
version: 0.12.5
|
||||
resolution: "util@npm:0.12.5"
|
||||
dependencies:
|
||||
inherits: "npm:^2.0.3"
|
||||
is-arguments: "npm:^1.0.4"
|
||||
is-generator-function: "npm:^1.0.7"
|
||||
is-typed-array: "npm:^1.1.3"
|
||||
which-typed-array: "npm:^1.1.2"
|
||||
checksum: 10c0/c27054de2cea2229a66c09522d0fa1415fb12d861d08523a8846bf2e4cbf0079d4c3f725f09dcb87493549bcbf05f5798dce1688b53c6c17201a45759e7253f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"utils-merge@npm:1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "utils-merge@npm:1.0.1"
|
||||
@@ -14708,7 +14794,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18":
|
||||
"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2":
|
||||
version: 1.1.18
|
||||
resolution: "which-typed-array@npm:1.1.18"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user