diff --git a/src/background/Wallet/Wallet.ts b/src/background/Wallet/Wallet.ts index 179f83de5..92ba3aa34 100644 --- a/src/background/Wallet/Wallet.ts +++ b/src/background/Wallet/Wallet.ts @@ -315,6 +315,22 @@ export class Wallet { await this.syncWithWalletStore(); } + async assignNewCredentials({ + params: { credentials, newCredentials }, + }: PublicMethodParams<{ + credentials: SessionCredentials; + newCredentials: SessionCredentials; + }>) { + this.ensureRecord(this.record); + this.record = await Model.reEncryptRecord(this.record, { + credentials, + newCredentials, + }); + this.userCredentials = newCredentials; + await this.updateWalletStore(this.record); + this.setExpirationForSeedPhraseEncryptionKey(1000 * 120); + } + async resetCredentials() { this.userCredentials = null; } diff --git a/src/background/Wallet/WalletRecord.ts b/src/background/Wallet/WalletRecord.ts index 9629a17a1..fec7f746d 100644 --- a/src/background/Wallet/WalletRecord.ts +++ b/src/background/Wallet/WalletRecord.ts @@ -1,5 +1,5 @@ import { decrypt, encrypt } from 'src/modules/crypto'; -import { produce } from 'immer'; +import { createDraft, finishDraft, produce } from 'immer'; import { nanoid } from 'nanoid'; import sortBy from 'lodash/sortBy'; import { toChecksumAddress } from 'src/modules/ethereum/toChecksumAddress'; @@ -477,7 +477,27 @@ export class WalletRecordModel { }) ); - return WalletRecordModel.verifyStateIntegrity(entry as WalletRecord); + return WalletRecordModel.verifyStateIntegrity(entry); + } + + static async reEncryptRecord( + record: WalletRecord, + { + credentials, + newCredentials, + }: { credentials: SessionCredentials; newCredentials: SessionCredentials } + ) { + // Async update flow for Immer: https://immerjs.github.io/immer/async/ + const draft = createDraft(record); + for (const group of draft.walletManager.groups) { + if (isMnemonicContainer(group.walletContainer)) { + await group.walletContainer.reEncryptWallets({ + credentials, + newCredentials, + }); + } + } + return finishDraft(draft); } static async getRecoveryPhrase( diff --git a/src/background/Wallet/model/WalletContainer.ts b/src/background/Wallet/model/WalletContainer.ts index deee7adf5..790c97529 100644 --- a/src/background/Wallet/model/WalletContainer.ts +++ b/src/background/Wallet/model/WalletContainer.ts @@ -210,6 +210,25 @@ export class MnemonicWalletContainer extends WalletContainerImpl { } this.wallets.push(wallet); } + + async reEncryptWallets({ + credentials, + newCredentials, + }: { + credentials: SessionCredentials; + newCredentials: SessionCredentials; + }) { + const { mnemonic: encryptedMnemonic } = this.getFirstWallet(); + invariant(encryptedMnemonic, 'Must be a Mnemonic WalletContainer'); + const phrase = await decryptMnemonic(encryptedMnemonic.phrase, credentials); + const { seedPhraseEncryptionKey } = newCredentials; + const updatedPhrase = await encrypt(seedPhraseEncryptionKey, phrase); + for (const wallet of this.wallets) { + if (wallet.mnemonic) { + wallet.mnemonic.phrase = updatedPhrase; + } + } + } } export class PrivateKeyWalletContainer extends WalletContainerImpl { diff --git a/src/background/account/Account.ts b/src/background/account/Account.ts index eb5cb2591..dbee31c76 100644 --- a/src/background/account/Account.ts +++ b/src/background/account/Account.ts @@ -9,10 +9,12 @@ import { eraseAndUpdateToLatestVersion } from 'src/shared/core/version'; import { currentUserKey } from 'src/shared/getCurrentUser'; import type { PublicUser, User } from 'src/shared/types/User'; import { payloadId } from '@walletconnect/jsonrpc-utils'; +import { invariant } from 'src/shared/invariant'; import { Wallet } from '../Wallet/Wallet'; import { peakSavedWalletState } from '../Wallet/persistence'; import type { NotificationWindow } from '../NotificationWindow/NotificationWindow'; import { credentialsKey } from './storage-keys'; +import { isSessionCredentials } from './Credentials'; const TEMPORARY_ID = 'temporary'; @@ -20,6 +22,38 @@ async function sha256({ password, salt }: { password: string; salt: string }) { return await getSHA256HexDigest(`${salt}:${password}`); } +async function deriveUserKeys({ + user, + credentials, +}: { + user: User; + credentials: { password: string } | { encryptionKey: string }; +}) { + let encryptionKey: string | null = null; + let seedPhraseEncryptionKey: string | null = null; + let seedPhraseEncryptionKey_deprecated: CryptoKey | null = null; + if ('password' in credentials) { + const { password } = credentials; + const [key1, key2, key3] = await Promise.all([ + sha256({ salt: user.id, password }), + sha256({ salt: user.salt, password }), + createCryptoKey(password, user.salt), + ]); + encryptionKey = key1; + seedPhraseEncryptionKey = key2; + seedPhraseEncryptionKey_deprecated = key3; + } else { + encryptionKey = credentials.encryptionKey; + } + + return { + id: user.id, + encryptionKey, + seedPhraseEncryptionKey, + seedPhraseEncryptionKey_deprecated, + }; +} + class EventEmitter { private emitter = createNanoEvents(); @@ -77,17 +111,27 @@ export class Account extends EventEmitter { } } - static async createUser(password: string): Promise { + static validatePassword(password: string) { const validity = validate({ password }); if (!validity.valid) { throw new Error(validity.message); } + } + + static async createUser(password: string): Promise { + Account.validatePassword(password); const id = nanoid(36); // use longer id than default (21) const salt = createSalt(); // used to encrypt seed phrases const record = { id, salt /* passwordHash: hash */ }; return record; } + /** Updates salt */ + static async updateUser(user: User): Promise { + const salt = createSalt(); // used to encrypt seed phrases + return { id: user.id, salt }; + } + constructor({ notificationWindow, }: { @@ -100,6 +144,7 @@ export class Account extends EventEmitter { this.notificationWindow = notificationWindow; this.wallet = new Wallet(TEMPORARY_ID, null, this.notificationWindow); this.on('authenticated', () => { + // TODO: Call Account.writeCurrentUser() here, too? if (this.encryptionKey) { Account.writeCredentials({ encryptionKey: this.encryptionKey }); } @@ -152,39 +197,75 @@ export class Account extends EventEmitter { await this.setUser(user, { password }, { isNewUser: false }); } + async changePassword({ + currentPassword, + newPassword, + user: currentUser, + }: { + user: User; + currentPassword: string; + newPassword: string; + }) { + Account.validatePassword(newPassword); + await this.login(currentUser, currentPassword); + invariant(this.user, 'User must be set'); + const updatedUser = await Account.updateUser(this.user); + const currentCredentials = await deriveUserKeys({ + user: currentUser, + credentials: { password: currentPassword }, + }); + const newCredentials = await deriveUserKeys({ + user: updatedUser, + credentials: { password: newPassword }, + }); + console.log({ currentCredentials, newCredentials }); + if ( + !isSessionCredentials(currentCredentials) || + !isSessionCredentials(newCredentials) + ) { + throw new Error('Full credentials are expected'); + } + await this.wallet.assignNewCredentials({ + id: payloadId(), + params: { newCredentials, credentials: currentCredentials }, + }); + // Update local state only if the above call was successful + this.user = updatedUser; + this.encryptionKey = newCredentials.encryptionKey; + await Account.writeCurrentUser(this.user); + this.emit('authenticated'); + } + async setUser( user: User, - credentials: { password: string } | { encryptionKey: string }, + partialCredentials: { password: string } | { encryptionKey: string }, { isNewUser = false } = {} ) { this.user = user; this.isPendingNewUser = isNewUser; - let seedPhraseEncryptionKey: string | null = null; - let seedPhraseEncryptionKey_deprecated: CryptoKey | null = null; - if ('password' in credentials) { - const { password } = credentials; - const [key1, key2, key3] = await Promise.all([ - sha256({ salt: user.id, password }), - sha256({ salt: user.salt, password }), - createCryptoKey(password, user.salt), - ]); - this.encryptionKey = key1; - seedPhraseEncryptionKey = key2; - seedPhraseEncryptionKey_deprecated = key3; - } else { - this.encryptionKey = credentials.encryptionKey; - } + const credentials = await deriveUserKeys({ + user, + credentials: partialCredentials, + }); + this.encryptionKey = credentials.encryptionKey; + // let seedPhraseEncryptionKey: string | null = null; + // let seedPhraseEncryptionKey_deprecated: CryptoKey | null = null; + // if ('password' in credentials) { + // const { password } = credentials; + // const [key1, key2, key3] = await Promise.all([ + // sha256({ salt: user.id, password }), + // sha256({ salt: user.salt, password }), + // createCryptoKey(password, user.salt), + // ]); + // this.encryptionKey = key1; + // seedPhraseEncryptionKey = key2; + // seedPhraseEncryptionKey_deprecated = key3; + // } else { + // this.encryptionKey = credentials.encryptionKey; + // } await this.wallet.updateCredentials({ id: payloadId(), - params: { - credentials: { - id: user.id, - encryptionKey: this.encryptionKey, - seedPhraseEncryptionKey, - seedPhraseEncryptionKey_deprecated, - }, - isNewUser, - }, + params: { credentials, isNewUser }, }); if (!this.isPendingNewUser) { this.emit('authenticated'); @@ -272,16 +353,25 @@ export class AccountPublicRPC { return null; } + async verifyUser(user: PublicUser) { + const currentUser = await Account.readCurrentUser(); + if (!currentUser || currentUser.id !== user.id) { + throw new Error(`User ${user.id} not found`); + } + return currentUser; + } + async login({ params: { user, password }, }: PublicMethodParams<{ user: PublicUser; password: string; }>): Promise { - const currentUser = await Account.readCurrentUser(); - if (!currentUser || currentUser.id !== user.id) { - throw new Error(`User ${user.id} not found`); - } + const currentUser = await this.verifyUser(user); + // const currentUser = await Account.readCurrentUser(); + // if (!currentUser || currentUser.id !== user.id) { + // throw new Error(`User ${user.id} not found`); + // } const canAuthorize = await this.account.verifyPassword( currentUser, password @@ -294,6 +384,21 @@ export class AccountPublicRPC { } } + async changePassword({ + params: { user, currentPassword, newPassword }, + }: PublicMethodParams<{ + user: PublicUser; + currentPassword: string; + newPassword: string; + }>) { + const currentUser = await this.verifyUser(user); + await this.account.changePassword({ + user: currentUser, + currentPassword, + newPassword, + }); + } + async hasActivePasswordSession() { return this.account.hasActivePasswordSession(); } diff --git a/src/ui/pages/Security/ChangePassword.tsx b/src/ui/pages/Security/ChangePassword.tsx new file mode 100644 index 000000000..97262f337 --- /dev/null +++ b/src/ui/pages/Security/ChangePassword.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Route, Routes, useNavigate } from 'react-router-dom'; +import { getError } from 'src/shared/errors/getError'; +import { invariant } from 'src/shared/invariant'; +import { useBackgroundKind } from 'src/ui/components/Background'; +import { whiteBackgroundKind } from 'src/ui/components/Background/Background'; +import { PageColumn } from 'src/ui/components/PageColumn'; +import { PageHeading } from 'src/ui/components/PageHeading'; +import { PageTop } from 'src/ui/components/PageTop'; +import { ViewSuspense } from 'src/ui/components/ViewSuspense'; +import { accountPublicRPCPort } from 'src/ui/shared/channels'; +import { zeroizeAfterSubmission } from 'src/ui/shared/zeroize-submission'; +import { Button } from 'src/ui/ui-kit/Button'; +import { Input } from 'src/ui/ui-kit/Input'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { VStack } from 'src/ui/ui-kit/VStack'; +import { ChangePasswordSuccess } from './ChangePasswordSuccess'; + +function ChangePassword() { + useBackgroundKind(whiteBackgroundKind); + const navigate = useNavigate(); + const { data: user } = useQuery({ + queryKey: ['account/getExistingUser'], + queryFn: () => accountPublicRPCPort.request('getExistingUser'), + }); + const changePasswordMutation = useMutation({ + mutationFn: async ({ + currentPassword, + newPassword, + }: { + currentPassword: string; + newPassword: string; + }) => { + invariant(user, 'User must be defined'); + await accountPublicRPCPort.request('changePassword', { + user, + currentPassword, + newPassword, + }); + }, + onSuccess: async () => { + zeroizeAfterSubmission(); + navigate('./success'); + }, + }); + const submitError = changePasswordMutation.isError + ? getError(changePasswordMutation.error) + : null; + const incorrectPassword = submitError?.message === 'Incorrect password'; + return ( + + + Change Password + +
{ + event.preventDefault(); + const fd = new FormData(event.currentTarget); + const currentPassword = fd.get('currentPassword') as + | string + | undefined; + const newPassword = fd.get('newPassword') as string | undefined; + invariant(currentPassword, 'currentPassword is required'); + invariant(newPassword, 'newPassword is required'); + changePasswordMutation.mutate({ currentPassword, newPassword }); + }} + > + + + + + + + {submitError && !incorrectPassword ? submitError.message : null} + + + + +
+
+ ); +} + +export function ChangePasswordRoutes() { + return ( + + + + + } + /> + + + + } + /> + + ); +} diff --git a/src/ui/pages/Security/ChangePasswordSuccess.tsx b/src/ui/pages/Security/ChangePasswordSuccess.tsx new file mode 100644 index 000000000..c5bf70776 --- /dev/null +++ b/src/ui/pages/Security/ChangePasswordSuccess.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { animated, useTrail } from '@react-spring/web'; +import { useNavigate } from 'react-router-dom'; +import { NavigationTitle } from 'src/ui/components/NavigationTitle'; +import { PageColumn } from 'src/ui/components/PageColumn'; +import { Spacer } from 'src/ui/ui-kit/Spacer'; +import CheckIcon from 'jsx:src/ui/assets/check-circle-thin.svg'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { Button } from 'src/ui/ui-kit/Button'; +import { VStack } from 'src/ui/ui-kit/VStack'; +import { PageBottom } from 'src/ui/components/PageBottom'; +import { focusNode } from 'src/ui/shared/focusNode'; + +export function ChangePasswordSuccess() { + const navigate = useNavigate(); + const trail = useTrail(3, { + config: { tension: 400 }, + from: { opacity: 0, y: 40 }, + to: { opacity: 1, y: 0 }, + }); + return ( + + + + + + + + + + Password Changed + + + + + All your wallet data has been re-encrypted with the new password + + + + + + + + + ); +} diff --git a/src/ui/pages/Security/Security.tsx b/src/ui/pages/Security/Security.tsx index 43e257243..766c51921 100644 --- a/src/ui/pages/Security/Security.tsx +++ b/src/ui/pages/Security/Security.tsx @@ -13,6 +13,7 @@ import { FrameListItemLink } from 'src/ui/ui-kit/FrameList'; import { useBackgroundKind } from 'src/ui/components/Background'; import { VStack } from 'src/ui/ui-kit/VStack'; import { AUTO_LOCK_TIMER_OPTIONS_TITLES, AutoLockTimer } from './AutoLockTimer'; +import { ChangePasswordRoutes } from './ChangePassword'; function SecurityMain() { const { globalPreferences } = useGlobalPreferences(); @@ -21,32 +22,54 @@ function SecurityMain() { return ( - - - - - - Auto-Lock Timer - {globalPreferences ? ( - - { - AUTO_LOCK_TIMER_OPTIONS_TITLES[ - globalPreferences.autoLockTimeout - ] - } - - ) : ( - - )} - - - - - + + + + + + + Auto-Lock Timer + {globalPreferences ? ( + + { + AUTO_LOCK_TIMER_OPTIONS_TITLES[ + globalPreferences.autoLockTimeout + ] + } + + ) : ( + + )} + + + + + + + + + + + + Change Password + + Or verify that you remember your existing one + + + + + + + + ); } @@ -70,6 +93,14 @@ export function Security() { } /> + + + + } + /> ); } diff --git a/src/ui/shared/form-data.ts b/src/ui/shared/form-data.ts index 00b84ac4d..76af9dcc2 100644 --- a/src/ui/shared/form-data.ts +++ b/src/ui/shared/form-data.ts @@ -4,7 +4,8 @@ export function naiveFormDataToObject( formData: FormData, modifier: (key: keyof T | string, value: unknown) => unknown ) { - const result: Partial<{ [K in keyof T]: T[K] } & Record> = {}; + const result: Partial<{ [K in keyof T]: T[K] } & Record> = + {}; for (const key of new Set(formData.keys())) { if (key.endsWith('[]')) { const value = modifier(key, formData.getAll(key));