From e9a03bdfba28828bf1b089801f1ccb62ec3584ee Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 27 Aug 2024 15:19:49 +1000 Subject: [PATCH] fix: wait for password input in settings page --- preload.js | 12 ++++ ts/components/SessionPasswordPrompt.tsx | 5 +- ts/components/dialog/EnterPasswordModal.tsx | 55 +++++++++--------- .../section/CategoryRecoveryPassword.tsx | 33 +++-------- ts/hooks/usePasswordModal.ts | 56 +++++-------------- ts/mains/main_node.ts | 22 ++++++++ ts/util/passwordUtils.ts | 11 ++-- ts/window.d.ts | 3 +- 8 files changed, 95 insertions(+), 102 deletions(-) diff --git a/preload.js b/preload.js index c143899f3..eb1e0afd9 100644 --- a/preload.js +++ b/preload.js @@ -71,6 +71,18 @@ window.setPassword = async (passPhrase, oldPhrase) => ipc.send('set-password', passPhrase, oldPhrase); }); +// called to verify that the password is correct when showing the recovery from seed modal +window.onTryPassword = passPhrase => + new Promise((resolve, reject) => { + ipcRenderer.once('password-recovery-phrase-response', (event, error) => { + if (error) { + return reject(error); + } + return resolve(); + }); + ipcRenderer.send('password-recovery-phrase', passPhrase); + }); + window.setStartInTray = async startInTray => new Promise((resolve, reject) => { ipc.once('start-in-tray-on-start-response', (_event, error) => { diff --git a/ts/components/SessionPasswordPrompt.tsx b/ts/components/SessionPasswordPrompt.tsx index 290a21638..1599b5daa 100644 --- a/ts/components/SessionPasswordPrompt.tsx +++ b/ts/components/SessionPasswordPrompt.tsx @@ -112,10 +112,9 @@ class SessionPasswordPromptInner extends PureComponent { } public async onLogin(passPhrase: string) { - const passPhraseTrimmed = passPhrase.trim(); - + // Note: we don't trim the password anymore. If the user entered a space at the end, so be it. try { - await window.onLogin(passPhraseTrimmed); + await window.onLogin(passPhrase); } catch (error) { // Increment the error counter and show the button if necessary this.setState({ diff --git a/ts/components/dialog/EnterPasswordModal.tsx b/ts/components/dialog/EnterPasswordModal.tsx index b5df86494..c6fe3981a 100644 --- a/ts/components/dialog/EnterPasswordModal.tsx +++ b/ts/components/dialog/EnterPasswordModal.tsx @@ -2,9 +2,9 @@ import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { useRef } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; import useMount from 'react-use/lib/useMount'; import { ToastUtils } from '../../session/utils'; -import { matchesHash } from '../../util/passwordUtils'; import { updateEnterPasswordModal } from '../../state/ducks/modalDialog'; import { SpacerSM } from '../basic/Text'; @@ -18,47 +18,48 @@ const StyledModalContainer = styled.div` `; export type EnterPasswordModalProps = { - passwordHash: string; - passwordValid: boolean; setPasswordValid: (value: boolean) => void; - onClickOk?: () => any; - onClickClose?: () => any; - title?: string; + onClickOk?: () => void; + onClickClose?: () => void; }; export const EnterPasswordModal = (props: EnterPasswordModalProps) => { - const { passwordHash, setPasswordValid, onClickOk, onClickClose, title } = props; + const { setPasswordValid, onClickOk, onClickClose } = props; + const title = window.i18n('sessionRecoveryPassword'); const passwordInputRef = useRef(null); const dispatch = useDispatch(); - const onClose = () => { - if (onClickClose) { - onClickClose(); - } + const onPasswordVerified = () => { + onClickOk?.(); dispatch(updateEnterPasswordModal(null)); }; - const confirmPassword = () => { - const passwordValue = passwordInputRef.current?.value; - if (!passwordValue) { - ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('noGivenPassword')); + const [, verifyPassword] = useAsyncFn(async () => { + try { + const passwordValue = passwordInputRef.current?.value; + if (!passwordValue) { + ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('noGivenPassword')); - return; - } + return; + } - const isPasswordValid = matchesHash(passwordValue as string, passwordHash); - if (passwordHash && !isPasswordValid) { - ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('invalidPassword')); + // this throws if the password is invalid. + await window.onTryPassword(passwordValue); - return; + setPasswordValid(true); + onPasswordVerified(); + } catch (e) { + window.log.error('window.onTryPassword failed with', e); + ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('invalidPassword')); } + }); - setPasswordValid(true); - - if (onClickOk) { - void onClickOk(); + const onClose = () => { + if (onClickClose) { + onClickClose(); } + dispatch(updateEnterPasswordModal(null)); }; useMount(() => { @@ -69,7 +70,7 @@ export const EnterPasswordModal = (props: EnterPasswordModalProps) => { useHotkey('Enter', (event: KeyboardEvent) => { if (event.target === passwordInputRef.current) { - confirmPassword(); + void verifyPassword(); } }); @@ -100,7 +101,7 @@ export const EnterPasswordModal = (props: EnterPasswordModalProps) => { { - const [loadingSeed, setLoadingSeed] = useState(true); - const [recoveryPhrase, setRecoveryPhrase] = useState(''); - const [hexEncodedSeed, setHexEncodedSeed] = useState(''); + const recoveryPhrase = getCurrentRecoveryPhrase(); + if (!recoveryPhrase || isEmpty(recoveryPhrase)) { + throw new Error('SettingsCategoryRecoveryPassword recovery seed is empty'); + } + const hexEncodedSeed = mnDecode(recoveryPhrase, 'english'); const [isQRVisible, setIsQRVisible] = useState(false); const hideRecoveryPassword = useHideRecoveryPasswordEnabled(); @@ -82,27 +83,11 @@ export const SettingsCategoryRecoveryPassword = () => { const dispatch = useDispatch(); const { hasPassword, passwordValid } = usePasswordModal({ - title: window.i18n('sessionRecoveryPassword'), onClose: () => { dispatch(showSettingsSection('privacy')); }, }); - const fetchRecoverPhrase = () => { - const newRecoveryPhrase = getCurrentRecoveryPhrase(); - setRecoveryPhrase(newRecoveryPhrase); - if (!isEmpty(newRecoveryPhrase)) { - setHexEncodedSeed(mnDecode(newRecoveryPhrase, 'english')); - } - setLoadingSeed(false); - }; - - useMount(() => { - if (!hasPassword || (hasPassword && passwordValid)) { - fetchRecoverPhrase(); - } - }); - useHotkey( 'v', () => { @@ -110,10 +95,10 @@ export const SettingsCategoryRecoveryPassword = () => { setIsQRVisible(!isQRVisible); } }, - (hasPassword && !passwordValid) || loadingSeed || hideRecoveryPassword + (hasPassword && !passwordValid) || hideRecoveryPassword ); - if ((hasPassword && !passwordValid) || loadingSeed || hideRecoveryPassword) { + if ((hasPassword && !passwordValid) || hideRecoveryPassword) { return null; } diff --git a/ts/hooks/usePasswordModal.ts b/ts/hooks/usePasswordModal.ts index 3fd24f5d9..511883710 100644 --- a/ts/hooks/usePasswordModal.ts +++ b/ts/hooks/usePasswordModal.ts @@ -1,4 +1,3 @@ -import { isEmpty } from 'lodash'; import { useState } from 'react'; import { useDispatch } from 'react-redux'; import useMount from 'react-use/lib/useMount'; @@ -7,63 +6,38 @@ import { getPasswordHash } from '../util/storage'; /** * Password protection for a component if a password has been set - * @param title - Title of the password modal * @param onSuccess - Callback when password is correct * @param onClose - Callback when modal is cancelled or closed. Definitely use this if your component returns null until a password is entered * @returns An object with two properties - hasPassword which is true if a password has been set, passwordValid which is true if the password entered is correct */ export function usePasswordModal({ - title, onSuccess, onClose, }: { - title?: string; onSuccess?: () => void; onClose?: () => void; }) { - const [hasPassword, setHasPassword] = useState(false); - const [passwordHash, setPasswordHash] = useState(''); - const [passwordValid, setPasswordValid] = useState(false); - const dispatch = useDispatch(); - const validateAccess = () => { - if (!isEmpty(passwordHash)) { - return; - } + const hashFromStorage = getPasswordHash(); + const [hasPassword] = useState(!!hashFromStorage); - const hash = getPasswordHash(); - setHasPassword(!!hash); + const [passwordValid, setPasswordValid] = useState(!hasPassword); - if (hash) { - setPasswordHash(hash); - dispatch( - updateEnterPasswordModal({ - passwordHash, - passwordValid, - setPasswordValid, - onClickOk: () => { - if (onSuccess) { - onSuccess(); - } - setPasswordHash(''); - dispatch(updateEnterPasswordModal(null)); - }, - onClickClose: () => { - if (onClose) { - onClose(); - } - setPasswordHash(''); - dispatch(updateEnterPasswordModal(null)); - }, - title, - }) - ); + useMount(() => { + // if no hash is set, the user didn't set a password. + // we can just show whatever was password protected + if (!hashFromStorage || passwordValid) { + return; } - }; - useMount(() => { - validateAccess(); + dispatch( + updateEnterPasswordModal({ + setPasswordValid, + onClickOk: onSuccess, + onClickClose: onClose, + }) + ); }); return { hasPassword, passwordValid }; diff --git a/ts/mains/main_node.ts b/ts/mains/main_node.ts index b9b221bc0..64206ea48 100644 --- a/ts/mains/main_node.ts +++ b/ts/mains/main_node.ts @@ -995,6 +995,28 @@ ipc.on('password-window-login', async (event, passPhrase) => { } }); +ipc.on('password-recovery-phrase', async (event, passPhrase) => { + const sendResponse = (e: string | undefined) => { + event.sender.send('password-recovery-phrase-response', e); + }; + + try { + // Check if the hash we have stored matches the given password. + const hash = sqlNode.getPasswordHash(); + + const hashMatches = passPhrase && PasswordUtil.matchesHash(passPhrase, hash); + if (hash && !hashMatches) { + throw new Error('Invalid password'); + } + // no issues. send back undefined, meaning OK + sendResponse(undefined); + } catch (e) { + const localisedError = locale.messages.removePasswordInvalid; + // send back the error + sendResponse(localisedError); + } +}); + ipc.on('start-in-tray-on-start', (event, newValue) => { try { userConfig.set('startInTray', newValue); diff --git a/ts/util/passwordUtils.ts b/ts/util/passwordUtils.ts index d6c12d1e0..5bb9cbda5 100644 --- a/ts/util/passwordUtils.ts +++ b/ts/util/passwordUtils.ts @@ -14,27 +14,26 @@ const sha512 = (text: string) => { export const MAX_PASSWORD_LENGTH = 64; -export const generateHash = (phrase: string) => phrase && sha512(phrase.trim()); +export const generateHash = (phrase: string) => phrase && sha512(phrase); export const matchesHash = (phrase: string | null, hash: string) => - phrase && sha512(phrase.trim()) === hash.trim(); + phrase && sha512(phrase) === hash; export const validatePassword = (phrase: string) => { if (typeof phrase !== 'string') { return window?.i18n ? window?.i18n('passwordTypeError') : ERRORS.TYPE; } - const trimmed = phrase.trim(); - if (trimmed.length === 0) { + if (phrase.length === 0) { return window?.i18n ? window?.i18n('noGivenPassword') : ERRORS.LENGTH; } - if (trimmed.length < 6 || trimmed.length > MAX_PASSWORD_LENGTH) { + if (phrase.length < 6 || phrase.length > MAX_PASSWORD_LENGTH) { return window?.i18n ? window?.i18n('passwordLengthError') : ERRORS.LENGTH; } // Restrict characters to letters, numbers and symbols const characterRegex = /^[a-zA-Z0-9-!?/\\()._`~@#$%^&*+=[\]{}|<>,;: ]+$/; - if (!characterRegex.test(trimmed)) { + if (!characterRegex.test(phrase)) { return window?.i18n ? window?.i18n('passwordCharacterError') : ERRORS.CHARACTER; } diff --git a/ts/window.d.ts b/ts/window.d.ts index b7281c1dc..9d3eef2af 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -42,7 +42,8 @@ declare global { debugOnionRequests: boolean; }; }; - onLogin: (pw: string) => Promise; + onLogin: (pw: string) => Promise; // only set on the password window + onTryPassword: (pw: string) => Promise; // only set on the main window persistStore?: Persistor; restart: () => void; getSeedNodeList: () => Array | undefined;