Skip to content

Commit 31ab78f

Browse files
authored
feat(e2ee): passphrase requirements (RocketChat#37327)
1 parent 6e128f3 commit 31ab78f

16 files changed

Lines changed: 431 additions & 303 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@rocket.chat/meteor": minor
3+
"@rocket.chat/password-policies": minor
4+
"@rocket.chat/ui-client": minor
5+
"@rocket.chat/ui-contexts": minor
6+
---
7+
8+
Adds complexity requirements to end-to-end encryption passphrase
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { Box, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow, PasswordInput, Button } from '@rocket.chat/fuselage';
2+
import { PasswordVerifierList } from '@rocket.chat/ui-client';
3+
import { useToastMessageDispatch, usePasswordPolicy } from '@rocket.chat/ui-contexts';
4+
import { useMutation } from '@tanstack/react-query';
5+
import DOMPurify from 'dompurify';
6+
import { useEffect, useId } from 'react';
7+
import { Controller, useForm } from 'react-hook-form';
8+
import { Trans, useTranslation } from 'react-i18next';
9+
10+
import { e2e } from '../../../lib/e2ee/rocketchat.e2e';
11+
import { useE2EEState } from '../../room/hooks/useE2EEState';
12+
13+
const PASSPHRASE_POLICY = Object.freeze({
14+
enabled: true,
15+
minLength: 30,
16+
mustContainAtLeastOneLowercase: true,
17+
mustContainAtLeastOneUppercase: true,
18+
mustContainAtLeastOneNumber: true,
19+
mustContainAtLeastOneSpecialCharacter: true,
20+
forbidRepeatingCharacters: false,
21+
});
22+
23+
const useKeysExist = () => {
24+
const state = useE2EEState();
25+
return state === 'READY' || state === 'SAVE_PASSWORD';
26+
};
27+
28+
const useValidatePassphrase = (passphrase: string) => {
29+
const validate = usePasswordPolicy(PASSPHRASE_POLICY);
30+
return validate(passphrase);
31+
};
32+
33+
const useChangeE2EPasswordMutation = () => {
34+
return useMutation({
35+
mutationFn: async (newPassword: string) => {
36+
await e2e.changePassword(newPassword);
37+
},
38+
});
39+
};
40+
41+
const defaultValues = {
42+
passphrase: '',
43+
confirmationPassphrase: '',
44+
};
45+
46+
export const ChangePassphrase = (): JSX.Element => {
47+
const { t } = useTranslation();
48+
const dispatchToastMessage = useToastMessageDispatch();
49+
50+
const uniqueId = useId();
51+
const passphraseId = `passphrase-${uniqueId}`;
52+
const passphraseHintId = `${passphraseId}-hint`;
53+
const passphraseErrorId = `${passphraseId}-error`;
54+
const confirmPassphraseId = `confirm-passphrase-${uniqueId}`;
55+
const confirmPassphraseErrorId = `${confirmPassphraseId}-error`;
56+
const passphraseVerifierId = `verifier-${uniqueId}`;
57+
const e2ePasswordExplanationId = `explanation-${uniqueId}`;
58+
59+
const {
60+
watch,
61+
formState: { errors, isValid },
62+
handleSubmit,
63+
reset,
64+
resetField,
65+
control,
66+
trigger,
67+
} = useForm({
68+
defaultValues,
69+
mode: 'all',
70+
});
71+
72+
const { passphrase, confirmationPassphrase } = watch();
73+
const { validations, valid } = useValidatePassphrase(passphrase);
74+
useEffect(() => {
75+
if (!valid) {
76+
resetField('confirmationPassphrase');
77+
return;
78+
}
79+
if (confirmationPassphrase) {
80+
const validateConfirmation = async () => {
81+
await trigger('confirmationPassphrase');
82+
};
83+
void validateConfirmation();
84+
}
85+
}, [valid, confirmationPassphrase, resetField, trigger]);
86+
const keysExist = useKeysExist();
87+
88+
const updatePassword = useChangeE2EPasswordMutation();
89+
90+
const handleSave = async ({ passphrase }: { passphrase: string }) => {
91+
try {
92+
await updatePassword.mutateAsync(passphrase);
93+
dispatchToastMessage({ type: 'success', message: t('Encryption_key_saved_successfully') });
94+
reset();
95+
} catch (error) {
96+
dispatchToastMessage({ type: 'error', message: error });
97+
}
98+
};
99+
100+
return (
101+
<>
102+
<Box
103+
is='p'
104+
fontScale='p1'
105+
id={e2ePasswordExplanationId}
106+
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t('E2E_Encryption_Password_Explanation')) }}
107+
/>
108+
<Box mbs={36} w='full'>
109+
<Box is='h3' fontScale='h4' mbe={12}>
110+
{t('Change_E2EE_password')}
111+
</Box>
112+
<FieldGroup w='full'>
113+
<Field>
114+
<FieldLabel htmlFor={passphraseId}>{t('New_E2EE_password')}</FieldLabel>
115+
<FieldRow>
116+
<Controller
117+
control={control}
118+
name='passphrase'
119+
rules={{
120+
required: t('Required_field', { field: t('New_E2EE_password') }),
121+
validate: () => (valid ? true : t('Password_must_meet_the_complexity_requirements')),
122+
}}
123+
render={({ field }) => (
124+
<PasswordInput
125+
{...field}
126+
id={passphraseId}
127+
error={errors.passphrase?.message}
128+
disabled={!keysExist}
129+
aria-describedby={[
130+
e2ePasswordExplanationId,
131+
keysExist ? passphraseVerifierId : passphraseHintId,
132+
errors.passphrase && passphraseErrorId,
133+
]
134+
.filter(Boolean)
135+
.join(' ')}
136+
aria-invalid={errors.passphrase ? 'true' : 'false'}
137+
/>
138+
)}
139+
/>
140+
</FieldRow>
141+
{errors.passphrase && (
142+
<FieldError aria-live='assertive' id={passphraseErrorId}>
143+
{errors.passphrase.message}
144+
</FieldError>
145+
)}
146+
{keysExist ? (
147+
<PasswordVerifierList id={passphraseVerifierId} validations={validations} />
148+
) : (
149+
<FieldHint id={passphraseHintId}>
150+
<Trans i18nKey='Enter_current_E2EE_password_to_set_new'>
151+
To set a new password, first
152+
<Box
153+
is='a'
154+
href='#'
155+
onClick={async (e) => {
156+
e.preventDefault();
157+
await e2e.decodePrivateKeyFlow();
158+
}}
159+
>
160+
enter your current E2EE password.
161+
</Box>
162+
</Trans>
163+
</FieldHint>
164+
)}
165+
</Field>
166+
{valid && (
167+
<Field>
168+
<FieldLabel htmlFor={confirmPassphraseId}>{t('Confirm_new_E2EE_password')}</FieldLabel>
169+
<FieldRow>
170+
<Controller
171+
control={control}
172+
name='confirmationPassphrase'
173+
rules={{
174+
required: t('Required_field', { field: t('Confirm_password') }),
175+
validate: (value) => (passphrase !== value ? t('Passwords_do_not_match') : true),
176+
}}
177+
render={({ field }) => (
178+
<PasswordInput
179+
{...field}
180+
id={confirmPassphraseId}
181+
error={errors.confirmationPassphrase?.message}
182+
flexGrow={1}
183+
disabled={!keysExist || !valid}
184+
aria-required={passphrase ? 'true' : 'false'}
185+
aria-invalid={errors.confirmationPassphrase ? 'true' : 'false'}
186+
aria-describedby={errors.confirmationPassphrase ? confirmPassphraseErrorId : undefined}
187+
/>
188+
)}
189+
/>
190+
</FieldRow>
191+
{errors.confirmationPassphrase && (
192+
<FieldError aria-live='assertive' id={confirmPassphraseErrorId} role='alert'>
193+
{errors.confirmationPassphrase.message}
194+
</FieldError>
195+
)}
196+
</Field>
197+
)}
198+
</FieldGroup>
199+
<Button
200+
primary
201+
disabled={!(keysExist && valid && isValid)}
202+
onClick={handleSubmit(handleSave)}
203+
mbs={12}
204+
data-qa-type='e2e-encryption-save-password-button'
205+
>
206+
{t('Save_changes')}
207+
</Button>
208+
</Box>
209+
</>
210+
);
211+
};

0 commit comments

Comments
 (0)