Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: reduce PIN attempts in Send and ChangePIN flows #2311

Merged
merged 1 commit into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ module.exports = {
additionalHooks: 'useDebouncedEffect',
},
],

// prettier
'no-mixed-spaces-and-tabs': 0,
},
overrides: [
// Disable type-aware linting for .js files
Expand Down
7 changes: 3 additions & 4 deletions e2e/security.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,10 @@ d('Settings Security And Privacy', () => {
await element(by.id('UseBiometryInstead')).tap();
await device.matchFace();
await sleep(1000);
await element(by.id('ChangePIN')).tap();
await element(by.id('N1').withAncestor(by.id('PinPad'))).multiTap(4);
await sleep(1000);
await element(by.id('PINChange')).tap();
await element(by.id('N3').withAncestor(by.id('ChangePIN'))).multiTap(4);
await expect(element(by.id('AttemptsRemaining'))).toBeVisible();
await element(by.id('N1').withAncestor(by.id('ChangePIN'))).multiTap(4);
await sleep(1000);
await element(by.id('N2').withAncestor(by.id('ChangePIN2'))).multiTap(4);
await element(by.id('N9').withAncestor(by.id('ChangePIN2'))).multiTap(4);
await expect(element(by.id('WrongPIN'))).toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2292,6 +2292,6 @@ SPEC CHECKSUMS:
Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5

PODFILE CHECKSUM: b1ff2276b558626bd07bddd66e26b06f3fc76609
PODFILE CHECKSUM: cb153cb4a39e6c92c8b869eafab65a4bba7b869f

COCOAPODS: 1.15.2
4 changes: 2 additions & 2 deletions src/components/AuthCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const AuthCheck = ({
<Animated.View style={StyleSheet.absoluteFillObject} exiting={FadeOut}>
<ThemedView style={styles.root}>
<Biometrics
onSuccess={(): void => onSuccess?.()}
onSuccess={onSuccess}
onFailure={(): void => setBioEnabled(false)}
/>
</ThemedView>
Expand All @@ -58,7 +58,7 @@ const AuthCheck = ({
showLogoOnPIN={showLogoOnPIN}
allowBiometrics={biometrics && !requirePin}
onShowBiotmetrics={(): void => setBioEnabled(true)}
onSuccess={(): void => onSuccess?.()}
onSuccess={onSuccess}
/>
</Animated.View>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/Biometrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const Biometrics = ({
style,
children,
}: {
onSuccess: () => void;
onSuccess?: () => void;
onFailure?: () => void;
style?: StyleProp<ViewStyle>;
children?: ReactElement;
Expand Down Expand Up @@ -101,7 +101,7 @@ const Biometrics = ({
.then(({ success }) => {
if (success) {
dispatch(updateSettings({ biometrics: true }));
onSuccess();
onSuccess?.();
} else {
vibrate();
onFailure?.();
Expand Down
169 changes: 19 additions & 150 deletions src/components/PinPad.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,21 @@
import React, {
memo,
ReactElement,
useState,
useEffect,
useCallback,
} from 'react';
import { StyleSheet, View, Pressable } from 'react-native';
import { FadeIn, FadeOut } from 'react-native-reanimated';
import React, { ReactElement, memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, StyleSheet, View } from 'react-native';
import { FadeIn, FadeOut } from 'react-native-reanimated';

import { BodyS, Subtitle } from '../styles/text';
import { View as ThemedView, AnimatedView } from '../styles/components';
import BitkitLogo from '../assets/bitkit-logo.svg';
import { PIN_ATTEMPTS } from '../constants/app';
import usePIN from '../hooks/pin';
import { showBottomSheet } from '../store/utils/ui';
import { AnimatedView, View as ThemedView } from '../styles/components';
import { FaceIdIcon, TouchIdIcon } from '../styles/icons';
import SafeAreaInset from './SafeAreaInset';
import NavigationHeader from './NavigationHeader';
import { BodyS, Subtitle } from '../styles/text';
import rnBiometrics from '../utils/biometrics';
import { IsSensorAvailableResult } from './Biometrics';
import NavigationHeader from './NavigationHeader';
import NumberPad from './NumberPad';
import SafeAreaInset from './SafeAreaInset';
import Button from './buttons/Button';
import useColors from '../hooks/colors';
import { wipeApp } from '../store/utils/settings';
import { showBottomSheet } from '../store/utils/ui';
import { vibrate } from '../utils/helpers';
import rnBiometrics from '../utils/biometrics';
import { showToast } from '../utils/notifications';
import { setKeychainValue, getKeychainValue } from '../utils/keychain';
import BitkitLogo from '../assets/bitkit-logo.svg';
import { PIN_ATTEMPTS } from '../constants/app';

const PinPad = ({
showLogoOnPIN,
Expand All @@ -37,122 +27,22 @@ const PinPad = ({
showLogoOnPIN: boolean;
allowBiometrics: boolean;
showBackNavigation?: boolean;
onSuccess: () => void;
onSuccess?: () => void;
onShowBiotmetrics?: () => void;
}): ReactElement => {
const { t } = useTranslation('security');
const { brand, brand08 } = useColors();
const [pin, setPin] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [attemptsRemaining, setAttemptsRemaining] = useState(0);
const [biometryData, setBiometricData] = useState<IsSensorAvailableResult>();

const handleOnPress = (key: string): void => {
vibrate();
if (key === 'delete') {
setPin((p) => {
return p.length === 0 ? '' : p.slice(0, -1);
});
} else {
setPin((p) => {
return p.length === 4 ? p : p + key;
});
}
};

// Reduce the amount of pin attempts remaining.
const reducePinAttemptsRemaining = useCallback(async (): Promise<void> => {
const _attemptsRemaining = attemptsRemaining - 1;
await setKeychainValue({
key: 'pinAttemptsRemaining',
value: `${_attemptsRemaining}`,
});
setAttemptsRemaining(_attemptsRemaining);
}, [attemptsRemaining]);

// init view
useEffect(() => {
(async (): Promise<void> => {
const attemptsRemainingResponse = await getKeychainValue({
key: 'pinAttemptsRemaining',
});

if (
!attemptsRemainingResponse.error &&
Number(attemptsRemainingResponse.data) !== Number(attemptsRemaining)
) {
let numAttempts =
attemptsRemainingResponse.data !== undefined
? Number(attemptsRemainingResponse.data)
: 5;
setAttemptsRemaining(numAttempts);
}
})();
}, [attemptsRemaining]);
const { attemptsRemaining, Dots, handleNumberPress, isLastAttempt, loading } =
usePIN(onSuccess);

// on mount
useEffect(() => {
(async (): Promise<void> => {
setIsLoading(true);
// wait for initial keychain read
await getKeychainValue({ key: 'pinAttemptsRemaining' });
// get available biometrics
const data = await rnBiometrics.isSensorAvailable();
setBiometricData(data);
setIsLoading(false);
})();
}, []);

// submit pin
useEffect(() => {
const timer = setTimeout(async () => {
if (pin.length !== 4) {
return;
}

const realPIN = await getKeychainValue({ key: 'pin' });

// error getting pin
if (realPIN.error) {
await reducePinAttemptsRemaining();
vibrate();
setPin('');
return;
}

// incorrect pin
if (pin !== realPIN?.data) {
if (attemptsRemaining <= 1) {
vibrate({ type: 'default' });
await wipeApp();
showToast({
type: 'warning',
title: t('wiped_title'),
description: t('wiped_message'),
});
} else {
await reducePinAttemptsRemaining();
}

vibrate();
setPin('');
return;
}

// correct pin
await setKeychainValue({
key: 'pinAttemptsRemaining',
value: PIN_ATTEMPTS,
});
setPin('');
onSuccess?.();
}, 500);

return (): void => clearTimeout(timer);
}, [pin, attemptsRemaining, onSuccess, reducePinAttemptsRemaining, t]);

const isLastAttempt = attemptsRemaining === 1;

const biometricsName =
biometryData?.biometryType === 'TouchID'
? t('bio_touch_id')
Expand All @@ -172,7 +62,7 @@ const PinPad = ({
</View>

<View style={styles.content}>
{!isLoading && (
{!loading && biometryData !== undefined && (
<AnimatedView
style={styles.contentInner}
color="transparent"
Expand Down Expand Up @@ -210,40 +100,26 @@ const PinPad = ({
<Button
style={styles.biometrics}
text={t('pin_use_biometrics', { biometricsName })}
onPress={onShowBiotmetrics}
icon={
biometryData?.biometryType === 'FaceID' ? (
<FaceIdIcon height={16} width={16} color="brand" />
) : (
<TouchIdIcon height={16} width={16} color="brand" />
)
}
onPress={onShowBiotmetrics}
/>
)}
</View>

<View style={styles.dots}>
{Array(4)
.fill(null)
.map((_, i) => (
<View
key={i}
style={[
styles.dot,
{
borderColor: brand,
backgroundColor:
pin[i] === undefined ? brand08 : brand,
},
]}
/>
))}
<Dots />
</View>

<NumberPad
style={styles.numberpad}
type="simple"
onPress={handleOnPress}
onPress={handleNumberPress}
/>
</AnimatedView>
)}
Expand Down Expand Up @@ -302,13 +178,6 @@ const styles = StyleSheet.create({
marginBottom: 16,
},
dots: {
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 48,
},
dot: {
width: 20,
height: 20,
borderRadius: 10,
marginHorizontal: 12,
borderWidth: 1,
Expand Down
Loading