mirror of
https://github.com/selfxyz/self.git
synced 2026-04-05 03:00:53 -04:00
Hotfix/audit fixes (#1193)
* Fix - Application Allows Cleartext Traffic * Fix: Insecure Keychain Protection Class * fix: Local Authentication Bypass * remove console.logs * feat: Add migrateToSecureKeychain function placeholder for web * Improve clearText traffic fix * update review comments * update review comments
This commit is contained in:
@@ -6,6 +6,8 @@ import React from 'react';
|
||||
import { SystemBars } from 'react-native-edge-to-edge';
|
||||
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
|
||||
import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
|
||||
import DeferredLinkingInfoScreen from '@/screens/system/DeferredLinkingInfoScreen';
|
||||
import LaunchScreen from '@/screens/system/LaunchScreen';
|
||||
import LoadingScreen from '@/screens/system/Loading';
|
||||
@@ -24,6 +26,11 @@ const systemScreens = {
|
||||
options: {
|
||||
headerShown: false,
|
||||
} as NativeStackNavigationOptions,
|
||||
params: {} as {
|
||||
documentCategory?: DocumentCategory;
|
||||
signatureAlgorithm?: string;
|
||||
curveOrExponent?: string;
|
||||
},
|
||||
},
|
||||
Modal: {
|
||||
screen: ModalScreen,
|
||||
|
||||
@@ -12,28 +12,64 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import ReactNativeBiometrics from 'react-native-biometrics';
|
||||
import Keychain from 'react-native-keychain';
|
||||
import Keychain, { GetOptions, SetOptions } from 'react-native-keychain';
|
||||
|
||||
import { AuthEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import type { Mnemonic } from '@/types/mnemonic';
|
||||
import analytics from '@/utils/analytics';
|
||||
import {
|
||||
createKeychainOptions,
|
||||
detectSecurityCapabilities,
|
||||
GetSecureOptions,
|
||||
} from '@/utils/keychainSecurity';
|
||||
|
||||
const { trackEvent } = analytics();
|
||||
|
||||
const SERVICE_NAME = 'secret';
|
||||
|
||||
type SignedPayload<T> = { signature: string; data: T };
|
||||
type KeychainOptions = {
|
||||
getOptions: GetOptions;
|
||||
setOptions: SetOptions;
|
||||
};
|
||||
const _getSecurely = async function <T>(
|
||||
fn: (keychainOptions: KeychainOptions) => Promise<string | false>,
|
||||
formatter: (dataString: string) => T,
|
||||
options: GetSecureOptions,
|
||||
): Promise<SignedPayload<T> | null> {
|
||||
try {
|
||||
const capabilities = await detectSecurityCapabilities();
|
||||
const { getOptions, setOptions } = await createKeychainOptions(
|
||||
options,
|
||||
capabilities,
|
||||
);
|
||||
const dataString = await fn({ getOptions, setOptions });
|
||||
if (dataString === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
trackEvent(AuthEvents.BIOMETRIC_AUTH_SUCCESS);
|
||||
return {
|
||||
signature: 'authenticated',
|
||||
data: formatter(dataString),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
trackEvent(AuthEvents.BIOMETRIC_AUTH_FAILED, {
|
||||
reason: 'unknown_error',
|
||||
error: message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const _getWithBiometrics = async function <T>(
|
||||
fn: () => Promise<string | false>,
|
||||
formatter: (dataString: string) => T,
|
||||
options: GetSecureOptions,
|
||||
): Promise<SignedPayload<T> | null> {
|
||||
const dataString = await fn();
|
||||
if (dataString === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const simpleCheck = await biometrics.simplePrompt({
|
||||
promptMessage: 'Allow access to identity',
|
||||
@@ -47,13 +83,16 @@ const _getSecurely = async function <T>(
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
trackEvent(AuthEvents.BIOMETRIC_AUTH_SUCCESS);
|
||||
const dataString = await fn();
|
||||
if (dataString === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
signature: 'authenticated',
|
||||
data: formatter(dataString),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error('Error in _getSecurely:', error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
trackEvent(AuthEvents.BIOMETRIC_AUTH_FAILED, {
|
||||
reason: 'unknown_error',
|
||||
@@ -79,7 +118,10 @@ async function checkBiometricsAvailable(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreFromMnemonic(mnemonic: string): Promise<string | false> {
|
||||
async function restoreFromMnemonic(
|
||||
mnemonic: string,
|
||||
options: KeychainOptions,
|
||||
): Promise<string | false> {
|
||||
if (!mnemonic || !ethers.Mnemonic.isValidMnemonic(mnemonic)) {
|
||||
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
|
||||
reason: 'invalid_mnemonic',
|
||||
@@ -91,6 +133,7 @@ async function restoreFromMnemonic(mnemonic: string): Promise<string | false> {
|
||||
const restoredWallet = ethers.Wallet.fromPhrase(mnemonic);
|
||||
const data = JSON.stringify(restoredWallet.mnemonic);
|
||||
await Keychain.setGenericPassword('secret', data, {
|
||||
...options.setOptions,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
trackEvent(AuthEvents.MNEMONIC_RESTORE_SUCCESS);
|
||||
@@ -104,8 +147,14 @@ async function restoreFromMnemonic(mnemonic: string): Promise<string | false> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrCreateMnemonic(): Promise<string | false> {
|
||||
async function loadOrCreateMnemonic(
|
||||
keychainOptions: KeychainOptions,
|
||||
): Promise<string | false> {
|
||||
// Get adaptive security configuration
|
||||
const { setOptions, getOptions } = keychainOptions;
|
||||
|
||||
const storedMnemonic = await Keychain.getGenericPassword({
|
||||
...getOptions,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
if (storedMnemonic) {
|
||||
@@ -129,7 +178,9 @@ async function loadOrCreateMnemonic(): Promise<string | false> {
|
||||
ethers.Mnemonic.fromEntropy(ethers.randomBytes(32)),
|
||||
);
|
||||
const data = JSON.stringify(mnemonic);
|
||||
|
||||
await Keychain.setGenericPassword('secret', data, {
|
||||
...setOptions,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
trackEvent(AuthEvents.MNEMONIC_CREATED);
|
||||
@@ -154,6 +205,7 @@ interface IAuthContext {
|
||||
isAuthenticating: boolean;
|
||||
loginWithBiometrics: () => Promise<void>;
|
||||
_getSecurely: typeof _getSecurely;
|
||||
_getWithBiometrics: typeof _getWithBiometrics;
|
||||
getOrCreateMnemonic: () => Promise<SignedPayload<Mnemonic> | null>;
|
||||
restoreAccountFromMnemonic: (
|
||||
mnemonic: string,
|
||||
@@ -165,6 +217,7 @@ export const AuthContext = createContext<IAuthContext>({
|
||||
isAuthenticating: false,
|
||||
loginWithBiometrics: () => Promise.resolve(),
|
||||
_getSecurely,
|
||||
_getWithBiometrics,
|
||||
getOrCreateMnemonic: () => Promise.resolve(null),
|
||||
restoreAccountFromMnemonic: () => Promise.resolve(null),
|
||||
checkBiometricsAvailable: () => Promise.resolve(false),
|
||||
@@ -219,15 +272,25 @@ export const AuthProvider = ({
|
||||
}, [authenticationTimeoutinMs, isAuthenticatingPromise]);
|
||||
|
||||
const getOrCreateMnemonic = useCallback(
|
||||
() => _getSecurely<Mnemonic>(loadOrCreateMnemonic, str => JSON.parse(str)),
|
||||
() =>
|
||||
_getSecurely<Mnemonic>(
|
||||
keychainOptions => loadOrCreateMnemonic(keychainOptions),
|
||||
str => JSON.parse(str),
|
||||
{
|
||||
requireAuth: false,
|
||||
},
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const restoreAccountFromMnemonic = useCallback(
|
||||
(mnemonic: string) =>
|
||||
_getSecurely<boolean>(
|
||||
() => restoreFromMnemonic(mnemonic),
|
||||
keychainOptions => restoreFromMnemonic(mnemonic, keychainOptions),
|
||||
str => !!str,
|
||||
{
|
||||
requireAuth: true,
|
||||
},
|
||||
),
|
||||
[],
|
||||
);
|
||||
@@ -241,6 +304,7 @@ export const AuthProvider = ({
|
||||
restoreAccountFromMnemonic,
|
||||
checkBiometricsAvailable,
|
||||
_getSecurely,
|
||||
_getWithBiometrics,
|
||||
}),
|
||||
[
|
||||
getOrCreateMnemonic,
|
||||
@@ -259,6 +323,54 @@ export async function hasSecretStored() {
|
||||
return !!seed;
|
||||
}
|
||||
|
||||
// Migrates existing mnemonic to use new security settings with accessControl.
|
||||
export async function migrateToSecureKeychain(): Promise<boolean> {
|
||||
try {
|
||||
const { hasCompletedKeychainMigration, setKeychainMigrationCompleted } =
|
||||
useSettingStore.getState();
|
||||
|
||||
if (hasCompletedKeychainMigration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// we try to get with old settings (no accessControl)
|
||||
const existingMnemonic = await Keychain.getGenericPassword({
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
|
||||
if (!existingMnemonic) {
|
||||
setKeychainMigrationCompleted();
|
||||
return false;
|
||||
}
|
||||
|
||||
const capabilities = await detectSecurityCapabilities();
|
||||
const { setOptions } = await createKeychainOptions(
|
||||
{ requireAuth: true },
|
||||
capabilities,
|
||||
);
|
||||
|
||||
await Keychain.setGenericPassword(SERVICE_NAME, existingMnemonic.password, {
|
||||
...setOptions,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
|
||||
trackEvent(AuthEvents.MNEMONIC_CREATED, { migrated: true });
|
||||
|
||||
setKeychainMigrationCompleted();
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error('Error during keychain migration:', error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
|
||||
reason: 'migration_failed',
|
||||
error: message,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsafe_clearSecrets() {
|
||||
if (__DEV__) {
|
||||
await Keychain.resetGenericPassword({ service: SERVICE_NAME });
|
||||
@@ -269,8 +381,14 @@ export async function unsafe_clearSecrets() {
|
||||
* 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 foundMnemonic = await loadOrCreateMnemonic();
|
||||
export async function unsafe_getPrivateKey(keychainOptions?: KeychainOptions) {
|
||||
const options =
|
||||
keychainOptions ||
|
||||
(await createKeychainOptions({
|
||||
requireAuth: true,
|
||||
}));
|
||||
|
||||
const foundMnemonic = await loadOrCreateMnemonic(options);
|
||||
if (!foundMnemonic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@ const _getSecurely = async function <T>(
|
||||
data: formatter(dataString),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error('Error in _getSecurely:', error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
trackEvent(AuthEvents.BIOMETRIC_AUTH_FAILED, {
|
||||
reason: 'unknown_error',
|
||||
@@ -271,6 +270,11 @@ export async function hasSecretStored() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function migrateToSecureKeychain(): Promise<boolean> {
|
||||
console.warn('migrateToSecureKeychain is not implemented for web');
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function unsafe_clearSecrets() {
|
||||
if (__DEV__) {
|
||||
console.warn('unsafe_clearSecrets is not implemented for web');
|
||||
|
||||
@@ -66,6 +66,7 @@ import type { DocumentsAdapter, SelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider';
|
||||
import { createKeychainOptions } from '@/utils/keychainSecurity';
|
||||
|
||||
// Create safe wrapper functions to prevent undefined errors during early initialization
|
||||
// These need to be declared early to avoid dependency issues
|
||||
@@ -142,18 +143,28 @@ export const PassportContext = createContext<IPassportContext>({
|
||||
});
|
||||
|
||||
export const PassportProvider = ({ children }: PassportProviderProps) => {
|
||||
const { _getSecurely } = useAuth();
|
||||
const { _getSecurely, _getWithBiometrics } = useAuth();
|
||||
const selfClient = useSelfClient();
|
||||
|
||||
const getData = useCallback(
|
||||
() => _getSecurely<PassportData>(loadPassportData, str => JSON.parse(str)),
|
||||
[_getSecurely],
|
||||
() =>
|
||||
_getWithBiometrics<PassportData>(
|
||||
loadPassportData,
|
||||
str => JSON.parse(str),
|
||||
{
|
||||
requireAuth: true,
|
||||
},
|
||||
),
|
||||
[_getWithBiometrics],
|
||||
);
|
||||
|
||||
const getSelectedData = useCallback(() => {
|
||||
return _getSecurely<PassportData>(
|
||||
() => loadSelectedPassportData(),
|
||||
str => JSON.parse(str),
|
||||
{
|
||||
requireAuth: true,
|
||||
},
|
||||
);
|
||||
}, [_getSecurely]);
|
||||
|
||||
@@ -169,6 +180,9 @@ export const PassportProvider = ({ children }: PassportProviderProps) => {
|
||||
_getSecurely<{ passportData: PassportData; secret: string }>(
|
||||
loadPassportDataAndSecret,
|
||||
str => JSON.parse(str),
|
||||
{
|
||||
requireAuth: true,
|
||||
},
|
||||
),
|
||||
[_getSecurely],
|
||||
);
|
||||
@@ -177,6 +191,9 @@ export const PassportProvider = ({ children }: PassportProviderProps) => {
|
||||
return _getSecurely<{ passportData: PassportData; secret: string }>(
|
||||
() => loadSelectedPassportDataAndSecret(),
|
||||
str => JSON.parse(str),
|
||||
{
|
||||
requireAuth: true,
|
||||
},
|
||||
);
|
||||
}, [_getSecurely]);
|
||||
|
||||
@@ -720,8 +737,11 @@ export async function reStorePassportDataWithRightCSCA(
|
||||
export async function saveDocumentCatalogDirectlyToKeychain(
|
||||
catalog: DocumentCatalog,
|
||||
): Promise<void> {
|
||||
const { setOptions } = await createKeychainOptions({ requireAuth: false });
|
||||
await Keychain.setGenericPassword('catalog', JSON.stringify(catalog), {
|
||||
service: 'documentCatalog',
|
||||
...setOptions,
|
||||
// securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -749,8 +769,11 @@ async function storeDocumentDirectlyToKeychain(
|
||||
contentHash: string,
|
||||
passportData: PassportData | AadhaarData,
|
||||
): Promise<void> {
|
||||
const { setOptions } = await createKeychainOptions({ requireAuth: false });
|
||||
await Keychain.setGenericPassword(contentHash, JSON.stringify(passportData), {
|
||||
service: `document-${contentHash}`,
|
||||
...setOptions,
|
||||
// securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import type { StaticScreenProps } from '@react-navigation/native';
|
||||
import { usePreventRemove } from '@react-navigation/native';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import {
|
||||
PassportEvents,
|
||||
ProofEvents,
|
||||
@@ -34,16 +35,62 @@ type ConfirmBelongingScreenProps = StaticScreenProps<Record<string, never>>;
|
||||
|
||||
const ConfirmBelongingScreen: React.FC<ConfirmBelongingScreenProps> = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const [documentMetadata, setDocumentMetadata] = useState<{
|
||||
documentCategory?: DocumentCategory;
|
||||
signatureAlgorithm?: string;
|
||||
curveOrExponent?: string;
|
||||
}>({});
|
||||
const { trackEvent } = selfClient;
|
||||
const navigate = useHapticNavigation('Loading', {
|
||||
params: {},
|
||||
params: {
|
||||
documentCategory: documentMetadata.documentCategory,
|
||||
signatureAlgorithm: documentMetadata.signatureAlgorithm,
|
||||
curveOrExponent: documentMetadata.curveOrExponent,
|
||||
},
|
||||
});
|
||||
const [_requestingPermission, setRequestingPermission] = useState(false);
|
||||
const setFcmToken = useSettingStore(state => state.setFcmToken);
|
||||
|
||||
useEffect(() => {
|
||||
notificationSuccess();
|
||||
}, []);
|
||||
|
||||
const initializeProving = async () => {
|
||||
try {
|
||||
const selectedDocument = await loadSelectedDocument(selfClient);
|
||||
let metadata: {
|
||||
documentCategory?: DocumentCategory;
|
||||
signatureAlgorithm?: string;
|
||||
curveOrExponent?: string;
|
||||
};
|
||||
if (selectedDocument?.data?.documentCategory === 'aadhaar') {
|
||||
metadata = {
|
||||
documentCategory: 'aadhaar',
|
||||
signatureAlgorithm: 'rsa',
|
||||
curveOrExponent: '65537',
|
||||
};
|
||||
} else {
|
||||
const passportData = selectedDocument?.data;
|
||||
metadata = {
|
||||
documentCategory: passportData?.documentCategory,
|
||||
signatureAlgorithm:
|
||||
passportData?.passportMetadata?.cscaSignatureAlgorithm,
|
||||
curveOrExponent:
|
||||
passportData?.passportMetadata?.cscaCurveOrExponent,
|
||||
};
|
||||
}
|
||||
setDocumentMetadata(metadata);
|
||||
} catch (error) {
|
||||
// setting defaults on error
|
||||
setDocumentMetadata({
|
||||
documentCategory: 'passport',
|
||||
signatureAlgorithm: 'rsa',
|
||||
curveOrExponent: '65537',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initializeProving();
|
||||
}, [selfClient]);
|
||||
|
||||
const onOkPress = async () => {
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Text, YStack } from 'tamagui';
|
||||
import type { StaticScreenProps } from '@react-navigation/native';
|
||||
import { useFocusEffect, useIsFocused } from '@react-navigation/native';
|
||||
|
||||
import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
import { IDDocument } from '@selfxyz/common/utils/types';
|
||||
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { ProvingStateType } from '@selfxyz/mobile-sdk-alpha/browser';
|
||||
@@ -27,7 +28,13 @@ import { loadingScreenProgress } from '@/utils/haptic';
|
||||
import { setupNotifications } from '@/utils/notifications/notificationService';
|
||||
import { getLoadingScreenText } from '@/utils/proving/loadingScreenStateText';
|
||||
|
||||
type LoadingScreenProps = StaticScreenProps<Record<string, never>>;
|
||||
type LoadingScreenParams = {
|
||||
documentCategory?: DocumentCategory;
|
||||
signatureAlgorithm?: string;
|
||||
curveOrExponent?: string;
|
||||
};
|
||||
|
||||
type LoadingScreenProps = StaticScreenProps<LoadingScreenParams>;
|
||||
|
||||
// Define all terminal states that should stop animations and haptics
|
||||
const terminalStates: ProvingStateType[] = [
|
||||
@@ -39,7 +46,7 @@ const terminalStates: ProvingStateType[] = [
|
||||
'passport_data_not_found',
|
||||
];
|
||||
|
||||
const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
|
||||
const LoadingScreen: React.FC<LoadingScreenProps> = ({ route }) => {
|
||||
const { useProvingStore } = useSelfClient();
|
||||
// Track if we're initializing to show clean state
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
@@ -49,9 +56,6 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
|
||||
LottieView['props']['source']
|
||||
>(proveLoadingAnimation);
|
||||
|
||||
// Passport data state
|
||||
const [passportData, setPassportData] = useState<IDDocument | null>(null);
|
||||
|
||||
// Loading text state
|
||||
const [loadingText, setLoadingText] = useState<{
|
||||
actionText: string;
|
||||
@@ -65,6 +69,14 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
|
||||
statusBarProgress: 0,
|
||||
});
|
||||
|
||||
// Get document metadata from navigation params
|
||||
const {
|
||||
documentCategory,
|
||||
signatureAlgorithm: paramSignatureAlgorithm,
|
||||
curveOrExponent: paramCurveOrExponent,
|
||||
} = route?.params || {};
|
||||
|
||||
// Get current state from proving machine, default to 'idle' if undefined
|
||||
// Get proving store and self client
|
||||
const selfClient = useSelfClient();
|
||||
const currentState = useProvingStore(state => state.currentState) ?? 'idle';
|
||||
@@ -95,7 +107,7 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
|
||||
await init(selfClient, 'dsc', true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading selected document:', error);
|
||||
console.error('Error loading selected document:');
|
||||
await init(selfClient, 'dsc', true);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
@@ -107,59 +119,34 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
|
||||
|
||||
// Initialize notifications and load passport data
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
if (!isFocused) return;
|
||||
|
||||
const initialize = async () => {
|
||||
if (!isFocused) return;
|
||||
|
||||
// Setup notifications
|
||||
const unsubscribe = setupNotifications();
|
||||
|
||||
// Load passport data if not already loaded
|
||||
if (!passportData) {
|
||||
try {
|
||||
const result = await loadPassportDataAndSecret();
|
||||
if (result && isMounted) {
|
||||
const { passportData: _passportData } = JSON.parse(result);
|
||||
setPassportData(_passportData);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Error loading passport data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
initialize();
|
||||
// Setup notifications
|
||||
const unsubscribe = setupNotifications();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFocused]); // Only depend on isFocused
|
||||
}, [isFocused]);
|
||||
|
||||
// Handle UI updates based on state changes
|
||||
useEffect(() => {
|
||||
let { signatureAlgorithm, curveOrExponent } = {
|
||||
signatureAlgorithm: 'rsa',
|
||||
curveOrExponent: '65537',
|
||||
};
|
||||
switch (passportData?.documentCategory) {
|
||||
case 'passport':
|
||||
case 'id_card':
|
||||
if (passportData?.passportMetadata) {
|
||||
signatureAlgorithm =
|
||||
passportData?.passportMetadata?.cscaSignatureAlgorithm;
|
||||
curveOrExponent = passportData?.passportMetadata?.cscaCurveOrExponent;
|
||||
}
|
||||
break;
|
||||
case 'aadhaar':
|
||||
break; // keep the default values for aadhaar
|
||||
// Stop haptics if screen is not focused
|
||||
if (!isFocused) {
|
||||
loadingScreenProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use params from navigation or fallback to defaults
|
||||
let signatureAlgorithm = 'rsa';
|
||||
let curveOrExponent = '65537';
|
||||
|
||||
// Use provided params if available (only relevant for passport/id_card)
|
||||
if (paramSignatureAlgorithm && paramCurveOrExponent) {
|
||||
signatureAlgorithm = paramSignatureAlgorithm;
|
||||
curveOrExponent = paramCurveOrExponent;
|
||||
}
|
||||
|
||||
// Use clean initial state if we're initializing, otherwise use current state
|
||||
@@ -199,7 +186,7 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({}) => {
|
||||
setAnimationSource(proveLoadingAnimation);
|
||||
break;
|
||||
}
|
||||
}, [currentState, fcmToken, passportData, isInitializing]);
|
||||
}, [currentState, fcmToken, isInitializing]);
|
||||
|
||||
// Handle haptic feedback using useFocusEffect for immediate response
|
||||
useFocusEffect(
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
import splashAnimation from '@/assets/animations/splash.json';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useAuth } from '@/providers/authProvider';
|
||||
import { migrateToSecureKeychain, useAuth } from '@/providers/authProvider';
|
||||
import {
|
||||
checkAndUpdateRegistrationStates,
|
||||
checkIfAnyDocumentsNeedMigration,
|
||||
@@ -74,6 +74,13 @@ const SplashScreen: React.FC = ({}) => {
|
||||
const hasValid = await hasAnyValidRegisteredDocument(selfClient);
|
||||
const parentScreen = hasValid ? 'Home' : 'Launch';
|
||||
|
||||
// Migrate keychain to secure storage with biometric protection
|
||||
try {
|
||||
await migrateToSecureKeychain();
|
||||
} catch (error) {
|
||||
console.warn('Keychain migration failed, continuing:', error);
|
||||
}
|
||||
|
||||
setDeeplinkParentScreen(parentScreen);
|
||||
|
||||
const queuedUrl = getAndClearQueuedUrl();
|
||||
|
||||
@@ -20,6 +20,8 @@ interface PersistedSettingsState {
|
||||
isDevMode: boolean;
|
||||
setDevModeOn: () => void;
|
||||
setDevModeOff: () => void;
|
||||
hasCompletedKeychainMigration: boolean;
|
||||
setKeychainMigrationCompleted: () => void;
|
||||
fcmToken: string | null;
|
||||
setFcmToken: (token: string | null) => void;
|
||||
}
|
||||
@@ -71,6 +73,9 @@ export const useSettingStore = create<SettingsState>()(
|
||||
setDevModeOn: () => set({ isDevMode: true }),
|
||||
setDevModeOff: () => set({ isDevMode: false }),
|
||||
|
||||
hasCompletedKeychainMigration: false,
|
||||
setKeychainMigrationCompleted: () =>
|
||||
set({ hasCompletedKeychainMigration: true }),
|
||||
fcmToken: null,
|
||||
setFcmToken: (token: string | null) => set({ fcmToken: token }),
|
||||
|
||||
|
||||
215
app/src/utils/keychainSecurity.ts
Normal file
215
app/src/utils/keychainSecurity.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import Keychain, {
|
||||
type ACCESS_CONTROL,
|
||||
type ACCESSIBLE,
|
||||
GetOptions,
|
||||
type SECURITY_LEVEL,
|
||||
SetOptions,
|
||||
} from 'react-native-keychain';
|
||||
|
||||
/**
|
||||
* Security configuration for keychain operations
|
||||
*/
|
||||
export interface AdaptiveSecurityConfig {
|
||||
accessible: ACCESSIBLE;
|
||||
securityLevel?: SECURITY_LEVEL;
|
||||
accessControl?: ACCESS_CONTROL;
|
||||
}
|
||||
|
||||
export interface GetSecureOptions {
|
||||
requireAuth?: boolean;
|
||||
promptMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device security capabilities
|
||||
*/
|
||||
export interface SecurityCapabilities {
|
||||
hasPasscode: boolean;
|
||||
hasSecureHardware: boolean;
|
||||
supportsBiometrics: boolean;
|
||||
maxSecurityLevel: SECURITY_LEVEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device supports biometric authentication
|
||||
*/
|
||||
export async function checkBiometricsAvailable(): Promise<boolean> {
|
||||
try {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const ReactNativeBiometrics = (await import('react-native-biometrics'))
|
||||
.default;
|
||||
const rnBiometrics = new ReactNativeBiometrics();
|
||||
const { available } = await rnBiometrics.isSensorAvailable();
|
||||
return available;
|
||||
} catch (error) {
|
||||
console.log('Biometrics not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device has a passcode set by attempting to store a test item
|
||||
*/
|
||||
export async function checkPasscodeAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const testService = `passcode-test-${Date.now()}`;
|
||||
await Keychain.setGenericPassword('test', 'test', {
|
||||
service: testService,
|
||||
accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
|
||||
});
|
||||
// Clean up test entry
|
||||
await Keychain.resetGenericPassword({ service: testService });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('Device passcode not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create keychain options with adaptive security
|
||||
*/
|
||||
export async function createKeychainOptions(
|
||||
options: GetSecureOptions,
|
||||
capabilities?: SecurityCapabilities,
|
||||
): Promise<{
|
||||
setOptions: SetOptions;
|
||||
getOptions: GetOptions;
|
||||
}> {
|
||||
const config = await getAdaptiveSecurityConfig(
|
||||
options.requireAuth,
|
||||
capabilities,
|
||||
);
|
||||
|
||||
const setOptions: SetOptions = {
|
||||
accessible: config.accessible,
|
||||
...(config.securityLevel && { securityLevel: config.securityLevel }),
|
||||
...(config.accessControl && { accessControl: config.accessControl }),
|
||||
};
|
||||
|
||||
const getOptions: GetOptions = {
|
||||
...(config.accessControl && {
|
||||
accessControl: config.accessControl,
|
||||
authenticationPrompt: {
|
||||
title: 'Authenticate to access secure data',
|
||||
subtitle: 'Use biometrics or device passcode',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { setOptions, getOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect device security capabilities
|
||||
*/
|
||||
export async function detectSecurityCapabilities(): Promise<SecurityCapabilities> {
|
||||
const [hasPasscode, maxSecurityLevel, supportsBiometrics] = await Promise.all(
|
||||
[
|
||||
checkPasscodeAvailable(),
|
||||
getMaxSecurityLevel(),
|
||||
checkBiometricsAvailable(),
|
||||
],
|
||||
);
|
||||
|
||||
const hasSecureHardware =
|
||||
maxSecurityLevel === Keychain.SECURITY_LEVEL.SECURE_HARDWARE;
|
||||
|
||||
return {
|
||||
hasPasscode,
|
||||
hasSecureHardware,
|
||||
supportsBiometrics,
|
||||
maxSecurityLevel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adaptive security configuration based on device capabilities
|
||||
*/
|
||||
export async function getAdaptiveSecurityConfig(
|
||||
requireAuth: boolean = false,
|
||||
capabilities?: SecurityCapabilities,
|
||||
): Promise<AdaptiveSecurityConfig> {
|
||||
const caps = capabilities || (await detectSecurityCapabilities());
|
||||
// Determine the best accessible setting
|
||||
let accessible: ACCESSIBLE;
|
||||
if (caps.hasPasscode) {
|
||||
accessible = Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY;
|
||||
} else {
|
||||
// Fallback to device-only but less restrictive
|
||||
accessible = Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
||||
}
|
||||
|
||||
// Determine the best security level (Android)
|
||||
let securityLevel: SECURITY_LEVEL;
|
||||
if (caps.hasSecureHardware) {
|
||||
securityLevel = Keychain.SECURITY_LEVEL.SECURE_HARDWARE;
|
||||
} else if (
|
||||
caps.maxSecurityLevel === Keychain.SECURITY_LEVEL.SECURE_SOFTWARE
|
||||
) {
|
||||
securityLevel = Keychain.SECURITY_LEVEL.SECURE_SOFTWARE;
|
||||
} else {
|
||||
securityLevel = Keychain.SECURITY_LEVEL.ANY;
|
||||
}
|
||||
|
||||
// Determine the best access control
|
||||
let accessControl: ACCESS_CONTROL | undefined;
|
||||
if (requireAuth) {
|
||||
if (caps.supportsBiometrics && caps.hasPasscode) {
|
||||
accessControl = Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE;
|
||||
} else if (caps.hasPasscode) {
|
||||
accessControl = Keychain.ACCESS_CONTROL.DEVICE_PASSCODE;
|
||||
} else {
|
||||
// Don't require additional authentication if no passcode is set
|
||||
accessControl = undefined;
|
||||
}
|
||||
} else {
|
||||
accessControl = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
accessible,
|
||||
securityLevel,
|
||||
accessControl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum security level supported by the device
|
||||
*/
|
||||
export async function getMaxSecurityLevel(): Promise<SECURITY_LEVEL> {
|
||||
try {
|
||||
// Try to get the device's security level
|
||||
const securityLevel = await Keychain.getSecurityLevel();
|
||||
return securityLevel || Keychain.SECURITY_LEVEL.ANY;
|
||||
} catch (error) {
|
||||
console.log('Could not determine security level, defaulting to ANY');
|
||||
return Keychain.SECURITY_LEVEL.ANY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security configuration for debugging
|
||||
*/
|
||||
export function logSecurityConfig(
|
||||
capabilities: SecurityCapabilities,
|
||||
config: AdaptiveSecurityConfig,
|
||||
): void {
|
||||
console.log('🔒 Device Security Capabilities:', {
|
||||
hasPasscode: capabilities.hasPasscode,
|
||||
hasSecureHardware: capabilities.hasSecureHardware,
|
||||
supportsBiometrics: capabilities.supportsBiometrics,
|
||||
maxSecurityLevel: capabilities.maxSecurityLevel,
|
||||
});
|
||||
|
||||
console.log('🔧 Adaptive Security Configuration:', {
|
||||
accessible: config.accessible,
|
||||
securityLevel: config.securityLevel,
|
||||
accessControl: config.accessControl,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user