fix: refactor secret and passport-data management (#459)

* fix: refactor secret and passport-data management

* clean things up

* hadle biometric availablity state

* added unsafe_secret_privateKey & unsafe_clearSecrets in usePassport for devSettingsScreen

* remove setting of secret in loadingScreen as it's handled in usePassport internally
This commit is contained in:
ehsunahmadi
2025-03-22 00:15:03 +03:30
committed by GitHub
parent e1186977d5
commit 34b2af5cb5
19 changed files with 437 additions and 509 deletions

View File

@@ -3,6 +3,7 @@ import React, { useCallback, useState } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import { Button, Text, XStack, YStack } from 'tamagui';
import { usePassport } from '../stores/passportDataProvider';
import {
black,
slate50,
@@ -15,8 +16,7 @@ import {
import { confirmTap } from '../utils/haptic';
interface MnemonicProps {
words?: string[];
onRevealWords?: () => Promise<void>;
onRevealWords?: () => void;
}
interface WordPill {
index: number;
@@ -46,20 +46,20 @@ const WordPill = ({ index, word }: WordPill) => {
const REDACTED = new Array(24)
.fill('')
.map(_ => '*'.repeat(Math.max(4, Math.floor(Math.random() * 10))));
const Mnemonic = ({ words = REDACTED, onRevealWords }: MnemonicProps) => {
const Mnemonic = ({ onRevealWords }: MnemonicProps) => {
const { secret } = usePassport();
const [revealWords, setRevealWords] = useState(false);
const [copied, setCopied] = useState(false);
const copyToClipboardOrReveal = useCallback(async () => {
confirmTap();
if (!revealWords) {
// TODO: container jumps when words are revealed on android
await onRevealWords?.();
onRevealWords?.();
return setRevealWords(previous => !previous);
}
Clipboard.setString(words.join(' '));
Clipboard.setString(secret?.phrase || '');
setCopied(true);
setTimeout(() => setCopied(false), 2500);
}, [words, revealWords]);
}, [secret, revealWords, onRevealWords]);
return (
<YStack position="relative" alignItems="stretch" gap={0}>
@@ -75,9 +75,11 @@ const Mnemonic = ({ words = REDACTED, onRevealWords }: MnemonicProps) => {
paddingVertical={28}
flexWrap="wrap"
>
{(revealWords ? words : REDACTED).map((word, i) => (
<WordPill key={i} word={word} index={i} />
))}
{(revealWords && secret ? secret.phrase.split(' ') : REDACTED).map(
(word, i) => (
<WordPill key={i + word} word={word} index={i} />
),
)}
</XStack>
<XStack
borderTopColor={slate200}

View File

@@ -1,24 +0,0 @@
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,
};
}

View File

@@ -13,7 +13,7 @@ import Keyboard from '../../images/icons/keyboard.svg';
import RestoreAccountSvg from '../../images/icons/restore_account.svg';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { useAuth } from '../../stores/authProvider';
import { loadPassportDataAndSecret } from '../../stores/passportDataProvider';
import { usePassport } from '../../stores/passportDataProvider';
import { useSettingStore } from '../../stores/settingStore';
import { STORAGE_NAME, useBackupMnemonic } from '../../utils/cloudBackup';
import { black, slate500, slate600, white } from '../../utils/colors';
@@ -24,13 +24,12 @@ interface AccountRecoveryChoiceScreenProps {}
const AccountRecoveryChoiceScreen: React.FC<
AccountRecoveryChoiceScreenProps
> = ({}) => {
const { restoreAccountFromMnemonic } = useAuth();
const { passportData, restorefromSecret, status } = usePassport();
const [restoring, setRestoring] = useState(false);
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
useSettingStore();
const { cloudBackupEnabled, toggleCloudBackupEnabled } = useSettingStore();
const { biometricAvailablity } = useAuth();
const { download } = useBackupMnemonic();
const navigation = useNavigation();
const onRestoreFromCloudNext = useHapticNavigation('AccountVerifiedSuccess');
const onEnterRecoveryPress = useHapticNavigation('RecoverWithPhrase');
@@ -38,44 +37,57 @@ const AccountRecoveryChoiceScreen: React.FC<
setRestoring(true);
try {
const mnemonic = await download();
const result = await restoreAccountFromMnemonic(mnemonic.phrase);
try {
if (status !== 'success') {
return;
}
const secret = await restorefromSecret(mnemonic.phrase);
if (!secret || !passportData) {
console.warn('Secret or passport data is missing');
navigation.navigate('Launch');
setRestoring(false);
return;
}
if (!result) {
console.warn('Failed to restore account');
navigation.navigate('Launch');
setRestoring(false);
return;
}
const passportDataAndSecret =
(await loadPassportDataAndSecret()) as string;
const { passportData, secret } = JSON.parse(passportDataAndSecret);
const isRegistered = await isUserRegistered(passportData, secret);
console.log('User is registered:', isRegistered);
if (!isRegistered) {
console.log(
'Secret provided did not match a registered passport. Please try again.',
const isRegistered = await isUserRegistered(
passportData,
secret.password,
);
navigation.navigate('Launch');
setRestoring(false);
return;
}
console.log('User is registered:', isRegistered);
if (!isRegistered) {
console.log(
'Secret provided did not match a registered passport. Please try again.',
);
navigation.navigate('Launch');
setRestoring(false);
return;
}
if (!cloudBackupEnabled) {
toggleCloudBackupEnabled();
if (!cloudBackupEnabled) {
toggleCloudBackupEnabled();
}
onRestoreFromCloudNext();
setRestoring(false);
} catch (e) {
console.error(e);
setRestoring(false);
throw new Error('Something wrong happened during cloud recovery');
}
onRestoreFromCloudNext();
} catch (error) {
console.warn('Failed to restore account');
navigation.navigate('Launch');
setRestoring(false);
} catch (e) {
console.error(e);
setRestoring(false);
throw new Error('Something wrong happened during cloud recovery');
return;
}
}, [
cloudBackupEnabled,
download,
restoreAccountFromMnemonic,
restorefromSecret,
onRestoreFromCloudNext,
navigation.navigate,
passportData,
toggleCloudBackupEnabled,
status,
]);
return (
@@ -91,7 +103,7 @@ const AccountRecoveryChoiceScreen: React.FC<
<Description>
By continuing, you certify that this passport belongs to you and is
not stolen or forged.{' '}
{biometricsAvailable && (
{biometricAvailablity === 'available' && (
<>
Your device doesn't support biometrics or is disabled for apps
and is required for cloud storage.
@@ -102,7 +114,7 @@ const AccountRecoveryChoiceScreen: React.FC<
<YStack gap="$2.5" width="100%" pt="$6">
<PrimaryButton
onPress={onRestoreFromCloudPress}
disabled={restoring || !biometricsAvailable}
disabled={restoring || biometricAvailablity !== 'available'}
>
{restoring ? 'Restoring' : 'Restore'} from {STORAGE_NAME}
{restoring ? '' : ''}

View File

@@ -9,8 +9,7 @@ import { Text, TextArea, View, XStack, YStack } from 'tamagui';
import { SecondaryButton } from '../../components/buttons/SecondaryButton';
import Description from '../../components/typography/Description';
import Paste from '../../images/icons/paste.svg';
import { useAuth } from '../../stores/authProvider';
import { loadPassportDataAndSecret } from '../../stores/passportDataProvider';
import { usePassport } from '../../stores/passportDataProvider';
import {
black,
slate300,
@@ -27,7 +26,7 @@ const RecoverWithPhraseScreen: React.FC<
RecoverWithPhraseScreenProps
> = ({}) => {
const navigation = useNavigation();
const { restoreAccountFromMnemonic } = useAuth();
const { restorefromSecret, passportData, status } = usePassport();
const [mnemonic, setMnemonic] = useState<string>();
const [restoring, setRestoring] = useState(false);
const onPaste = useCallback(async () => {
@@ -39,6 +38,9 @@ const RecoverWithPhraseScreen: React.FC<
}, []);
const restoreAccount = useCallback(async () => {
if (status !== 'success') {
return;
}
setRestoring(true);
const slimMnemonic = mnemonic?.trim();
if (!slimMnemonic || !ethers.Mnemonic.isValidMnemonic(slimMnemonic)) {
@@ -46,31 +48,39 @@ const RecoverWithPhraseScreen: React.FC<
setRestoring(false);
return;
}
const result = await restoreAccountFromMnemonic(slimMnemonic);
if (!result) {
try {
const secret = await restorefromSecret(slimMnemonic);
if (!passportData || !secret) {
console.warn('Secret or passport data is missing');
navigation.navigate('Launch');
setRestoring(false);
return;
}
const isRegistered = await isUserRegistered(
passportData,
secret.password,
);
console.log('User is registered:', isRegistered);
if (!isRegistered) {
console.log(
'Secret provided did not match a registered passport. Please try again.',
);
navigation.navigate('Launch');
setRestoring(false);
return;
}
setRestoring(false);
navigation.navigate('AccountVerifiedSuccess');
} catch (error) {
console.warn('Failed to restore account');
navigation.navigate('Launch');
setRestoring(false);
return;
}
const passportDataAndSecret = (await loadPassportDataAndSecret()) as string;
const { passportData, secret } = JSON.parse(passportDataAndSecret);
const isRegistered = await isUserRegistered(passportData, secret);
console.log('User is registered:', isRegistered);
if (!isRegistered) {
console.log(
'Secret provided did not match a registered passport. Please try again.',
);
navigation.navigate('Launch');
setRestoring(false);
return;
}
setRestoring(false);
navigation.navigate('AccountVerifiedSuccess');
}, [mnemonic, restoreAccountFromMnemonic]);
}, [mnemonic, restorefromSecret, navigation, passportData, status]);
return (
<YStack alignItems="center" gap="$6" pb="$2.5" style={styles.layout}>

View File

@@ -7,7 +7,6 @@ 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 { STORAGE_NAME } from '../../utils/cloudBackup';
import { black, slate400, white } from '../../utils/colors';
@@ -18,12 +17,6 @@ const SaveRecoveryPhraseScreen: React.FC<
SaveRecoveryPhraseScreenProps
> = ({}) => {
const [userHasSeenMnemonic, setUserHasSeenMnemonic] = useState(false);
const { mnemonic, loadMnemonic } = useMnemonic();
const onRevealWords = useCallback(async () => {
await loadMnemonic();
setUserHasSeenMnemonic(true);
}, []);
const onCloudBackupPress = useHapticNavigation('CloudBackupSettings', {
params: { nextScreen: 'SaveRecoveryPhrase' },
@@ -31,6 +24,11 @@ const SaveRecoveryPhraseScreen: React.FC<
const onSkipPress = useHapticNavigation('AccountVerifiedSuccess', {
action: 'confirm',
});
const onRevealWords = useCallback(() => {
() => {
setUserHasSeenMnemonic(true);
};
}, []);
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
@@ -53,7 +51,7 @@ const SaveRecoveryPhraseScreen: React.FC<
gap={10}
backgroundColor={white}
>
<Mnemonic words={mnemonic} onRevealWords={onRevealWords} />
<Mnemonic onRevealWords={onRevealWords} />
<Caption color={slate400}>
You can reveal your recovery phrase in settings.
</Caption>

View File

@@ -27,13 +27,14 @@ import { PrimaryButton } from '../components/buttons/PrimaryButton';
import { SecondaryButton } from '../components/buttons/SecondaryButton';
import { BodyText } from '../components/typography/BodyText';
import { Title } from '../components/typography/Title';
import { storePassportData } from '../stores/passportDataProvider';
import { usePassport } from '../stores/passportDataProvider';
import { borderColor, separatorColor, textBlack, white } from '../utils/colors';
import { buttonTap, selectionChange } from '../utils/haptic';
interface MockDataScreenProps {}
const MockDataScreen: React.FC<MockDataScreenProps> = ({}) => {
const MockDataScreen: React.FC<MockDataScreenProps> = () => {
const { setPassportData } = usePassport(false);
const navigation = useNavigation();
const [age, setAge] = useState(24);
const [expiryYears, setExpiryYears] = useState(5);
@@ -194,7 +195,7 @@ const MockDataScreen: React.FC<MockDataScreenProps> = ({}) => {
);
}
mockPassportData = initPassportDataParsing(mockPassportData);
await storePassportData(mockPassportData);
await setPassportData(mockPassportData);
resolve(null);
}, 0),
);

View File

@@ -40,7 +40,7 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
};
const [animationSource, setAnimationSource] = useState<any>(miscAnimation);
const { registrationStatus, resetProof } = useProofInfo();
const { getPassportDataAndSecret, clearPassportData } = usePassport();
const { passportData, clearPassportData, secret, status } = usePassport();
useEffect(() => {
// TODO this makes sense if reset proof was only about passport registration
@@ -63,17 +63,20 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
}, [registrationStatus]);
const processPayloadCalled = useRef(false);
useEffect(() => {
if (!processPayloadCalled.current) {
processPayloadCalled.current = true;
const processPayload = async () => {
try {
const passportDataAndSecret = await getPassportDataAndSecret();
if (!passportDataAndSecret) {
if (status !== 'success') {
return;
}
if (!passportData || !secret) {
console.warn('no passportData or secret');
navigation.navigate('Launch');
return;
}
const { passportData, secret } = passportDataAndSecret.data;
const isSupported = await checkPassportSupported(passportData);
if (isSupported.status !== 'passport_supported') {
trackEvent('Passport not supported', {
@@ -85,7 +88,10 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
clearPassportData();
return;
}
const isRegistered = await isUserRegistered(passportData, secret);
const isRegistered = await isUserRegistered(
passportData,
secret.password,
);
console.log('User is registered:', isRegistered);
if (isRegistered) {
console.log(
@@ -103,7 +109,7 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
navigation.navigate('AccountRecoveryChoice');
return;
}
registerPassport(passportData, secret);
registerPassport(passportData, secret.password);
} catch (error) {
console.error('Error processing payload:', error);
setTimeout(() => resetProof(), 1000);
@@ -111,7 +117,15 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
};
processPayload();
}
}, []);
}, [
clearPassportData,
goToUnsupportedScreen,
passportData,
secret,
navigation.navigate,
resetProof,
status,
]);
return (
<View style={styles.container}>

View File

@@ -25,7 +25,7 @@ import { Title } from '../../components/typography/Title';
import useHapticNavigation from '../../hooks/useHapticNavigation';
import NFC_IMAGE from '../../images/nfc.png';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { storePassportData } from '../../stores/passportDataProvider';
import { usePassport } from '../../stores/passportDataProvider';
import useUserStore from '../../stores/userStore';
import analytics from '../../utils/analytics';
import { black, slate100, white } from '../../utils/colors';
@@ -42,6 +42,7 @@ const emitter =
: null;
const PassportNFCScanScreen: React.FC<PassportNFCScanScreenProps> = ({}) => {
const { setPassportData } = usePassport();
const navigation = useNavigation();
const { passportNumber, dateOfBirth, dateOfExpiry } = useUserStore();
const [dialogMessage, setDialogMessage] = useState('');
@@ -142,7 +143,7 @@ const PassportNFCScanScreen: React.FC<PassportNFCScanScreenProps> = ({}) => {
passportMetadata.cscaSignatureAlgorithmBits,
dsc: passportMetadata.dsc,
});
await storePassportData(parsedPassportData);
await setPassportData(parsedPassportData);
// Feels better somehow
await new Promise(resolve => setTimeout(resolve, 1000));
navigation.navigate('ConfirmBelongingScreen');

View File

@@ -40,7 +40,7 @@ import {
const ProveScreen: React.FC = () => {
const { navigate } = useNavigation();
const { getPassportDataAndSecret } = usePassport();
const { passportData, secret, status: passportStatus } = usePassport();
const { selectedApp, resetProof, cleanSelfApp } = useProofInfo();
const { handleProofVerified } = useApp();
const selectedAppRef = useRef(selectedApp);
@@ -107,9 +107,10 @@ const ProveScreen: React.FC = () => {
const onVerify = useCallback(
async function () {
if (isProcessing.current) {
if (passportStatus !== 'success' || isProcessing.current) {
return;
}
isProcessing.current = true;
resetProof();
@@ -119,25 +120,20 @@ const ProveScreen: React.FC = () => {
try {
let timeToNavigateToStatusScreen: NodeJS.Timeout;
const passportDataAndSecret = await getPassportDataAndSecret().catch(
(e: Error) => {
console.error('Error getting passport data', e);
globalSetDisclosureStatus?.(ProofStatusEnum.ERROR);
},
);
timeToNavigateToStatusScreen = setTimeout(() => {
navigate('ProofRequestStatusScreen');
}, 1000);
if (!passportDataAndSecret) {
if (!passportData || !secret) {
console.log('No passport data or secret');
globalSetDisclosureStatus?.(ProofStatusEnum.ERROR);
return;
}
const { passportData, secret } = passportDataAndSecret.data;
const isRegistered = await isUserRegistered(passportData, secret);
const isRegistered = await isUserRegistered(
passportData,
secret.password,
);
console.log('isRegistered', isRegistered);
if (!isRegistered) {
@@ -152,7 +148,7 @@ const ProveScreen: React.FC = () => {
console.log('currentApp', currentApp);
const status = await sendVcAndDisclosePayload(
secret,
secret.password,
passportData,
currentApp,
);
@@ -167,7 +163,14 @@ const ProveScreen: React.FC = () => {
isProcessing.current = false;
}
},
[navigate, getPassportDataAndSecret, handleProofVerified, resetProof],
[
navigate,
handleProofVerified,
resetProof,
cleanSelfApp,
passportData,
secret,
],
);
const handleScroll = useCallback(

View File

@@ -14,6 +14,7 @@ import { useModal } from '../../hooks/useModal';
import Cloud from '../../images/icons/logo_cloud_backup.svg';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { useAuth } from '../../stores/authProvider';
import { usePassport } from '../../stores/passportDataProvider';
import { useSettingStore } from '../../stores/settingStore';
import { STORAGE_NAME, useBackupMnemonic } from '../../utils/cloudBackup';
import { black, white } from '../../utils/colors';
@@ -32,9 +33,11 @@ interface CloudBackupScreenProps
const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
route: { params },
}) => {
const { getOrCreateMnemonic, loginWithBiometrics } = useAuth();
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
useSettingStore();
const { loginWithBiometrics } = useAuth();
const { secret, status } = usePassport();
const { cloudBackupEnabled, toggleCloudBackupEnabled } = useSettingStore();
const { biometricAvailablity } = useAuth();
const { upload, disableBackup } = useBackupMnemonic();
const [pending, setPending] = useState(false);
@@ -64,26 +67,20 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
const enableCloudBackups = useCallback(async () => {
buttonTap();
if (cloudBackupEnabled) {
if (cloudBackupEnabled || status !== 'success') {
return;
}
setPending(true);
const storedMnemonic = await getOrCreateMnemonic();
if (!storedMnemonic) {
if (!secret) {
setPending(false);
return;
}
await upload(storedMnemonic.data);
await upload(secret);
toggleCloudBackupEnabled();
setPending(false);
}, [
cloudBackupEnabled,
getOrCreateMnemonic,
upload,
toggleCloudBackupEnabled,
]);
}, [cloudBackupEnabled, upload, toggleCloudBackupEnabled, secret]);
const disableCloudBackups = useCallback(() => {
confirmTap();
@@ -112,7 +109,7 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
: `Your account will be end-to-end encrypted backed up to ${STORAGE_NAME} so you can easily restore it if you ever get a new phone.`}
</Description>
<Caption>
{biometricsAvailable ? (
{biometricAvailablity === 'available' ? (
<>
Learn more about <BackupDocumentationLink />
</>
@@ -128,7 +125,7 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
{cloudBackupEnabled ? (
<SecondaryButton
onPress={disableCloudBackups}
disabled={pending || !biometricsAvailable}
disabled={pending || biometricAvailablity !== 'available'}
>
{pending ? 'Disabling' : 'Disable'} {STORAGE_NAME} backups
{pending ? '' : ''}
@@ -136,7 +133,7 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
) : (
<PrimaryButton
onPress={enableCloudBackups}
disabled={pending || !biometricsAvailable}
disabled={pending || biometricAvailablity !== 'available'}
>
{pending ? 'Enabling' : 'Enable'} {STORAGE_NAME} backups
{pending ? '' : ''}

View File

@@ -1,4 +1,4 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import React, { PropsWithChildren } from 'react';
import { Platform, TextInput } from 'react-native';
import { useNavigation } from '@react-navigation/native';
@@ -22,14 +22,7 @@ import {
import { genMockPassportData } from '../../../../common/src/utils/passports/genMockPassportData';
import { RootStackParamList } from '../../Navigation';
import {
unsafe_clearSecrets,
unsafe_getPrivateKey,
} from '../../stores/authProvider';
import {
storePassportData,
usePassport,
} from '../../stores/passportDataProvider';
import { usePassport } from '../../stores/passportDataProvider';
import { borderColor, textBlack } from '../../utils/colors';
interface DevSettingsScreenProps {}
@@ -132,18 +125,26 @@ const ScreenSelector = ({}) => {
};
const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
const { clearPassportData } = usePassport();
const [privateKey, setPrivateKey] = useState('Loading private key…');
const {
clearPassportData,
setPassportData,
status,
unsafe_clearSecrets,
unsafe_secret_privateKey,
} = usePassport();
const nav = useNavigation();
async function handleRestart() {
if (status !== 'success') {
return;
}
await clearPassportData();
nav.navigate('Launch');
}
async function deleteEverything() {
await unsafe_clearSecrets();
await clearPassportData();
await handleRestart();
}
@@ -156,13 +157,9 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
'000101',
'300101',
);
storePassportData(passportData);
setPassportData(passportData);
}
useEffect(() => {
unsafe_getPrivateKey().then(setPrivateKey);
}, []);
return (
<YStack gap="$3" mt="$2" ai="center">
<Fieldset px="$4" horizontal width="100%" justifyContent="space-between">
@@ -266,7 +263,7 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
userSelect="all"
style={{ fontFamily: 'monospace', fontWeight: 'bold' }}
>
{privateKey}
{unsafe_secret_privateKey ?? ''}
</SelectableText>
</Fieldset>
</YStack>

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useState } from 'react';
import React from 'react';
import { useFocusEffect } from '@react-navigation/native';
import { ScrollView, Separator, XStack, YStack } from 'tamagui';
import { PassportMetadata } from '../../../../common/src/utils/passports/passport_parsing/parsePassportData';
@@ -54,27 +53,7 @@ const InfoRow: React.FC<{
interface PassportDataInfoScreenProps {}
const PassportDataInfoScreen: React.FC<PassportDataInfoScreenProps> = ({}) => {
const { getData } = usePassport();
const [metadata, setMetadata] = useState<PassportMetadata | null>(null);
const loadData = useCallback(async () => {
if (metadata) {
return;
}
const result = await getData();
if (!result || !result.data) {
// maybe handle error instead
return;
}
setMetadata(result.data.passportMetadata!);
}, [metadata, getData]);
useFocusEffect(() => {
loadData();
});
const { passportData } = usePassport();
return (
<ScrollView px="$4" backgroundColor={white}>
@@ -84,15 +63,15 @@ const PassportDataInfoScreen: React.FC<PassportDataInfoScreenProps> = ({}) => {
key={key}
label={label}
value={
!metadata
!passportData?.passportMetadata
? ''
: key === 'cscaFound'
? metadata?.cscaFound === true
? passportData.passportMetadata.cscaFound === true
? 'Yes'
: 'No'
: (metadata?.[key as keyof PassportMetadata] as
| string
| number) || 'None'
: (passportData.passportMetadata?.[
key as keyof PassportMetadata
] as string | number) || 'None'
}
/>
))}

View File

@@ -1,8 +1,7 @@
import React, { useCallback } from 'react';
import React from 'react';
import Mnemonic from '../../components/Mnemonic';
import Description from '../../components/typography/Description';
import useMnemonic from '../../hooks/useMnemonic';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
interface ShowRecoveryPhraseScreenProps {}
@@ -10,12 +9,6 @@ interface ShowRecoveryPhraseScreenProps {}
const ShowRecoveryPhraseScreen: React.FC<
ShowRecoveryPhraseScreenProps
> = ({}) => {
const { mnemonic, loadMnemonic } = useMnemonic();
const onRevealWords = useCallback(async () => {
await loadMnemonic();
}, []);
return (
<ExpandableBottomLayout.Layout backgroundColor="white">
<ExpandableBottomLayout.BottomSection
@@ -23,7 +16,7 @@ const ShowRecoveryPhraseScreen: React.FC<
justifyContent="center"
gap={20}
>
<Mnemonic words={mnemonic} onRevealWords={onRevealWords} />
<Mnemonic />
<Description>
This phrase is the only way to recover your account. Keep it secret,
keep it safe.

View File

@@ -1,46 +1,35 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import LottieView from 'lottie-react-native';
import splashAnimation from '../assets/animations/splash.json';
import { useAuth } from '../stores/authProvider';
import { loadPassportDataAndSecret } from '../stores/passportDataProvider';
import { useSettingStore } from '../stores/settingStore';
import { usePassport } from '../stores/passportDataProvider';
import { black } from '../utils/colors';
import { impactLight } from '../utils/haptic';
import { isUserRegistered } from '../utils/proving/payload';
const SplashScreen: React.FC = ({}) => {
const navigation = useNavigation();
const { createSigningKeyPair } = useAuth();
const { setBiometricsAvailable } = useSettingStore();
useEffect(() => {
createSigningKeyPair()
.then(setBiometricsAvailable)
.catch(err => {
console.warn(
'Something ELSE and totally unexpected went wrong during keypair creation',
err,
);
});
}, []);
const { passportData, secret, status } = usePassport(false);
const handleAnimationFinish = useCallback(() => {
setTimeout(async () => {
impactLight();
const passportDataAndSecret = await loadPassportDataAndSecret();
if (status !== 'success') {
return;
}
if (!passportDataAndSecret) {
if (!passportData || !secret) {
navigation.navigate('Launch');
return;
}
const { passportData, secret } = JSON.parse(passportDataAndSecret);
const isRegistered = await isUserRegistered(passportData, secret);
const isRegistered = await isUserRegistered(
passportData,
secret.password,
);
console.log('User is registered:', isRegistered);
if (isRegistered) {
console.log('Passport is registered already. Skipping to HomeScreen');
@@ -56,7 +45,7 @@ const SplashScreen: React.FC = ({}) => {
// Rest of the time, keep the LaunchScreen flow
navigation.navigate('Launch');
}, 1000);
}, [navigation]);
}, [navigation, passportData, secret, status]);
return (
<LottieView

View File

@@ -3,152 +3,46 @@ import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import ReactNativeBiometrics from 'react-native-biometrics';
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>,
formatter: (dataString: string) => T,
): Promise<SignedPayload<T> | null> {
console.log('Starting _getSecurely');
const keysExist = await biometrics.biometricKeysExist();
console.log('Biometric keys exist:', keysExist.keysExist);
if (!keysExist.keysExist) {
console.log('Creating new biometric keys');
await biometrics.createKeys();
}
const dataString = await fn();
console.log('Got data string:', dataString ? 'exists' : 'not found');
if (dataString === false) {
console.log('No data string available');
return null;
}
async function fetchBiometricAvailablity(): Promise<boolean> {
try {
const simpleCheck = await biometrics.simplePrompt({
promptMessage: 'Allow access to identity',
});
if (!simpleCheck.success) {
throw new Error('Authentication failed');
}
return {
signature: 'authenticated',
data: formatter(dataString),
};
const { available } = await biometrics.isSensorAvailable();
return available;
} catch (error) {
console.error('Error in _getSecurely:', error);
throw error;
}
};
async function createSigningKeyPair(): Promise<boolean> {
const { available } = await biometrics.isSensorAvailable();
if (!available) {
console.error('Error checking biometric availability:', error);
return false;
}
if ((await biometrics.biometricKeysExist()).keysExist) {
return true;
}
console.log('No enrolled public key. Creating a public key from biometrics');
try {
await biometrics.createKeys();
return true;
} catch (e) {
if (available) {
console.error(
"User has biometrics but somehow it wasn't able to create keys",
);
return false;
} else {
throw e;
}
}
}
async function restoreFromMnemonic(mnemonic: string) {
if (!mnemonic || !ethers.Mnemonic.isValidMnemonic(mnemonic)) {
throw new Error('Invalid mnemonic');
}
const restoredWallet = ethers.Wallet.fromPhrase(mnemonic);
const data = JSON.stringify(restoredWallet.mnemonic);
await Keychain.setGenericPassword('secret', data, {
service: SERVICE_NAME,
});
return data;
}
async function loadOrCreateMnemonic() {
const storedMnemonic = await Keychain.getGenericPassword({
service: SERVICE_NAME,
});
if (storedMnemonic) {
try {
JSON.parse(storedMnemonic.password);
console.log('Stored mnemonic parsed successfully');
return storedMnemonic.password;
} catch (e) {
console.log(
'Error parsing stored mnemonic, old secret format was used',
e,
);
console.log('Creating a new one');
}
}
console.log('No secret found, creating one');
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({
allowDeviceCredentials: true,
});
interface AuthProviderProps extends PropsWithChildren {
authenticationTimeoutinMs?: number;
}
type BiometricAvailablity =
| 'unknown'
| 'checking'
| 'available'
| 'unavailable';
interface IAuthContext {
isAuthenticated: boolean;
isAuthenticating: boolean;
loginWithBiometrics: () => Promise<void>;
_getSecurely: typeof _getSecurely;
getOrCreateMnemonic: () => Promise<SignedPayload<Mnemonic> | null>;
restoreAccountFromMnemonic: (
mnemonic: string,
) => Promise<SignedPayload<boolean> | null>;
createSigningKeyPair: () => Promise<boolean>;
biometricAvailablity: BiometricAvailablity;
}
export const AuthContext = createContext<IAuthContext>({
isAuthenticated: false,
isAuthenticating: false,
loginWithBiometrics: () => Promise.resolve(),
_getSecurely,
getOrCreateMnemonic: () => Promise.resolve(null),
restoreAccountFromMnemonic: () => Promise.resolve(null),
createSigningKeyPair: () => Promise.resolve(false),
biometricAvailablity: 'unknown',
});
export const AuthProvider = ({
@@ -158,68 +52,59 @@ export const AuthProvider = ({
const [_, setAuthenticatedTimeout] =
useState<ReturnType<typeof setTimeout>>();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthenticatingPromise, setIsAuthenticatingPromise] =
useState<Promise<{ success: boolean; error?: string }> | null>(null);
const [biometricAvailablity, setBiometricAvailablity] =
useState<BiometricAvailablity>('unknown');
const [isAuthenticating, setIsAuthenticating] = useState(false);
useEffect(() => {
fetchBiometricAvailablity();
}, []);
const loginWithBiometrics = useCallback(async () => {
if (isAuthenticatingPromise) {
await isAuthenticatingPromise;
return;
}
const promise = biometrics.simplePrompt({
promptMessage: 'Confirm your identity to access the stored secret',
});
setIsAuthenticatingPromise(promise);
const { success, error } = await promise;
if (error) {
setIsAuthenticatingPromise(null);
// handle error
throw error;
}
if (!success) {
// user canceled
throw new Error('Canceled by user');
}
setIsAuthenticatingPromise(null);
setIsAuthenticated(true);
setAuthenticatedTimeout(previousTimeout => {
if (previousTimeout) {
clearTimeout(previousTimeout);
setIsAuthenticating(true);
try {
const { success, error } = await biometrics.simplePrompt({
promptMessage: 'Confirm your identity to access the stored secret',
});
if (error) {
// handle error
throw error;
}
setBiometricAvailablity('available');
if (!success) {
// user canceled
throw new Error('Canceled by user');
}
return setTimeout(
() => setIsAuthenticated(false),
authenticationTimeoutinMs,
);
});
}, [isAuthenticatingPromise]);
const getOrCreateMnemonic = useCallback(
() => _getSecurely<Mnemonic>(loadOrCreateMnemonic, str => JSON.parse(str)),
[],
);
const restoreAccountFromMnemonic = useCallback(
(mnemonic: string) =>
_getSecurely<boolean>(
() => restoreFromMnemonic(mnemonic),
str => !!str,
),
[],
);
setIsAuthenticated(true);
setAuthenticatedTimeout(previousTimeout => {
if (previousTimeout) {
clearTimeout(previousTimeout);
}
return setTimeout(
() => setIsAuthenticated(false),
authenticationTimeoutinMs,
);
});
} finally {
setIsAuthenticating(false);
}
}, [authenticationTimeoutinMs]);
const state: IAuthContext = useMemo(
() => ({
isAuthenticated,
isAuthenticating: !!isAuthenticatingPromise,
isAuthenticating,
loginWithBiometrics,
getOrCreateMnemonic,
restoreAccountFromMnemonic,
createSigningKeyPair,
_getSecurely,
biometricAvailablity,
}),
[isAuthenticated, isAuthenticatingPromise, loginWithBiometrics],
[
isAuthenticated,
isAuthenticating,
loginWithBiometrics,
biometricAvailablity,
],
);
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
@@ -228,24 +113,3 @@ 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 });
}
}

View File

@@ -3,91 +3,184 @@ import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import Keychain from 'react-native-keychain';
import { PassportData } from '../../../common/src/utils/types';
import { unsafe_getPrivateKey } from '../stores/authProvider';
import { useAuth } from './authProvider';
import { type Mnemonic, ethers } from 'ethers';
// TODO: refactor this as it shouldnt be used directly IMHO
export async function loadPassportData() {
const passportDataCreds = await Keychain.getGenericPassword({
import type { PassportData } from '../../../common/src/utils/types';
import { useAuth } from '../stores/authProvider';
const password = 'passportData';
const SERVICE_NAME = 'secret';
export async function hasSecretStored() {
const seed = await Keychain.getGenericPassword({ service: SERVICE_NAME });
return !!seed;
}
async function storePassportDataInKeychain(passportData: PassportData) {
await Keychain.setGenericPassword(password, JSON.stringify(passportData), {
service: 'passportData',
});
return passportDataCreds === false ? false : passportDataCreds.password;
}
export async function loadPassportDataAndSecret() {
const passportData = await loadPassportData();
const secret = await unsafe_getPrivateKey();
if (!secret || !passportData) {
return false;
}
return JSON.stringify({
secret,
passportData: JSON.parse(passportData),
});
}
export async function storePassportData(passportData: PassportData) {
await Keychain.setGenericPassword(
'passportData',
JSON.stringify(passportData),
{ service: 'passportData' },
);
}
export async function clearPassportData() {
async function clearPassportDataFromKeychain() {
await Keychain.resetGenericPassword({ service: 'passportData' });
}
async function restoreFromMnemonic(mnemonic: string) {
if (!mnemonic || !ethers.Mnemonic.isValidMnemonic(mnemonic)) {
throw new Error('Invalid mnemonic');
}
const restoredWallet = ethers.Wallet.fromPhrase(mnemonic);
const data = JSON.stringify(restoredWallet.mnemonic);
await Keychain.setGenericPassword('secret', data, {
service: SERVICE_NAME,
});
return restoredWallet.mnemonic;
}
async function unsafe_clearSecrets() {
if (__DEV__) {
await Keychain.resetGenericPassword({ service: SERVICE_NAME });
}
}
interface PassportProviderProps extends PropsWithChildren {
authenticationTimeoutinMs?: number;
}
type Status = 'idle' | 'initializing' | 'updating' | 'error' | 'success';
interface IPassportContext {
getData: () => Promise<{ signature: string; data: PassportData } | null>;
setData: (data: PassportData) => Promise<void>;
getPassportDataAndSecret: () => Promise<{
data: { passportData: PassportData; secret: string };
signature: string;
} | null>;
passportData: PassportData | null;
secret: Mnemonic | null;
status: Status;
unsafe_secret_privateKey?: string;
setPassportData: (data: PassportData) => Promise<void>;
clearPassportData: () => Promise<void>;
setSecret: () => Promise<Mnemonic | null>;
restorefromSecret: (mnemonic: string) => Promise<Mnemonic | null>;
unsafe_clearSecrets: () => Promise<void>;
}
export const PassportContext = createContext<IPassportContext>({
getData: () => Promise.resolve(null),
setData: storePassportData,
getPassportDataAndSecret: () => Promise.resolve(null),
clearPassportData: clearPassportData,
const PassportContext = createContext<IPassportContext>({
passportData: null,
secret: null,
status: 'idle',
setPassportData: () => Promise.resolve(),
clearPassportData: () => Promise.resolve(),
setSecret: () => Promise.resolve(null),
restorefromSecret: () => Promise.resolve(null),
unsafe_clearSecrets: () => Promise.resolve(),
});
export const PassportProvider = ({ children }: PassportProviderProps) => {
const { _getSecurely } = useAuth();
const [status, setStatus] = useState<Status>('idle');
const [passportCache, setPasspotCache] = useState<PassportData | null>(null);
const [secretCache, setSecretCache] = useState<Mnemonic | null>(null);
const [unsafePrivateKey, setUnsafePrivateKey] = useState<string>();
const getPassportDataFromKeychain = useCallback(async () => {
const passportDataCreds = await Keychain.getGenericPassword({
service: 'passportData',
});
if (!passportDataCreds) {
return false;
}
return JSON.parse(passportDataCreds.password);
}, []);
const getData = useCallback(
() => _getSecurely<PassportData>(loadPassportData, str => JSON.parse(str)),
[_getSecurely],
);
const getSecretDataFromKeyChain = useCallback(async () => {
const storedMnemonic = await Keychain.getGenericPassword({
service: SERVICE_NAME,
});
if (storedMnemonic) {
const parsed = JSON.parse(storedMnemonic.password);
console.log('Stored mnemonic parsed successfully');
return parsed as Mnemonic;
}
}, []);
const getPassportDataAndSecret = useCallback(
() =>
_getSecurely<{ passportData: PassportData; secret: string }>(
loadPassportDataAndSecret,
str => JSON.parse(str),
),
[_getSecurely],
);
const isPassportNull = useMemo(() => !passportCache, [passportCache]);
useEffect(() => {
(async () => {
setStatus(isPassportNull ? 'initializing' : 'updating');
try {
const passportData = await getPassportDataFromKeychain();
if (passportData) {
setPasspotCache(passportData);
}
const secret =
(await getSecretDataFromKeyChain()) || (await setSecret());
if (secret) {
setSecretCache(secret);
}
setStatus('success');
} catch (error) {
console.error(
'Error fetching passport data or secret from keychain:',
error,
);
setStatus('error');
}
})();
}, [getPassportDataFromKeychain, getSecretDataFromKeyChain, isPassportNull]);
const setPassportData = useCallback(async (data: PassportData) => {
await storePassportDataInKeychain(data);
setPasspotCache(data);
}, []);
const setSecret = useCallback(async () => {
const { mnemonic, privateKey } = ethers.HDNodeWallet.fromMnemonic(
ethers.Mnemonic.fromEntropy(ethers.randomBytes(32)),
);
setUnsafePrivateKey(privateKey);
const data = JSON.stringify(mnemonic);
await Keychain.setGenericPassword('secret', data, {
service: SERVICE_NAME,
});
setSecretCache(mnemonic);
return mnemonic;
}, []);
const clearPassportData = useCallback(async () => {
await clearPassportDataFromKeychain();
setPasspotCache(null);
}, []);
const restorefromSecret = useCallback(async (mnemonic: string) => {
const data = await restoreFromMnemonic(mnemonic);
setSecretCache(data);
return data;
}, []);
const state: IPassportContext = useMemo(
() => ({
getData,
setData: storePassportData,
getPassportDataAndSecret,
clearPassportData: clearPassportData,
passportData: passportCache,
secret: secretCache,
status,
setPassportData,
clearPassportData,
restorefromSecret,
setSecret,
unsafe_clearSecrets,
unsafe_secret_privateKey: unsafePrivateKey,
}),
[getData, getPassportDataAndSecret],
[
passportCache,
secretCache,
status,
setPassportData,
clearPassportData,
restorefromSecret,
setSecret,
unsafePrivateKey,
],
);
return (
@@ -97,6 +190,18 @@ export const PassportProvider = ({ children }: PassportProviderProps) => {
);
};
export const usePassport = () => {
return useContext(PassportContext);
export const usePassport = (auth = true) => {
const c = useContext(PassportContext);
if (!c) {
throw new Error('usePassport must be used within a PassportProvider');
}
const { isAuthenticated, isAuthenticating, loginWithBiometrics } = useAuth();
useEffect(() => {
if (!isAuthenticated && !isAuthenticating && auth) {
loginWithBiometrics();
}
}, [isAuthenticated, loginWithBiometrics, auth, isAuthenticating]);
return c;
};

View File

@@ -9,6 +9,7 @@ import React, {
import { SelfApp } from '../../../common/src/utils/appType';
import { setupUniversalLinkListener } from '../utils/qrCodeNew';
import { usePassport } from './passportDataProvider';
export enum ProofStatusEnum {
PENDING = 'pending',
@@ -60,6 +61,7 @@ export let globalSetDisclosureStatus:
store to manage the proof verification process, including app the is requesting, intemidiate status and final result
*/
export function ProofProvider({ children }: PropsWithChildren<{}>) {
const { passportData, secret } = usePassport(false);
const [registrationStatus, setRegistrationStatus] = useState<ProofStatusEnum>(
ProofStatusEnum.PENDING,
);
@@ -97,14 +99,16 @@ export function ProofProvider({ children }: PropsWithChildren<{}>) {
globalSetRegistrationStatus = null;
globalSetDisclosureStatus = null;
};
}, [setRegistrationStatus, setDisclosureStatus]);
}, []);
useEffect(() => {
const universalLinkCleanup = setupUniversalLinkListener(setSelectedApp);
return () => {
universalLinkCleanup();
};
}, []);
if (passportData && secret) {
const universalLinkCleanup = setupUniversalLinkListener(setSelectedApp);
return () => {
universalLinkCleanup();
};
}
}, [passportData, secret, setSelectedApp]);
const publicApi: IProofContext = useMemo(
() => ({

View File

@@ -5,8 +5,6 @@ import { createJSONStorage, persist } from 'zustand/middleware';
interface SettingsState {
hasPrivacyNoteBeenDismissed: boolean;
dismissPrivacyNote: () => void;
biometricsAvailable: boolean;
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
cloudBackupEnabled: boolean;
toggleCloudBackupEnabled: () => void;
isDevMode: boolean;
@@ -23,12 +21,6 @@ export const useSettingStore = create<SettingsState>()(
hasPrivacyNoteBeenDismissed: false,
dismissPrivacyNote: () => set({ hasPrivacyNoteBeenDismissed: true }),
biometricsAvailable: false,
setBiometricsAvailable: biometricsAvailable =>
set({
biometricsAvailable,
}),
cloudBackupEnabled: false,
toggleCloudBackupEnabled: () =>
set(oldState => ({

View File

@@ -4,36 +4,27 @@ import { decode } from 'msgpack-lite';
import { inflate } from 'pako';
import { SelfApp } from '../../../common/src/utils/appType';
import { loadPassportData } from '../stores/passportDataProvider';
export default async function handleQRCodeScan(
result: string,
setApp: (app: SelfApp) => void,
) {
try {
const passportData = await loadPassportData();
if (passportData) {
const decodedResult = atob(result);
const uint8Array = new Uint8Array(
decodedResult.split('').map(char => char.charCodeAt(0)),
);
const decompressedData = inflate(uint8Array);
const unpackedData = decode(decompressedData);
const openPassportApp: SelfApp = unpackedData;
const decodedResult = atob(result);
const uint8Array = new Uint8Array(
decodedResult.split('').map(char => char.charCodeAt(0)),
);
const decompressedData = inflate(uint8Array);
const unpackedData = decode(decompressedData);
const openPassportApp: SelfApp = unpackedData;
setApp(openPassportApp);
console.log('✅', {
message: 'QR code scanned',
customData: {
type: 'success',
},
});
} else {
console.log('Welcome', {
message: 'Please register your passport first',
type: 'info',
});
}
setApp(openPassportApp);
console.log('✅', {
message: 'QR code scanned',
customData: {
type: 'success',
},
});
} catch (error) {
console.error('Error parsing QR code result:', error);
console.log('Try again', {