1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Mobile: Security: Prevent bypassing fingerprint lock on certain devices

This commit is contained in:
Laurent Cozic 2023-04-09 11:28:18 +02:00
parent 02cf546124
commit 6b72f86e7b
7 changed files with 83 additions and 18 deletions

View File

@ -405,6 +405,7 @@ packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/TextInput.js packages/app-mobile/components/TextInput.js
packages/app-mobile/components/app-nav.js packages/app-mobile/components/app-nav.js
packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/getResponsiveValue.test.js packages/app-mobile/components/getResponsiveValue.test.js

1
.gitignore vendored
View File

@ -392,6 +392,7 @@ packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/TextInput.js packages/app-mobile/components/TextInput.js
packages/app-mobile/components/app-nav.js packages/app-mobile/components/app-nav.js
packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/getResponsiveValue.test.js packages/app-mobile/components/getResponsiveValue.test.js

View File

@ -2,10 +2,10 @@ const React = require('react');
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { View, Dimensions, Alert, Button } from 'react-native'; import { View, Dimensions, Alert, Button } from 'react-native';
import FingerprintScanner from 'react-native-fingerprint-scanner';
import { SensorInfo } from './sensorInfo'; import { SensorInfo } from './sensorInfo';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import biometricAuthenticate from './biometricAuthenticate';
const logger = Logger.create('BiometricPopup'); const logger = Logger.create('BiometricPopup');
@ -21,7 +21,7 @@ export default (props: Props) => {
// doesn't work properly, we disable it. We only want the user to enable the // doesn't work properly, we disable it. We only want the user to enable the
// feature after they've read the description in the config screen. // feature after they've read the description in the config screen.
const [initialPromptDone, setInitialPromptDone] = useState(true); // useState(Setting.value('security.biometricsInitialPromptDone')); const [initialPromptDone, setInitialPromptDone] = useState(true); // useState(Setting.value('security.biometricsInitialPromptDone'));
const [display, setDisplay] = useState(!!props.sensorInfo.supportedSensors && (props.sensorInfo.enabled || !initialPromptDone)); const [display, setDisplay] = useState(props.sensorInfo.enabled || !initialPromptDone);
const [tryBiometricsCheck, setTryBiometricsCheck] = useState(initialPromptDone); const [tryBiometricsCheck, setTryBiometricsCheck] = useState(initialPromptDone);
logger.info('Render start'); logger.info('Render start');
@ -37,18 +37,14 @@ export default (props: Props) => {
logger.info('biometricsCheck: start'); logger.info('biometricsCheck: start');
try { try {
logger.info('biometricsCheck: authenticate...'); await biometricAuthenticate();
await FingerprintScanner.authenticate({ description: _('Verify your identity') });
logger.info('biometricsCheck: authenticate done');
setTryBiometricsCheck(false);
setDisplay(false); setDisplay(false);
} catch (error) { } catch (error) {
Alert.alert(_('Could not verify your identify'), error.message); Alert.alert(error.message);
setTryBiometricsCheck(false);
} finally {
FingerprintScanner.release();
} }
setTryBiometricsCheck(false);
logger.info('biometricsCheck: end'); logger.info('biometricsCheck: end');
}; };
@ -97,7 +93,7 @@ export default (props: Props) => {
}, },
] ]
); );
}, [initialPromptDone, props.sensorInfo.supportedSensors, display, props.dispatch]); }, [initialPromptDone, display, props.dispatch]);
const windowSize = useMemo(() => { const windowSize = useMemo(() => {
return { return {

View File

@ -0,0 +1,28 @@
import Logger from '@joplin/lib/Logger';
import FingerprintScanner, { Errors } from 'react-native-fingerprint-scanner';
import { _ } from '@joplin/lib/locale';
const logger = Logger.create('biometricAuthenticate');
export default async () => {
try {
logger.info('Authenticate...');
await FingerprintScanner.authenticate({ description: _('Verify your identity') });
logger.info('Authenticate done');
} catch (error) {
const errorName = (error as Errors).name;
let errorMessage = error.message;
if (errorName === 'FingerprintScannerNotEnrolled' || errorName === 'FingerprintScannerNotAvailable') {
errorMessage = _('Biometric unlock is not setup on the device. Please set it up in order to unlock Joplin. If the device is on lockout, consider switching it off and on to reset biometrics scanning.');
}
error.message = _('Could not verify your identify: %s', errorMessage);
logger.warn(error);
throw error;
} finally {
FingerprintScanner.release();
}
};

View File

@ -30,6 +30,18 @@ export default async (): Promise<SensorInfo> => {
try { try {
logger.info('Getting isSensorAvailable...'); logger.info('Getting isSensorAvailable...');
// Note: If `isSensorAvailable()` doesn't return anything, it seems we
// could assume that biometrics are not setup on the device, and thus we
// can unlock the app. However that's not always correct - on some
// devices (eg Galaxy S22), `isSensorAvailable()` will return nothing if
// the device is on lockout - i.e. if the user gave the wrong
// fingerprint multiple times.
//
// So we definitely can't unlock the app in that case, and it means
// `isSensorAvailable()` is pretty much useless. Instead we ask for
// fingerprint when the user turns on the feature and at that point we
// know if the device supports biometrics or not.
const result = await FingerprintScanner.isSensorAvailable(); const result = await FingerprintScanner.isSensorAvailable();
logger.info('isSensorAvailable result', result); logger.info('isSensorAvailable result', result);
supportedSensors = result; supportedSensors = result;

View File

@ -23,6 +23,7 @@ const { themeStyle } = require('../global-style.js');
const shared = require('@joplin/lib/components/shared/config-shared.js'); const shared = require('@joplin/lib/components/shared/config-shared.js');
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import { openDocumentTree } from '@joplin/react-native-saf-x'; import { openDocumentTree } from '@joplin/react-native-saf-x';
import biometricAuthenticate from '../biometrics/biometricAuthenticate';
class ConfigScreenComponent extends BaseScreenComponent { class ConfigScreenComponent extends BaseScreenComponent {
public static navigationOptions(): any { public static navigationOptions(): any {
@ -463,7 +464,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
<Text key="label" style={this.styles().switchSettingText}> <Text key="label" style={this.styles().switchSettingText}>
{label} {label}
</Text> </Text>
<Switch key="control" style={this.styles().switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value: any) => updateSettingValue(key, value)} /> <Switch key="control" style={this.styles().switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value: any) => void updateSettingValue(key, value)} />
</View> </View>
{descriptionComp} {descriptionComp}
</View> </View>
@ -474,13 +475,39 @@ class ConfigScreenComponent extends BaseScreenComponent {
return !hasDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder; return !hasDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder;
} }
private async handleSetting(key: string, value: any): Promise<boolean> {
// When the user tries to enable biometrics unlock, we ask for the
// fingerprint or Face ID, and if it's correct we save immediately. If
// it's not, we don't turn on the setting.
if (key === 'security.biometricsEnabled' && !!value) {
try {
await biometricAuthenticate();
shared.updateSettingValue(this, key, value);
await this.saveButton_press();
} catch (error) {
shared.updateSettingValue(this, key, false);
Alert.alert(error.message);
}
return true;
}
if (key === 'security.biometricsEnabled' && !value) {
shared.updateSettingValue(this, key, value);
await this.saveButton_press();
return true;
}
return false;
}
public settingToComponent(key: string, value: any) { public settingToComponent(key: string, value: any) {
const themeId = this.props.themeId; const themeId = this.props.themeId;
const theme = themeStyle(themeId); const theme = themeStyle(themeId);
const output: any = null; const output: any = null;
const updateSettingValue = (key: string, value: any) => { const updateSettingValue = async (key: string, value: any) => {
return shared.updateSettingValue(this, key, value); const handled = await this.handleSetting(key, value);
if (!handled) shared.updateSettingValue(this, key, value);
}; };
const md = Setting.settingMetadata(key); const md = Setting.settingMetadata(key);
@ -517,7 +544,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
fontSize: theme.fontSize, fontSize: theme.fontSize,
}} }}
onValueChange={(itemValue: string) => { onValueChange={(itemValue: string) => {
updateSettingValue(key, itemValue); void updateSettingValue(key, itemValue);
}} }}
/> />
</View> </View>
@ -553,7 +580,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
</Text> </Text>
<View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', flex: 1 }}> <View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', flex: 1 }}>
<Text style={this.styles().sliderUnits}>{unitLabel}</Text> <Text style={this.styles().sliderUnits}>{unitLabel}</Text>
<Slider key="control" style={{ flex: 1 }} step={md.step} minimumValue={minimum} maximumValue={maximum} value={value} onValueChange={value => updateSettingValue(key, value)} /> <Slider key="control" style={{ flex: 1 }} step={md.step} minimumValue={minimum} maximumValue={maximum} value={value} onValueChange={value => void updateSettingValue(key, value)} />
</View> </View>
</View> </View>
); );
@ -577,7 +604,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
<Text key="label" style={this.styles().settingText}> <Text key="label" style={this.styles().settingText}>
{md.label()} {md.label()}
</Text> </Text>
<TextInput autoCorrect={false} autoComplete="off" selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} autoCapitalize="none" key="control" style={this.styles().settingControl} value={value} onChangeText={(value: any) => updateSettingValue(key, value)} secureTextEntry={!!md.secure} /> <TextInput autoCorrect={false} autoComplete="off" selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} autoCapitalize="none" key="control" style={this.styles().settingControl} value={value} onChangeText={(value: any) => void updateSettingValue(key, value)} secureTextEntry={!!md.secure} />
</View> </View>
); );
} else { } else {

View File

@ -501,7 +501,7 @@ async function initialize(dispatch: Function) {
if (Setting.value('env') === 'prod') { if (Setting.value('env') === 'prod') {
await db.open({ name: getDatabaseName(currentProfile, isSubProfile) }); await db.open({ name: getDatabaseName(currentProfile, isSubProfile) });
} else { } else {
await db.open({ name: getDatabaseName(currentProfile, isSubProfile, '-1') }); await db.open({ name: getDatabaseName(currentProfile, isSubProfile, '-3') });
// await db.clearForTesting(); // await db.clearForTesting();
} }