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(keychain): fix private key missing error #5277

Merged
merged 2 commits into from
Apr 19, 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
10 changes: 10 additions & 0 deletions src/identity/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export enum Actions {
FETCH_ADDRESS_VERIFICATION_STATUS = 'IDENTITY/FETCH_ADDRESS_VERIFICATION_STATUS',
ADDRESS_VERIFICATION_STATUS_RECEIVED = 'IDENTITY/ADDRESS_VERIFICATION_STATUS_RECEIVED',
CONTACTS_SAVED = 'IDENTITY/CONTACTS_SAVED',
STORED_PASSWORD_REFRESHED = 'IDENTITY/STORED_PASSWORD_REFRESHED',
}

export interface SetHasSeenVerificationNux {
Expand Down Expand Up @@ -149,6 +150,10 @@ interface ContactsSavedAction {
hash: string
}

interface StoredPasswordRefreshedAction {
type: Actions.STORED_PASSWORD_REFRESHED
}

export type ActionTypes =
| SetHasSeenVerificationNux
| UpdateE164PhoneNumberAddressesAction
Expand All @@ -170,6 +175,7 @@ export type ActionTypes =
| FetchAddressVerificationAction
| AddressVerificationStatusReceivedAction
| ContactsSavedAction
| StoredPasswordRefreshedAction

export const setHasSeenVerificationNux = (status: boolean): SetHasSeenVerificationNux => ({
type: Actions.SET_SEEN_VERIFICATION_NUX,
Expand Down Expand Up @@ -333,3 +339,7 @@ export const contactsSaved = (hash: string): ContactsSavedAction => ({
type: Actions.CONTACTS_SAVED,
hash,
})

export const storedPasswordRefreshed = (): StoredPasswordRefreshedAction => ({
type: Actions.STORED_PASSWORD_REFRESHED,
})
7 changes: 7 additions & 0 deletions src/identity/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
// Mapping of address to verification status; undefined entries represent a loading state
addressToVerificationStatus: AddressToVerificationStatus
lastSavedContactsHash: string | null
shouldRefreshStoredPasswordHash: boolean
}

const initialState: State = {
Expand All @@ -109,6 +110,7 @@
secureSendPhoneNumberMapping: {},
addressToVerificationStatus: {},
lastSavedContactsHash: null,
shouldRefreshStoredPasswordHash: false,
}

export const reducer = (
Expand Down Expand Up @@ -289,6 +291,11 @@
...state,
lastSavedContactsHash: action.hash,
}
case Actions.STORED_PASSWORD_REFRESHED:
return {

Check warning on line 295 in src/identity/reducer.ts

View check run for this annotation

Codecov / codecov/patch

src/identity/reducer.ts#L294-L295

Added lines #L294 - L295 were not covered by tests
...state,
shouldRefreshStoredPasswordHash: false,
}
default:
return state
}
Expand Down
3 changes: 3 additions & 0 deletions src/identity/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ export const identifierToE164NumberSelector = createSelector(

export const lastSavedContactsHashSelector = (state: RootState) =>
state.identity.lastSavedContactsHash

export const shouldRefreshStoredPasswordHashSelector = (state: RootState) =>
state.identity.shouldRefreshStoredPasswordHash
115 changes: 115 additions & 0 deletions src/pincode/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { PincodeType } from 'src/account/reducer'
import { pincodeTypeSelector } from 'src/account/selectors'
import { AuthenticationEvents } from 'src/analytics/Events'
import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { storedPasswordRefreshed } from 'src/identity/actions'
import { navigate, navigateBack } from 'src/navigator/NavigationService'
import {
CANCELLED_PIN_INPUT,
checkPin,
DEFAULT_CACHE_ACCOUNT,
getPasswordSaga,
getPincode,
getPincodeWithBiometry,
passwordHashStorageKey,
PinBlocklist,
removeStoredPin,
retrieveOrGeneratePepper,
Expand All @@ -23,15 +26,18 @@ import {
clearPasswordCaches,
getCachedPepper,
getCachedPin,
setCachedPasswordHash,
setCachedPepper,
setCachedPin,
} from 'src/pincode/PasswordCache'
import { store } from 'src/redux/store'
import { ensureError } from 'src/utils/ensureError'
import Logger from 'src/utils/Logger'
import { getWalletAsync } from 'src/web3/contracts'
import { getMockStoreData } from 'test/utils'
import { mockAccount } from 'test/values'

jest.mock('src/web3/contracts')
jest.unmock('src/pincode/authentication')
jest.mock('src/redux/store', () => ({ store: { getState: jest.fn() } }))
jest.mock('react-native-securerandom', () => ({
Expand All @@ -54,6 +60,7 @@ const mockPin = '111555'
const mockedKeychain = jest.mocked(Keychain)
const mockStore = jest.mocked(store)
mockStore.getState.mockImplementation(getMockStoreData)
mockStore.dispatch = jest.fn()
const mockedNavigate = navigate as jest.Mock

const expectPincodeEntered = () => {
Expand Down Expand Up @@ -363,6 +370,9 @@ describe(updatePin, () => {
}
return Promise.resolve(false)
})
jest.mocked(getWalletAsync).mockResolvedValue({
updateAccount: jest.fn().mockResolvedValue(true),
} as any)
})

it('should update the cached pin, stored password, and store mnemonic', async () => {
Expand Down Expand Up @@ -533,3 +543,108 @@ describe(PinBlocklist, () => {
})
})
})

describe(checkPin, () => {
const expectedPassword = mockPepper.password + mockPin
const expectedPasswordHash = 'd9bb2d77ec27dc8bf4269a6241daaa0388e8908518458f6ce0314380d11411cd' // sha256 of expectedPassword
const mockUnlockAccount = jest.fn().mockImplementation((_account, password, _duration) => {
if (password === expectedPassword) {
return Promise.resolve(true)
}
return Promise.resolve(false)
})

beforeEach(() => {
jest.clearAllMocks()
clearPasswordCaches()
setCachedPepper(DEFAULT_CACHE_ACCOUNT, mockPepper.password)
setCachedPasswordHash(mockAccount, expectedPasswordHash)

jest.mocked(getWalletAsync).mockResolvedValue({
unlockAccount: mockUnlockAccount,
} as any)
})

it('returns true if the pin unlocks the account, and refresh the stored password even if a stored password exists', async () => {
mockStore.getState.mockImplementationOnce(() =>
getMockStoreData({ identity: { shouldRefreshStoredPasswordHash: true } })
)

const result = await checkPin(mockPin, mockAccount)

expect(result).toBe(true)
expect(mockUnlockAccount).toHaveBeenCalledWith(mockAccount, expectedPassword, 600)
expect(mockedKeychain.setGenericPassword).toHaveBeenCalledTimes(1)
expect(mockedKeychain.setGenericPassword).toHaveBeenCalledWith(
'CELO',
expectedPasswordHash,
expect.objectContaining({
service: passwordHashStorageKey(mockAccount),
})
)
expect(mockStore.dispatch).toHaveBeenCalledWith(storedPasswordRefreshed())
})

it('returns true if the pin unlocks the account, and stored the password if it does not exist', async () => {
setCachedPasswordHash(mockAccount, '') // no cached password hash
mockedKeychain.getGenericPassword.mockResolvedValueOnce(false) // no stored password hash
mockStore.getState.mockImplementationOnce(() =>
getMockStoreData({ identity: { shouldRefreshStoredPasswordHash: false } })
)

const result = await checkPin(mockPin, mockAccount)

expect(result).toBe(true)
expect(mockUnlockAccount).toHaveBeenCalledWith(mockAccount, expectedPassword, 600)
expect(mockedKeychain.setGenericPassword).toHaveBeenCalledTimes(1)
expect(mockedKeychain.setGenericPassword).toHaveBeenCalledWith(
'CELO',
expectedPasswordHash,
expect.objectContaining({
service: passwordHashStorageKey(mockAccount),
})
)
expect(mockStore.dispatch).toHaveBeenCalledWith(storedPasswordRefreshed())
})

it('returns true if the pin matches the stored password, without unlocking the account or updating the keychain', async () => {
mockStore.getState.mockImplementationOnce(() =>
getMockStoreData({ identity: { shouldRefreshStoredPasswordHash: false } })
)

const result = await checkPin(mockPin, mockAccount)

expect(result).toBe(true)
expect(mockUnlockAccount).not.toHaveBeenCalled()
expect(mockedKeychain.setGenericPassword).not.toHaveBeenCalled()
expect(mockStore.dispatch).not.toHaveBeenCalled()
})

it('returns false if the pin does not match the stored password', async () => {
mockStore.getState.mockImplementationOnce(() =>
getMockStoreData({ identity: { shouldRefreshStoredPasswordHash: false } })
)

const result = await checkPin('143826', mockAccount) // incorrect pin

expect(result).toBe(false)
expect(mockUnlockAccount).not.toHaveBeenCalled()
expect(mockedKeychain.setGenericPassword).not.toHaveBeenCalled()
expect(mockStore.dispatch).not.toHaveBeenCalled()
})

it('returns false if the pin does not unlock the wallet', async () => {
const incorrectPin = '143826'
const incorrectPassword = mockPepper.password + incorrectPin
mockStore.getState.mockImplementationOnce(() =>
getMockStoreData({ identity: { shouldRefreshStoredPasswordHash: true } })
)

const result = await checkPin('143826', mockAccount) // incorrect pin

expect(result).toBe(false)
expect(mockUnlockAccount).toHaveBeenCalledWith(mockAccount, incorrectPassword, 600)
expect(mockedKeychain.setGenericPassword).not.toHaveBeenCalled()
expect(mockStore.dispatch).not.toHaveBeenCalled()
})
})
11 changes: 9 additions & 2 deletions src/pincode/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { ErrorMessages } from 'src/app/ErrorMessages'
import { getStoredMnemonic, storeMnemonic } from 'src/backup/utils'
import i18n from 'src/i18n'
import { storedPasswordRefreshed } from 'src/identity/actions'
import { shouldRefreshStoredPasswordHashSelector } from 'src/identity/selectors'
import { navigate, navigateBack } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import {
Expand Down Expand Up @@ -140,6 +142,7 @@ export async function retrieveOrGeneratePepper(account = DEFAULT_CACHE_ACCOUNT)
if (!getCachedPepper(account)) {
let storedPepper = await retrieveStoredItem(STORAGE_KEYS.PEPPER)
if (!storedPepper) {
Logger.debug(TAG, 'No stored pepper, generating new pepper and storing it to the keychain')
const randomBytes = await generateSecureRandom(PEPPER_LENGTH)
const pepper = Buffer.from(randomBytes).toString('hex')
await storeItem({ key: STORAGE_KEYS.PEPPER, value: pepper })
Expand Down Expand Up @@ -395,15 +398,19 @@ export async function requestPincodeInput(

// Confirm pin is correct by checking it against the stored password hash
export async function checkPin(pin: string, account: string) {
const shouldRefreshStoredPasswordHash = shouldRefreshStoredPasswordHashSelector(store.getState())

const hashForPin = await getPasswordHashForPin(pin)
const correctHash = await retrievePasswordHash(account)

if (!correctHash) {
Logger.warn(`${TAG}@checkPin`, 'No password hash stored. Checking with rpcWallet instead.')
if (!correctHash || shouldRefreshStoredPasswordHash) {
Logger.warn(`${TAG}@checkPin`, 'Validating pin without stored password hash')
const password = await getPasswordForPin(pin)
const unlocked = await ensureCorrectPassword(password, account)
if (unlocked) {
await storePasswordHash(hashForPin, account)
store.dispatch(storedPasswordRefreshed())

return true
}
return false
Expand Down
7 changes: 7 additions & 0 deletions src/redux/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1744,4 +1744,11 @@ export const migrations = {
pointsConfigStatus: 'idle',
},
}),
208: (state: any) => ({
...state,
identity: {
...state.identity,
shouldRefreshStoredPasswordHash: true,
},
}),
}
3 changes: 2 additions & 1 deletion src/redux/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('store state', () => {
{
"_persist": {
"rehydrated": true,
"version": 207,
"version": 208,
},
"account": {
"acceptedTerms": false,
Expand Down Expand Up @@ -253,6 +253,7 @@ describe('store state', () => {
},
"lastSavedContactsHash": null,
"secureSendPhoneNumberMapping": {},
"shouldRefreshStoredPasswordHash": true,
"walletToAccountAddress": {},
},
"imports": {
Expand Down
2 changes: 1 addition & 1 deletion src/redux/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const persistConfig: PersistConfig<ReducersRootState> = {
key: 'root',
// default is -1, increment as we make migrations
// See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration
version: 207,
version: 208,
keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems.
storage: FSStorage(),
blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', 'jumpstart'],
Expand Down
4 changes: 2 additions & 2 deletions src/web3/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('getViemWallet', () => {
.provide([
[select(walletAddressSelector), '0x123'],
[call(listStoredAccounts), [{ address: '0x123', createdAt: date }]],
[call(getPasswordSaga, '0x123', false, true), 'password'],
[call(getPasswordSaga, '0x123', true, false), 'password'],
[
call(getStoredPrivateKey, { address: '0x123', createdAt: new Date() }, 'password'),
null,
Expand All @@ -61,7 +61,7 @@ describe('getViemWallet', () => {
.provide([
[select(walletAddressSelector), '0x123'],
[call(listStoredAccounts), [{ address: '0x123', createdAt: date }]],
[call(getPasswordSaga, '0x123', false, true), 'password'],
[call(getPasswordSaga, '0x123', true, false), 'password'],
[
call(getStoredPrivateKey, { address: '0x123', createdAt: new Date() }, 'password'),
'password',
Expand Down
2 changes: 1 addition & 1 deletion src/web3/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function* getViemWallet(chain: Chain) {
if (!account) {
throw new Error(`Account ${walletAddress} not found in Keychain`)
}
const password = yield* call(getPasswordSaga, walletAddress, false, true)
const password = yield* call(getPasswordSaga, walletAddress, true, false)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the fix for new users. everything else is fixing the bad leftover state for existing users.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be in a follow up, but should we change the arg type of getPasswordSaga to an object, so its explicit on what booleans are set?. E.g.,

const password = yield* call(getPasswordSaga, { account: walletAddress, withVerification: true })

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes absolutely (although i'd prefer to refactor that function to remove these arguments / code paths altogether, they should be in separate functions that are called predictably) but wanted to keep this fix isolated here

const privateKey = yield* call(getStoredPrivateKey, account, password)
if (!privateKey) {
throw new Error(`Private key not found for account ${walletAddress}`)
Expand Down
4 changes: 4 additions & 0 deletions test/RootStateSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4716,6 +4716,9 @@
"secureSendPhoneNumberMapping": {
"$ref": "#/definitions/SecureSendPhoneNumberMapping"
},
"shouldRefreshStoredPasswordHash": {
"type": "boolean"
},
"walletToAccountAddress": {
"$ref": "#/definitions/WalletToAccountAddressType"
}
Expand All @@ -4732,6 +4735,7 @@
"importContactsProgress",
"lastSavedContactsHash",
"secureSendPhoneNumberMapping",
"shouldRefreshStoredPasswordHash",
"walletToAccountAddress"
],
"type": "object"
Expand Down
14 changes: 13 additions & 1 deletion test/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3249,6 +3249,18 @@ export const v207Schema = {
},
}

export const v208Schema = {
...v207Schema,
_persist: {
...v207Schema._persist,
version: 208,
},
identity: {
...v207Schema.identity,
shouldRefreshStoredPasswordHash: true,
},
}

export function getLatestSchema(): Partial<RootState> {
return v207Schema as Partial<RootState>
return v208Schema as Partial<RootState>
}
Loading