diff --git a/package-lock.json b/package-lock.json
index 73ec202..2463b8d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"browser-encrypt-attachment": "^0.3.0",
"dateformat": "^4.5.1",
"emojibase-data": "^6.2.0",
+ "file-saver": "^2.0.5",
"flux": "^4.0.1",
"formik": "^2.2.9",
"html-react-parser": "^1.2.7",
@@ -6699,6 +6700,11 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
"node_modules/file-type": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-9.0.0.tgz",
@@ -21127,6 +21133,11 @@
}
}
},
+ "file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
"file-type": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-9.0.0.tgz",
diff --git a/package.json b/package.json
index d53eb98..962bacd 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"browser-encrypt-attachment": "^0.3.0",
"dateformat": "^4.5.1",
"emojibase-data": "^6.2.0",
+ "file-saver": "^2.0.5",
"flux": "^4.0.1",
"formik": "^2.2.9",
"html-react-parser": "^1.2.7",
diff --git a/src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.jsx b/src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.jsx
deleted file mode 100644
index ef4cacf..0000000
--- a/src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.jsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import './ImportE2ERoomKeys.scss';
-import EventEmitter from 'events';
-
-import initMatrix from '../../../client/initMatrix';
-import decryptMegolmKeyFile from '../../../util/decryptE2ERoomKeys';
-
-import Text from '../../atoms/text/Text';
-import IconButton from '../../atoms/button/IconButton';
-import Button from '../../atoms/button/Button';
-import Input from '../../atoms/input/Input';
-import Spinner from '../../atoms/spinner/Spinner';
-
-import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
-
-const viewEvent = new EventEmitter();
-
-async function tryDecrypt(file, password) {
- try {
- const arrayBuffer = await file.arrayBuffer();
- viewEvent.emit('importing', true);
- viewEvent.emit('status', 'Decrypting file...');
- const keys = await decryptMegolmKeyFile(arrayBuffer, password);
-
- viewEvent.emit('status', 'Decrypting messages...');
- await initMatrix.matrixClient.importRoomKeys(JSON.parse(keys));
-
- viewEvent.emit('status', null);
- viewEvent.emit('importing', false);
- } catch (e) {
- viewEvent.emit('status', e.friendlyText || 'Something went wrong!');
- viewEvent.emit('importing', false);
- }
-}
-
-function ImportE2ERoomKeys() {
- const [keyFile, setKeyFile] = useState(null);
- const [status, setStatus] = useState(null);
- const [isImporting, setIsImporting] = useState(false);
- const inputRef = useRef(null);
- const passwordRef = useRef(null);
-
- useEffect(() => {
- const handleIsImporting = (isImp) => setIsImporting(isImp);
- const handleStatus = (msg) => setStatus(msg);
- viewEvent.on('importing', handleIsImporting);
- viewEvent.on('status', handleStatus);
-
- return () => {
- viewEvent.removeListener('importing', handleIsImporting);
- viewEvent.removeListener('status', handleStatus);
- };
- }, []);
-
- function importE2ERoomKeys() {
- const password = passwordRef.current.value;
- if (password === '' || keyFile === null) return;
- if (isImporting) return;
-
- tryDecrypt(keyFile, password);
- }
-
- function handleFileChange(e) {
- const file = e.target.files.item(0);
- passwordRef.current.value = '';
- setKeyFile(file);
- setStatus(null);
- }
- function removeImportKeysFile() {
- inputRef.current.value = null;
- passwordRef.current.value = null;
- setKeyFile(null);
- setStatus(null);
- }
-
- useEffect(() => {
- if (!isImporting && status === null) {
- removeImportKeysFile();
- }
- }, [isImporting, status]);
-
- return (
-
-
-
-
- { isImporting && status !== null && (
-
-
- {status}
-
- )}
- {!isImporting && status !== null &&
{status}}
-
- );
-}
-
-export default ImportE2ERoomKeys;
diff --git a/src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.jsx b/src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.jsx
new file mode 100644
index 0000000..b7738a6
--- /dev/null
+++ b/src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.jsx
@@ -0,0 +1,100 @@
+import React, { useState, useEffect, useRef } from 'react';
+import './ExportE2ERoomKeys.scss';
+
+import FileSaver from 'file-saver';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { encryptMegolmKeyFile } from '../../../util/cryptE2ERoomKeys';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+
+import { useStore } from '../../hooks/useStore';
+
+function ExportE2ERoomKeys() {
+ const isMountStore = useStore();
+ const [status, setStatus] = useState({
+ isOngoing: false,
+ msg: null,
+ type: cons.status.PRE_FLIGHT,
+ });
+ const passwordRef = useRef(null);
+ const confirmPasswordRef = useRef(null);
+
+ const exportE2ERoomKeys = async () => {
+ const password = passwordRef.current.value;
+ if (password !== confirmPasswordRef.current.value) {
+ setStatus({
+ isOngoing: false,
+ msg: 'Password does not match.',
+ type: cons.status.ERROR,
+ });
+ return;
+ }
+ setStatus({
+ isOngoing: true,
+ msg: 'Getting keys...',
+ type: cons.status.IN_FLIGHT,
+ });
+ try {
+ const keys = await initMatrix.matrixClient.exportRoomKeys();
+ if (isMountStore.getItem()) {
+ setStatus({
+ isOngoing: true,
+ msg: 'Encrypting keys...',
+ type: cons.status.IN_FLIGHT,
+ });
+ }
+ const encKeys = await encryptMegolmKeyFile(JSON.stringify(keys), password);
+ const blob = new Blob([encKeys], {
+ type: 'text/plain;charset=us-ascii',
+ });
+ FileSaver.saveAs(blob, 'cinny-keys.txt');
+ if (isMountStore.getItem()) {
+ setStatus({
+ isOngoing: false,
+ msg: 'Successfully exported all keys.',
+ type: cons.status.SUCCESS,
+ });
+ }
+ } catch (e) {
+ if (isMountStore.getItem()) {
+ setStatus({
+ isOngoing: false,
+ msg: e.friendlyText || 'Failed to export keys. Please try again.',
+ type: cons.status.ERROR,
+ });
+ }
+ }
+ };
+
+ useEffect(() => {
+ isMountStore.setItem(true);
+ return () => {
+ isMountStore.setItem(false);
+ };
+ }, []);
+
+ return (
+
+
+ { status.type === cons.status.IN_FLIGHT && (
+
+
+ {status.msg}
+
+ )}
+ {status.type === cons.status.SUCCESS &&
{status.msg}}
+ {status.type === cons.status.ERROR &&
{status.msg}}
+
+ );
+}
+
+export default ExportE2ERoomKeys;
diff --git a/src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.scss b/src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.scss
new file mode 100644
index 0000000..a8bd029
--- /dev/null
+++ b/src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.scss
@@ -0,0 +1,28 @@
+.export-e2e-room-keys {
+ margin-top: var(--sp-extra-tight);
+ &__form {
+ display: flex;
+ & > .input-container {
+ flex: 1;
+ min-width: 0;
+ }
+ & > *:nth-child(2) {
+ margin: 0 var(--sp-tight);
+ }
+ }
+
+ &__process {
+ margin-top: var(--sp-tight);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ & .text {
+ margin: 0 var(--sp-tight);
+ }
+ }
+ &__error {
+ margin-top: var(--sp-tight);
+ color: var(--tc-danger-high);
+ }
+
+}
\ No newline at end of file
diff --git a/src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.jsx b/src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.jsx
new file mode 100644
index 0000000..b5a44b0
--- /dev/null
+++ b/src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.jsx
@@ -0,0 +1,133 @@
+import React, { useState, useEffect, useRef } from 'react';
+import './ImportE2ERoomKeys.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { decryptMegolmKeyFile } from '../../../util/cryptE2ERoomKeys';
+
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
+import Button from '../../atoms/button/Button';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+
+import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
+
+import { useStore } from '../../hooks/useStore';
+
+function ImportE2ERoomKeys() {
+ const isMountStore = useStore();
+ const [keyFile, setKeyFile] = useState(null);
+ const [status, setStatus] = useState({
+ isOngoing: false,
+ msg: null,
+ type: cons.status.PRE_FLIGHT,
+ });
+ const inputRef = useRef(null);
+ const passwordRef = useRef(null);
+
+ async function tryDecrypt(file, password) {
+ try {
+ const arrayBuffer = await file.arrayBuffer();
+ if (isMountStore.getItem()) {
+ setStatus({
+ isOngoing: true,
+ msg: 'Decrypting file...',
+ type: cons.status.IN_FLIGHT,
+ });
+ }
+
+ const keys = await decryptMegolmKeyFile(arrayBuffer, password);
+ if (isMountStore.getItem()) {
+ setStatus({
+ isOngoing: true,
+ msg: 'Decrypting messages...',
+ type: cons.status.IN_FLIGHT,
+ });
+ }
+ await initMatrix.matrixClient.importRoomKeys(JSON.parse(keys));
+ if (isMountStore.getItem()) {
+ setStatus({
+ isOngoing: false,
+ msg: 'Successfully imported all keys.',
+ type: cons.status.SUCCESS,
+ });
+ inputRef.current.value = null;
+ passwordRef.current.value = null;
+ }
+ } catch (e) {
+ if (isMountStore.getItem()) {
+ setStatus({
+ isOngoing: false,
+ msg: e.friendlyText || 'Failed to decrypt keys. Please try again.',
+ type: cons.status.ERROR,
+ });
+ }
+ }
+ }
+
+ const importE2ERoomKeys = () => {
+ const password = passwordRef.current.value;
+ if (password === '' || keyFile === null) return;
+ if (status.isOngoing) return;
+
+ tryDecrypt(keyFile, password);
+ };
+
+ const handleFileChange = (e) => {
+ const file = e.target.files.item(0);
+ passwordRef.current.value = '';
+ setKeyFile(file);
+ setStatus({
+ isOngoing: false,
+ msg: null,
+ type: cons.status.PRE_FLIGHT,
+ });
+ };
+ const removeImportKeysFile = () => {
+ if (status.isOngoing) return;
+ inputRef.current.value = null;
+ passwordRef.current.value = null;
+ setKeyFile(null);
+ setStatus({
+ isOngoing: false,
+ msg: null,
+ type: cons.status.PRE_FLIGHT,
+ });
+ };
+
+ useEffect(() => {
+ isMountStore.setItem(true);
+ return () => {
+ isMountStore.setItem(false);
+ };
+ }, []);
+
+ return (
+
+
+
+
+ { status.type === cons.status.IN_FLIGHT && (
+
+
+ {status.msg}
+
+ )}
+ {status.type === cons.status.SUCCESS &&
{status.msg}}
+ {status.type === cons.status.ERROR &&
{status.msg}}
+
+ );
+}
+
+export default ImportE2ERoomKeys;
diff --git a/src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.scss b/src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.scss
similarity index 90%
rename from src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.scss
rename to src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.scss
index 2c9e631..ec63892 100644
--- a/src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.scss
+++ b/src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.scss
@@ -58,6 +58,10 @@
}
&__error {
margin-top: var(--sp-tight);
- color: var(--bg-danger);
+ color: var(--tc-danger-high);
+ }
+ &__success {
+ margin-top: var(--sp-tight);
+ color: var(--tc-positive-high);
}
}
\ No newline at end of file
diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx
index 79a9638..9644817 100644
--- a/src/app/organisms/settings/Settings.jsx
+++ b/src/app/organisms/settings/Settings.jsx
@@ -15,7 +15,8 @@ import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls'
import PopupWindow, { PWContentSelector } from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile';
-import ImportE2ERoomKeys from '../../molecules/import-e2e-room-keys/ImportE2ERoomKeys';
+import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
+import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import ProfileEditor from '../profile-editor/ProfileEditor';
@@ -84,6 +85,15 @@ function SecuritySection() {
title={`Device key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
content={Use this device ID-key combo to verify or manage this session from Element client.}
/>
+
+ Export end-to-end encryption room keys to decrypt old messages in other session. In order to encrypt keys you need to set a password, which will be used while importing.
+
+ >
+ )}
+ />
} promise for encrypted output
+ */
+export async function encryptMegolmKeyFile(data, password, options) {
+ options = options || {};
+ const kdfRounds = options.kdf_rounds || 500000;
+
+ const salt = new Uint8Array(16);
+ window.crypto.getRandomValues(salt);
+
+ const iv = new Uint8Array(16);
+ window.crypto.getRandomValues(iv);
+
+ // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary
+ // (which would mean we wouldn't be able to decrypt on Android). The loss
+ // of a single bit of iv is a price we have to pay.
+ iv[8] &= 0x7f;
+
+ const [aesKey, hmacKey] = await deriveKeys(salt, kdfRounds, password);
+ const encodedData = new TextEncoder().encode(data);
+
+ let ciphertext;
+ try {
+ ciphertext = await subtleCrypto.encrypt(
+ {
+ name: 'AES-CTR',
+ counter: iv,
+ length: 64,
+ },
+ aesKey,
+ encodedData,
+ );
+ } catch (e) {
+ throw friendlyError('subtleCrypto.encrypt failed: ' + e, cryptoFailMsg());
+ }
+
+ const cipherArray = new Uint8Array(ciphertext);
+ const bodyLength = (1+salt.length+iv.length+4+cipherArray.length+32);
+ const resultBuffer = new Uint8Array(bodyLength);
+ let idx = 0;
+ resultBuffer[idx++] = 1; // version
+ resultBuffer.set(salt, idx); idx += salt.length;
+ resultBuffer.set(iv, idx); idx += iv.length;
+ resultBuffer[idx++] = kdfRounds >> 24;
+ resultBuffer[idx++] = (kdfRounds >> 16) & 0xff;
+ resultBuffer[idx++] = (kdfRounds >> 8) & 0xff;
+ resultBuffer[idx++] = kdfRounds & 0xff;
+ resultBuffer.set(cipherArray, idx); idx += cipherArray.length;
+
+ const toSign = resultBuffer.subarray(0, idx);
+
+ let hmac;
+ try {
+ hmac = await subtleCrypto.sign(
+ { name: 'HMAC' },
+ hmacKey,
+ toSign,
+ );
+ } catch (e) {
+ throw friendlyError('subtleCrypto.sign failed: ' + e, cryptoFailMsg());
+ }
+
+ const hmacArray = new Uint8Array(hmac);
+ resultBuffer.set(hmacArray, idx);
+ return packMegolmKeyFile(resultBuffer);
+}