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:
Seshanth.S
2025-10-03 22:53:11 +05:30
committed by GitHub
parent c68ef2b79e
commit ad009394eb
13 changed files with 523 additions and 77 deletions

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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,
});
}

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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();

View File

@@ -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 }),

View 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,
});
}