From e1fb53b789119e69b6efac5c0fe53cd3884cb1cf Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 11 Jul 2023 10:55:54 +0100
Subject: [PATCH 1/6] Use PassphraseFields in ExportE2eKeysDialog to enforce
minimum passphrase complexity
---
.../dialogs/security/ExportE2eKeysDialog.tsx | 67 ++++++++++++++-----
src/i18n/strings/en_EN.json | 5 +-
2 files changed, 54 insertions(+), 18 deletions(-)
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
index 139ede4d9ce..ce58bb44b7e 100644
--- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
+++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
@@ -20,11 +20,13 @@ import React, { ChangeEvent } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
-import { _t } from "../../../../languageHandler";
+import { _t, _td } from "../../../../languageHandler";
import * as MegolmExportEncryption from "../../../../utils/MegolmExportEncryption";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
-import Field from "../../../../components/views/elements/Field";
import { KeysStartingWith } from "../../../../@types/common";
+import PassphraseField from "../../../../components/views/auth/PassphraseField";
+import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField";
+import Field from "../../../../components/views/elements/Field";
enum Phase {
Edit = "edit",
@@ -46,6 +48,9 @@ interface IState {
type AnyPassphrase = KeysStartingWith;
export default class ExportE2eKeysDialog extends React.Component {
+ private fieldPassword: Field | null = null;
+ private fieldPasswordConfirm: Field | null = null;
+
private unmounted = false;
public constructor(props: IProps) {
@@ -63,21 +68,40 @@ export default class ExportE2eKeysDialog extends React.Component
this.unmounted = true;
}
- private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => {
- ev.preventDefault();
+ private async verifyFieldsBeforeSubmit(): Promise {
+ const fieldIdsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
- const passphrase = this.state.passphrase1;
- if (passphrase !== this.state.passphrase2) {
- this.setState({ errStr: _t("Passphrases must match") });
- return false;
+ const invalidFields: Field[] = [];
+
+ for (const field of fieldIdsInDisplayOrder) {
+ if (!field) continue;
+
+ const valid = await field.validate({ allowEmpty: false });
+ if (!valid) {
+ invalidFields.push(field);
+ }
}
- if (!passphrase) {
- this.setState({ errStr: _t("Passphrase must not be empty") });
- return false;
+
+ if (invalidFields.length === 0) {
+ return true;
}
- this.startExport(passphrase);
+ // Focus on the first invalid field, then re-validate,
+ // which will result in the error tooltip being displayed for that field.
+ invalidFields[0].focus();
+ invalidFields[0].validate({ allowEmpty: false, focused: true });
+
return false;
+ }
+
+ private onPassphraseFormSubmit = async (ev: React.FormEvent): Promise => {
+ ev.preventDefault();
+
+ if (!(await this.verifyFieldsBeforeSubmit())) return;
+
+ const passphrase = this.state.passphrase1;
+ this.startExport(passphrase);
+ return;
};
private startExport(passphrase: string): void {
@@ -160,8 +184,12 @@ export default class ExportE2eKeysDialog extends React.Component
{this.state.errStr}
- ) =>
this.onPassphraseChange(e, "passphrase1")
@@ -170,11 +198,16 @@ export default class ExportE2eKeysDialog extends React.Component
size={64}
type="password"
disabled={disableForm}
+ autoComplete="new-password"
+ fieldRef={(field) => (this.fieldPassword = field)}
/>
- ) =>
this.onPassphraseChange(e, "passphrase2")
@@ -182,6 +215,8 @@ export default class ExportE2eKeysDialog extends React.Component
size={64}
type="password"
disabled={disableForm}
+ autoComplete="new-password"
+ fieldRef={(field) => (this.fieldPasswordConfirm = field)}
/>
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index d0a369fdea9..27721decd57 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -3674,13 +3674,14 @@
"Save your Security Key": "Save your Security Key",
"Secure Backup successful": "Secure Backup successful",
"Unable to set up secret storage": "Unable to set up secret storage",
- "Passphrases must match": "Passphrases must match",
- "Passphrase must not be empty": "Passphrase must not be empty",
"Export room keys": "Export room keys",
"This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.",
"The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.",
"Enter passphrase": "Enter passphrase",
+ "Great! This passphrase looks strong enough": "Great! This passphrase looks strong enough",
"Confirm passphrase": "Confirm passphrase",
+ "Passphrase must not be empty": "Passphrase must not be empty",
+ "Passphrases must match": "Passphrases must match",
"Import room keys": "Import room keys",
"This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.",
"The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.",
From 3d1866be55fe27f03bfeed6e8cec13460c2f8e4d Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 11 Jul 2023 13:19:05 +0100
Subject: [PATCH 2/6] Tweak copy
---
.../views/dialogs/security/ExportE2eKeysDialog.tsx | 6 +++---
src/i18n/strings/en_EN.json | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
index ce58bb44b7e..f980c065939 100644
--- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
+++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
@@ -176,9 +176,9 @@ export default class ExportE2eKeysDialog extends React.Component
"The exported file will allow anyone who can read it to decrypt " +
"any encrypted messages that you can see, so you should be " +
"careful to keep it secure. To help with this, you should enter " +
- "a passphrase below, which will be used to encrypt the exported " +
- "data. It will only be possible to import the data by using the " +
- "same passphrase.",
+ "a unique passphrase below, which will only be used to encrypt the " +
+ "exported data. " +
+ "It will only be possible to import the data by using the same passphrase.",
)}
{this.state.errStr}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 27721decd57..0058fbab2be 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -3676,7 +3676,7 @@
"Unable to set up secret storage": "Unable to set up secret storage",
"Export room keys": "Export room keys",
"This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.",
- "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.",
+ "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a unique passphrase below, which will only be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a unique passphrase below, which will only be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.",
"Enter passphrase": "Enter passphrase",
"Great! This passphrase looks strong enough": "Great! This passphrase looks strong enough",
"Confirm passphrase": "Confirm passphrase",
From 7119ca67479aed6e11dff0d62e5a1767cad752cb Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 11 Jul 2023 13:23:09 +0100
Subject: [PATCH 3/6] Iterate
---
.../views/dialogs/security/ExportE2eKeysDialog.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
index f980c065939..354696016ab 100644
--- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
+++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
@@ -98,10 +98,10 @@ export default class ExportE2eKeysDialog extends React.Component
ev.preventDefault();
if (!(await this.verifyFieldsBeforeSubmit())) return;
+ if (this.unmounted) return;
const passphrase = this.state.passphrase1;
this.startExport(passphrase);
- return;
};
private startExport(passphrase: string): void {
From 65987c054e9d35bb043c2d9ac8cb24f38ba84d5c Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Mon, 24 Jul 2023 17:36:26 +0100
Subject: [PATCH 4/6] Add tests
---
.../security/ExportE2eKeysDialog-test.tsx | 61 ++++++++++
.../ExportE2eKeysDialog-test.tsx.snap | 112 ++++++++++++++++++
2 files changed, 173 insertions(+)
create mode 100644 test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx
create mode 100644 test/components/views/dialogs/security/__snapshots__/ExportE2eKeysDialog-test.tsx.snap
diff --git a/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx b/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx
new file mode 100644
index 00000000000..a91569cc6cb
--- /dev/null
+++ b/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx
@@ -0,0 +1,61 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import { screen, fireEvent, render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import ExportE2eKeysDialog from "../../../../../src/async-components/views/dialogs/security/ExportE2eKeysDialog";
+import { createTestClient } from "../../../../test-utils";
+
+describe("ExportE2eKeysDialog", () => {
+ it("renders", () => {
+ const cli = createTestClient();
+ const onFinished = jest.fn();
+ const { asFragment } = render();
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ it("should have disabled submit button initially", () => {
+ const cli = createTestClient();
+ const onFinished = jest.fn();
+ const { container } = render();
+ fireEvent.click(container.querySelector("[type=submit]")!);
+ expect(screen.getByText("Enter passphrase")).toBeInTheDocument();
+ });
+
+ it("should complain about weak passphrases", async () => {
+ const cli = createTestClient();
+ const onFinished = jest.fn();
+
+ const { container } = render();
+ const input = screen.getByLabelText("Enter passphrase");
+ await userEvent.type(input, "password");
+ fireEvent.click(container.querySelector("[type=submit]")!);
+ await expect(screen.findByText("This is a top-10 common password")).resolves.toBeInTheDocument();
+ });
+
+ it("should complain if passphrases don't match", async () => {
+ const cli = createTestClient();
+ const onFinished = jest.fn();
+
+ const { container } = render();
+ await userEvent.type(screen.getByLabelText("Enter passphrase"), "ThisIsAMoreSecurePW123$$");
+ await userEvent.type(screen.getByLabelText("Confirm passphrase"), "ThisIsAMoreSecurePW124$$");
+ fireEvent.click(container.querySelector("[type=submit]")!);
+ await expect(screen.findByText("Passphrases must match")).resolves.toBeInTheDocument();
+ });
+});
diff --git a/test/components/views/dialogs/security/__snapshots__/ExportE2eKeysDialog-test.tsx.snap b/test/components/views/dialogs/security/__snapshots__/ExportE2eKeysDialog-test.tsx.snap
new file mode 100644
index 00000000000..f613eb5979e
--- /dev/null
+++ b/test/components/views/dialogs/security/__snapshots__/ExportE2eKeysDialog-test.tsx.snap
@@ -0,0 +1,112 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExportE2eKeysDialog renders 1`] = `
+
+
+
+
+
+`;
From b541c108bc8fee180985dc8f1e4ae7acd26fc6b0 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 26 Jul 2023 12:10:38 +0100
Subject: [PATCH 5/6] Improve variable naming
---
.../views/dialogs/security/ExportE2eKeysDialog.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
index 354696016ab..1f138146b98 100644
--- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
+++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx
@@ -69,11 +69,11 @@ export default class ExportE2eKeysDialog extends React.Component
}
private async verifyFieldsBeforeSubmit(): Promise {
- const fieldIdsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
+ const fieldsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
const invalidFields: Field[] = [];
- for (const field of fieldIdsInDisplayOrder) {
+ for (const field of fieldsInDisplayOrder) {
if (!field) continue;
const valid = await field.validate({ allowEmpty: false });
From 492854902985b5e4f993a8535f2bfbc632dbf504 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 26 Jul 2023 13:03:05 +0100
Subject: [PATCH 6/6] Improve coverage
---
.../dialogs/security/ExportE2eKeysDialog-test.tsx | 13 ++++++++++++-
test/test-utils/test-utils.ts | 1 +
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx b/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx
index a91569cc6cb..5f0cfd29032 100644
--- a/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx
+++ b/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx
@@ -15,7 +15,7 @@ limitations under the License.
*/
import React from "react";
-import { screen, fireEvent, render } from "@testing-library/react";
+import { screen, fireEvent, render, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ExportE2eKeysDialog from "../../../../../src/async-components/views/dialogs/security/ExportE2eKeysDialog";
@@ -58,4 +58,15 @@ describe("ExportE2eKeysDialog", () => {
fireEvent.click(container.querySelector("[type=submit]")!);
await expect(screen.findByText("Passphrases must match")).resolves.toBeInTheDocument();
});
+
+ it("should export if everything is fine", async () => {
+ const cli = createTestClient();
+ const onFinished = jest.fn();
+
+ const { container } = render();
+ await userEvent.type(screen.getByLabelText("Enter passphrase"), "ThisIsAMoreSecurePW123$$");
+ await userEvent.type(screen.getByLabelText("Confirm passphrase"), "ThisIsAMoreSecurePW123$$");
+ fireEvent.click(container.querySelector("[type=submit]")!);
+ await waitFor(() => expect(cli.exportRoomKeys).toHaveBeenCalled());
+ });
});
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index b4de68df356..d503652d1b6 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -241,6 +241,7 @@ export function createTestClient(): MatrixClient {
joinRoom: jest.fn(),
getSyncStateData: jest.fn(),
getDehydratedDevice: jest.fn(),
+ exportRoomKeys: jest.fn(),
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);