Skip to content

Commit

Permalink
feat: lock wallet after too many password attempts
Browse files Browse the repository at this point in the history
  • Loading branch information
josheleonard committed Aug 1, 2022
1 parent a9e8f40 commit 7cfa50a
Show file tree
Hide file tree
Showing 10 changed files with 196 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,4 @@ export const addFilecoinAccount = createAction<AddFilecoinAccountPayloadType>('a
export const getOnRampCurrencies = createAction('getOnRampCurrencies')
export const setOnRampCurrencies = createAction<BraveWallet.OnRampCurrency[]>('setOnRampCurrencies')
export const selectCurrency = createAction<BraveWallet.OnRampCurrency>('selectCurrency')
export const setPasswordAttempts = createAction<number>('setPasswordAttempts')
35 changes: 19 additions & 16 deletions components/brave_wallet_ui/common/async/__mocks__/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { BraveWallet } from '../../../constants/types'
import { SwapParamsPayloadType } from '../../constants/action_types'
import WalletApiProxy from '../../wallet_api_proxy'
export class MockedWalletApiProxy {
mockQuote = {
Expand Down Expand Up @@ -42,37 +41,41 @@ export class MockedWalletApiProxy {
buyTokenToEthRate: '1'
}

swapService = {
swapService: Partial<InstanceType<typeof BraveWallet.SwapServiceInterface>> = {
getTransactionPayload: async ({
fromAsset,
toAsset,
fromAssetAmount,
toAssetAmount
}: SwapParamsPayloadType): Promise<{ success: boolean, errorResponse: any, response: BraveWallet.SwapResponse }> => ({
buyAmount,
buyToken,
sellAmount,
sellToken
}: BraveWallet.SwapParams): Promise<{
success: boolean
errorResponse: any
response: BraveWallet.SwapResponse
}> => ({
success: true,
errorResponse: {},
response: {
...this.mockQuote,
buyTokenAddress: toAsset.contractAddress,
sellTokenAddress: fromAsset.contractAddress,
buyAmount: toAssetAmount || '',
sellAmount: fromAssetAmount || '',
buyTokenAddress: buyToken,
sellTokenAddress: sellToken,
buyAmount: buyAmount || '',
sellAmount: sellAmount || '',
price: '1'
}
// as BraveWallet.SwapResponse
}),
getPriceQuote: async () => ({
success: true,
errorResponse: {},
errorResponse: null,
response: this.mockTransaction
})
}

keyringService = {
validatePassword: async () => ({ result: true })
keyringService: Partial<InstanceType<typeof BraveWallet.KeyringServiceInterface>> = {
validatePassword: async (password: string) => ({ result: password === 'password' }),
lock: () => alert('wallet locked')
}

ethTxManagerProxy = {
ethTxManagerProxy: Partial<InstanceType<typeof BraveWallet.EthTxManagerProxyInterface>> = {
getGasEstimation1559: async () => {
return {
estimation: {
Expand Down
10 changes: 8 additions & 2 deletions components/brave_wallet_ui/common/hooks/swap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ import { mockPageState } from '../../stories/mock-data/mock-page-state'
import { LibContext } from '../context/lib.context'
import { mockBasicAttentionToken, mockEthToken } from '../../stories/mock-data/mock-asset-options'

jest.useFakeTimers()

const store = createStore(combineReducers({
wallet: createWalletReducer(mockWalletState),
page: createPageReducer(mockPageState)
Expand Down Expand Up @@ -65,6 +63,14 @@ const mockQuote = {
} as BraveWallet.SwapResponse

describe('useSwap hook', () => {
beforeAll(() => {
jest.useFakeTimers()
})
afterAll(() => {
jest.clearAllTimers()
jest.useRealTimers()
})

it('should initialize From and To assets', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSwap(), renderHookOptions)

Expand Down
2 changes: 1 addition & 1 deletion components/brave_wallet_ui/common/hooks/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export default function useSwap ({ fromAsset: fromAssetProp, toAsset: toAssetPro
full
} = payload

const swapParams = {
const swapParams: BraveWallet.SwapParams = {
takerAddress: accountAddress,
sellAmount: fromAssetAmount || '',
buyAmount: toAssetAmount || '',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2022 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.

import * as React from 'react'
import { createStore, combineReducers } from 'redux'
import { Provider } from 'react-redux'
import { act, renderHook } from '@testing-library/react-hooks'

import { createWalletReducer } from '../reducers/wallet_reducer'
import { usePasswordAttempts } from './use-password-attempts'
import { mockWalletState } from '../../stories/mock-data/mock-wallet-state'
import { ApiProxyContext } from '../context/api-proxy.context'
import { getMockedAPIProxy } from '../async/__mocks__/bridge'

const proxy = getMockedAPIProxy()
proxy.keyringService.lock = jest.fn(proxy.keyringService.lock)

const makeStore = () => {
const store = createStore(combineReducers({
wallet: createWalletReducer(mockWalletState)
}))

store.dispatch = jest.fn(store.dispatch)
return store
}

function renderHookOptionsWithCustomStore (store: any) {
return {
wrapper: ({ children }: { children?: React.ReactChildren }) =>
<ApiProxyContext.Provider value={proxy}>
<Provider store={store}>
{children}
</Provider>
</ApiProxyContext.Provider>
}
}

const MAX_ATTEMPTS = 3

describe('useTransactionParser hook', () => {
it('should increment attempts on bad password ', async () => {
const store = makeStore()

const {
result
} = renderHook(() => usePasswordAttempts({
maxAttempts: MAX_ATTEMPTS
}), renderHookOptionsWithCustomStore(store))

expect(result.current.attempts).toEqual(0)

// attempt 1
await act(async () => {
await result.current.attemptPasswordEntry('pass')
})

expect(result.current.attempts).toEqual(1)

// attempt 2
await act(async () => {
await result.current.attemptPasswordEntry('pass')
})

expect(result.current.attempts).toEqual(2)

// attempt 3
await act(async () => {
await result.current.attemptPasswordEntry('pass')
})

// Wallet is now locked
expect(proxy.keyringService.lock).toHaveBeenCalled()

// attempts should be reset since wallet was locked
expect(result.current.attempts).toEqual(0)
})
})
69 changes: 69 additions & 0 deletions components/brave_wallet_ui/common/hooks/use-password-attempts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) 2022 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.

import * as React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { WalletState } from '../../constants/types'
import { WalletActions } from '../actions'
import { useApiProxy } from './use-api-proxy'

interface Options {
maxAttempts: number
}

/**
* Provides a methods to check the user's password,
* and lock the wallet after too many incorrect attempts
*
* Uses the context-injected ApiProxy keyring
* Uses redux to track attempts globally
*/
export const usePasswordAttempts = ({
maxAttempts
}: Options) => {
// custom hooks
const { keyringService } = useApiProxy()

// redux
const dispatch = useDispatch()
const attempts = useSelector(({ wallet }: { wallet: WalletState }) => {
return wallet.passwordAttempts
})

// methods
const attemptPasswordEntry = React.useCallback(async (password: string): Promise<boolean> => {
if (!password) { // require password to view key
return false
}

// entered password must be correct
const {
result: isPasswordValid
} = await keyringService.validatePassword(password)

if (!isPasswordValid) {
const newAttempts = attempts + 1
if (newAttempts >= maxAttempts) {
// lock wallet
keyringService.lock()
dispatch(WalletActions.setPasswordAttempts(0)) // reset attempts now that the wallet is locked
return false
}

// increase attempts count
dispatch(WalletActions.setPasswordAttempts(newAttempts))
return false
}

// correct password entered, reset attempts
dispatch(WalletActions.setPasswordAttempts(0))
return isPasswordValid
}, [keyringService, attempts])

return {
attemptPasswordEntry,
attempts
}
}
10 changes: 9 additions & 1 deletion components/brave_wallet_ui/common/reducers/wallet_reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ const defaultState: WalletState = {
selectedNetworkFilter: AllNetworksOption,
solFeeEstimates: undefined,
onRampCurrencies: [] as BraveWallet.OnRampCurrency[],
selectedCurrency: undefined
selectedCurrency: undefined,
passwordAttempts: 0
}

const getAccountType = (info: AccountInfo) => {
Expand Down Expand Up @@ -554,6 +555,13 @@ export const createWalletReducer = (initialState: WalletState) => {
}
})

reducer.on(WalletActions.setPasswordAttempts, (state: WalletState, payload: number): WalletState => {
return {
...state,
passwordAttempts: payload
}
})

return reducer
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import PopupModal from '../index'
import PasswordInput from '../../../shared/password-input/index'

// hooks
import { useApiProxy } from '../../../../common/hooks/use-api-proxy'
import { usePasswordAttempts } from '../../../../common/hooks/use-password-attempts'

// style
import {
Expand Down Expand Up @@ -93,7 +93,7 @@ export const AccountSettingsModal = ({
const [qrCode, setQRCode] = React.useState<string>('')

// custom hooks
const { keyringService } = useApiProxy()
const { attemptPasswordEntry } = usePasswordAttempts({ maxAttempts: 3 })

// methods
const handleAccountNameChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -138,9 +138,7 @@ export const AccountSettingsModal = ({
}

// entered password must be correct
const {
result: isPasswordValid
} = await keyringService.validatePassword(password)
const isPasswordValid = await attemptPasswordEntry(password)

if (!isPasswordValid) {
setIsCorrectPassword(isPasswordValid) // set or clear error
Expand Down Expand Up @@ -284,7 +282,10 @@ export const AccountSettingsModal = ({
<ButtonWrapper>
<NavButton
onSubmit={!showPrivateKey ? onShowPrivateKey : onHidePrivateKey}
text={!showPrivateKey ? getLocale('braveWalletAccountSettingsShowKey') : getLocale('braveWalletAccountSettingsHideKey')}
text={getLocale(!showPrivateKey
? 'braveWalletAccountSettingsShowKey'
: 'braveWalletAccountSettingsHideKey'
)}
buttonType='primary'
disabled={
showPrivateKey
Expand Down
1 change: 1 addition & 0 deletions components/brave_wallet_ui/constants/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export interface WalletState {
defaultAccounts: BraveWallet.AccountInfo[]
onRampCurrencies: BraveWallet.OnRampCurrency[]
selectedCurrency: BraveWallet.OnRampCurrency | undefined
passwordAttempts: number
}

export interface PanelState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,5 +341,6 @@ export const mockWalletState: WalletState = {
}
],
onRampCurrencies: mockCurrencies,
selectedCurrency: mockCurrency
selectedCurrency: mockCurrency,
passwordAttempts: 0
}

0 comments on commit 7cfa50a

Please sign in to comment.