fix: wait for password input in settings page

pull/3178/head
Audric Ackermann 8 months ago
parent ba601360de
commit e9a03bdfba
No known key found for this signature in database

@ -71,6 +71,18 @@ window.setPassword = async (passPhrase, oldPhrase) =>
ipc.send('set-password', 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 => window.setStartInTray = async startInTray =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
ipc.once('start-in-tray-on-start-response', (_event, error) => { ipc.once('start-in-tray-on-start-response', (_event, error) => {

@ -112,10 +112,9 @@ class SessionPasswordPromptInner extends PureComponent<unknown, State> {
} }
public async onLogin(passPhrase: string) { 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 { try {
await window.onLogin(passPhraseTrimmed); await window.onLogin(passPhrase);
} catch (error) { } catch (error) {
// Increment the error counter and show the button if necessary // Increment the error counter and show the button if necessary
this.setState({ this.setState({

@ -2,9 +2,9 @@ import { useDispatch } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { useRef } from 'react'; import { useRef } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import useMount from 'react-use/lib/useMount'; import useMount from 'react-use/lib/useMount';
import { ToastUtils } from '../../session/utils'; import { ToastUtils } from '../../session/utils';
import { matchesHash } from '../../util/passwordUtils';
import { updateEnterPasswordModal } from '../../state/ducks/modalDialog'; import { updateEnterPasswordModal } from '../../state/ducks/modalDialog';
import { SpacerSM } from '../basic/Text'; import { SpacerSM } from '../basic/Text';
@ -18,47 +18,48 @@ const StyledModalContainer = styled.div`
`; `;
export type EnterPasswordModalProps = { export type EnterPasswordModalProps = {
passwordHash: string;
passwordValid: boolean;
setPasswordValid: (value: boolean) => void; setPasswordValid: (value: boolean) => void;
onClickOk?: () => any; onClickOk?: () => void;
onClickClose?: () => any; onClickClose?: () => void;
title?: string;
}; };
export const EnterPasswordModal = (props: EnterPasswordModalProps) => { 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<HTMLInputElement>(null); const passwordInputRef = useRef<HTMLInputElement>(null);
const dispatch = useDispatch(); const dispatch = useDispatch();
const onClose = () => { const onPasswordVerified = () => {
if (onClickClose) { onClickOk?.();
onClickClose();
}
dispatch(updateEnterPasswordModal(null)); dispatch(updateEnterPasswordModal(null));
}; };
const confirmPassword = () => { const [, verifyPassword] = useAsyncFn(async () => {
const passwordValue = passwordInputRef.current?.value; try {
if (!passwordValue) { const passwordValue = passwordInputRef.current?.value;
ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('noGivenPassword')); if (!passwordValue) {
ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('noGivenPassword'));
return; return;
} }
const isPasswordValid = matchesHash(passwordValue as string, passwordHash); // this throws if the password is invalid.
if (passwordHash && !isPasswordValid) { await window.onTryPassword(passwordValue);
ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('invalidPassword'));
return; setPasswordValid(true);
onPasswordVerified();
} catch (e) {
window.log.error('window.onTryPassword failed with', e);
ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('invalidPassword'));
} }
});
setPasswordValid(true); const onClose = () => {
if (onClickClose) {
if (onClickOk) { onClickClose();
void onClickOk();
} }
dispatch(updateEnterPasswordModal(null));
}; };
useMount(() => { useMount(() => {
@ -69,7 +70,7 @@ export const EnterPasswordModal = (props: EnterPasswordModalProps) => {
useHotkey('Enter', (event: KeyboardEvent) => { useHotkey('Enter', (event: KeyboardEvent) => {
if (event.target === passwordInputRef.current) { if (event.target === passwordInputRef.current) {
confirmPassword(); void verifyPassword();
} }
}); });
@ -100,7 +101,7 @@ export const EnterPasswordModal = (props: EnterPasswordModalProps) => {
<SessionButton <SessionButton
text={window.i18n('done')} text={window.i18n('done')}
buttonType={SessionButtonType.Simple} buttonType={SessionButtonType.Simple}
onClick={confirmPassword} onClick={verifyPassword}
dataTestId="session-confirm-ok-button" dataTestId="session-confirm-ok-button"
/> />
<SessionButton <SessionButton

@ -1,8 +1,8 @@
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { useState } from 'react'; import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import useMount from 'react-use/lib/useMount';
import styled from 'styled-components'; import styled from 'styled-components';
import { useHotkey } from '../../../hooks/useHotkey';
import { useIconToImageURL } from '../../../hooks/useIconToImageURL'; import { useIconToImageURL } from '../../../hooks/useIconToImageURL';
import { usePasswordModal } from '../../../hooks/usePasswordModal'; import { usePasswordModal } from '../../../hooks/usePasswordModal';
import { mnDecode } from '../../../session/crypto/mnemonic'; import { mnDecode } from '../../../session/crypto/mnemonic';
@ -11,6 +11,7 @@ import {
updateLightBoxOptions, updateLightBoxOptions,
} from '../../../state/ducks/modalDialog'; } from '../../../state/ducks/modalDialog';
import { showSettingsSection } from '../../../state/ducks/section'; import { showSettingsSection } from '../../../state/ducks/section';
import { getIsModalVisble } from '../../../state/selectors/modal';
import { useHideRecoveryPasswordEnabled } from '../../../state/selectors/settings'; import { useHideRecoveryPasswordEnabled } from '../../../state/selectors/settings';
import { useIsDarkTheme } from '../../../state/selectors/theme'; import { useIsDarkTheme } from '../../../state/selectors/theme';
import { THEME_GLOBALS } from '../../../themes/globals'; import { THEME_GLOBALS } from '../../../themes/globals';
@ -28,8 +29,6 @@ import {
SessionSettingsItemWrapper, SessionSettingsItemWrapper,
StyledSettingItem, StyledSettingItem,
} from '../SessionSettingListItem'; } from '../SessionSettingListItem';
import { useHotkey } from '../../../hooks/useHotkey';
import { getIsModalVisble } from '../../../state/selectors/modal';
const StyledSettingsItemContainer = styled.div` const StyledSettingsItemContainer = styled.div`
p { p {
@ -68,9 +67,11 @@ const qrLogoProps: QRCodeLogoProps = {
}; };
export const SettingsCategoryRecoveryPassword = () => { export const SettingsCategoryRecoveryPassword = () => {
const [loadingSeed, setLoadingSeed] = useState(true); const recoveryPhrase = getCurrentRecoveryPhrase();
const [recoveryPhrase, setRecoveryPhrase] = useState(''); if (!recoveryPhrase || isEmpty(recoveryPhrase)) {
const [hexEncodedSeed, setHexEncodedSeed] = useState(''); throw new Error('SettingsCategoryRecoveryPassword recovery seed is empty');
}
const hexEncodedSeed = mnDecode(recoveryPhrase, 'english');
const [isQRVisible, setIsQRVisible] = useState(false); const [isQRVisible, setIsQRVisible] = useState(false);
const hideRecoveryPassword = useHideRecoveryPasswordEnabled(); const hideRecoveryPassword = useHideRecoveryPasswordEnabled();
@ -82,27 +83,11 @@ export const SettingsCategoryRecoveryPassword = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { hasPassword, passwordValid } = usePasswordModal({ const { hasPassword, passwordValid } = usePasswordModal({
title: window.i18n('sessionRecoveryPassword'),
onClose: () => { onClose: () => {
dispatch(showSettingsSection('privacy')); 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( useHotkey(
'v', 'v',
() => { () => {
@ -110,10 +95,10 @@ export const SettingsCategoryRecoveryPassword = () => {
setIsQRVisible(!isQRVisible); setIsQRVisible(!isQRVisible);
} }
}, },
(hasPassword && !passwordValid) || loadingSeed || hideRecoveryPassword (hasPassword && !passwordValid) || hideRecoveryPassword
); );
if ((hasPassword && !passwordValid) || loadingSeed || hideRecoveryPassword) { if ((hasPassword && !passwordValid) || hideRecoveryPassword) {
return null; return null;
} }

@ -1,4 +1,3 @@
import { isEmpty } from 'lodash';
import { useState } from 'react'; import { useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import useMount from 'react-use/lib/useMount'; 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 * 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 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 * @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 * @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({ export function usePasswordModal({
title,
onSuccess, onSuccess,
onClose, onClose,
}: { }: {
title?: string;
onSuccess?: () => void; onSuccess?: () => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const [hasPassword, setHasPassword] = useState(false);
const [passwordHash, setPasswordHash] = useState('');
const [passwordValid, setPasswordValid] = useState(false);
const dispatch = useDispatch(); const dispatch = useDispatch();
const validateAccess = () => { const hashFromStorage = getPasswordHash();
if (!isEmpty(passwordHash)) { const [hasPassword] = useState(!!hashFromStorage);
return;
}
const hash = getPasswordHash(); const [passwordValid, setPasswordValid] = useState(!hasPassword);
setHasPassword(!!hash);
if (hash) { useMount(() => {
setPasswordHash(hash); // if no hash is set, the user didn't set a password.
dispatch( // we can just show whatever was password protected
updateEnterPasswordModal({ if (!hashFromStorage || passwordValid) {
passwordHash, return;
passwordValid,
setPasswordValid,
onClickOk: () => {
if (onSuccess) {
onSuccess();
}
setPasswordHash('');
dispatch(updateEnterPasswordModal(null));
},
onClickClose: () => {
if (onClose) {
onClose();
}
setPasswordHash('');
dispatch(updateEnterPasswordModal(null));
},
title,
})
);
} }
};
useMount(() => { dispatch(
validateAccess(); updateEnterPasswordModal({
setPasswordValid,
onClickOk: onSuccess,
onClickClose: onClose,
})
);
}); });
return { hasPassword, passwordValid }; return { hasPassword, passwordValid };

@ -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) => { ipc.on('start-in-tray-on-start', (event, newValue) => {
try { try {
userConfig.set('startInTray', newValue); userConfig.set('startInTray', newValue);

@ -14,27 +14,26 @@ const sha512 = (text: string) => {
export const MAX_PASSWORD_LENGTH = 64; 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) => export const matchesHash = (phrase: string | null, hash: string) =>
phrase && sha512(phrase.trim()) === hash.trim(); phrase && sha512(phrase) === hash;
export const validatePassword = (phrase: string) => { export const validatePassword = (phrase: string) => {
if (typeof phrase !== 'string') { if (typeof phrase !== 'string') {
return window?.i18n ? window?.i18n('passwordTypeError') : ERRORS.TYPE; return window?.i18n ? window?.i18n('passwordTypeError') : ERRORS.TYPE;
} }
const trimmed = phrase.trim(); if (phrase.length === 0) {
if (trimmed.length === 0) {
return window?.i18n ? window?.i18n('noGivenPassword') : ERRORS.LENGTH; 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; return window?.i18n ? window?.i18n('passwordLengthError') : ERRORS.LENGTH;
} }
// Restrict characters to letters, numbers and symbols // Restrict characters to letters, numbers and symbols
const characterRegex = /^[a-zA-Z0-9-!?/\\()._`~@#$%^&*+=[\]{}|<>,;: ]+$/; const characterRegex = /^[a-zA-Z0-9-!?/\\()._`~@#$%^&*+=[\]{}|<>,;: ]+$/;
if (!characterRegex.test(trimmed)) { if (!characterRegex.test(phrase)) {
return window?.i18n ? window?.i18n('passwordCharacterError') : ERRORS.CHARACTER; return window?.i18n ? window?.i18n('passwordCharacterError') : ERRORS.CHARACTER;
} }

3
ts/window.d.ts vendored

@ -42,7 +42,8 @@ declare global {
debugOnionRequests: boolean; debugOnionRequests: boolean;
}; };
}; };
onLogin: (pw: string) => Promise<void>; onLogin: (pw: string) => Promise<void>; // only set on the password window
onTryPassword: (pw: string) => Promise<void>; // only set on the main window
persistStore?: Persistor; persistStore?: Persistor;
restart: () => void; restart: () => void;
getSeedNodeList: () => Array<string> | undefined; getSeedNodeList: () => Array<string> | undefined;

Loading…
Cancel
Save