Add export E2E key (#178)
authorAjay Bura <ajbura@gmail.com>
Mon, 6 Dec 2021 04:52:45 +0000 (10:22 +0530)
committerAjay Bura <ajbura@gmail.com>
Mon, 6 Dec 2021 04:52:45 +0000 (10:22 +0530)
12 files changed:
package-lock.json
package.json
src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.jsx [deleted file]
src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.scss [deleted file]
src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.jsx [new file with mode: 0644]
src/app/molecules/import-export-e2e-room-keys/ExportE2ERoomKeys.scss [new file with mode: 0644]
src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.jsx [new file with mode: 0644]
src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.scss [new file with mode: 0644]
src/app/organisms/settings/Settings.jsx
src/client/state/cons.js
src/util/cryptE2ERoomKeys.js [new file with mode: 0644]
src/util/decryptE2ERoomKeys.js [deleted file]

index 73ec20225f31cdc00fd93c67d1ceeaecd65ea2b5..2463b8d94087dfd1718d5dadd3c740094655f113 100644 (file)
@@ -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",
         "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",
         }
       }
     },
+    "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",
index d53eb98b8b37ee3700974db0fec10136e948e5f4..962bacda0995c2dad071fd03907fbc0a78b494bb 100644 (file)
@@ -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 (file)
index ef4cacf..0000000
+++ /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 (
-    <div className="import-e2e-room-keys">
-      <input ref={inputRef} onChange={handleFileChange} style={{ display: 'none' }} type="file" />
-
-      <form className="import-e2e-room-keys__form" onSubmit={(e) => { e.preventDefault(); importE2ERoomKeys(); }}>
-        { keyFile !== null && (
-          <div className="import-e2e-room-keys__file">
-            <IconButton onClick={removeImportKeysFile} src={CirclePlusIC} tooltip="Remove file" />
-            <Text>{keyFile.name}</Text>
-          </div>
-        )}
-        {keyFile === null && <Button onClick={() => inputRef.current.click()}>Import keys</Button>}
-        <Input forwardRef={passwordRef} type="password" placeholder="Password" required />
-        <Button disabled={isImporting} variant="primary" type="submit">Decrypt</Button>
-      </form>
-      { isImporting && status !== null && (
-        <div className="import-e2e-room-keys__process">
-          <Spinner size="small" />
-          <Text variant="b2">{status}</Text>
-        </div>
-      )}
-      {!isImporting && status !== null && <Text className="import-e2e-room-keys__error" variant="b2">{status}</Text>}
-    </div>
-  );
-}
-
-export default ImportE2ERoomKeys;
diff --git a/src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.scss b/src/app/molecules/import-e2e-room-keys/ImportE2ERoomKeys.scss
deleted file mode 100644 (file)
index 2c9e631..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-
-.import-e2e-room-keys {
-  &__file {
-    display: inline-flex;
-    align-items: center;
-    background: var(--bg-surface-low);
-    border-radius: var(--bo-radius);
-    box-shadow: var(--bs-surface-border);
-
-    & button {
-      --parent-height: 46px;
-      width: var(--parent-height);
-      height: 100%;
-      display: flex;
-      justify-content: center;
-      align-items: center;
-    }
-
-    & .ic-raw {
-      background-color: var(--bg-caution);
-      transform: rotate(45deg);
-    }
-    
-    & .text {
-      margin-left: var(--sp-tight);
-      margin-right: var(--sp-loose);
-      max-width: 86px;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-
-      [dir=rtl] {
-        margin-right: var(--sp-tight);
-        margin-left: var(--sp-loose);
-      }
-    }
-  }
-
-  &__form {
-    display: flex;
-    margin-top: var(--sp-extra-tight);
-    
-    
-    & .input-container {
-      flex: 1;
-      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(--bg-danger);
-  }
-}
\ No newline at end of file
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 (file)
index 0000000..b7738a6
--- /dev/null
@@ -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 (
+    <div className="export-e2e-room-keys">
+      <form className="export-e2e-room-keys__form" onSubmit={(e) => { e.preventDefault(); exportE2ERoomKeys(); }}>
+        <Input forwardRef={passwordRef} type="password" placeholder="Password" required />
+        <Input forwardRef={confirmPasswordRef} type="password" placeholder="Confirm password" required />
+        <Button disabled={status.isOngoing} variant="primary" type="submit">Export</Button>
+      </form>
+      { status.type === cons.status.IN_FLIGHT && (
+        <div className="import-e2e-room-keys__process">
+          <Spinner size="small" />
+          <Text variant="b2">{status.msg}</Text>
+        </div>
+      )}
+      {status.type === cons.status.SUCCESS && <Text className="import-e2e-room-keys__success" variant="b2">{status.msg}</Text>}
+      {status.type === cons.status.ERROR && <Text className="import-e2e-room-keys__error" variant="b2">{status.msg}</Text>}
+    </div>
+  );
+}
+
+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 (file)
index 0000000..a8bd029
--- /dev/null
@@ -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 (file)
index 0000000..b5a44b0
--- /dev/null
@@ -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 (
+    <div className="import-e2e-room-keys">
+      <input ref={inputRef} onChange={handleFileChange} style={{ display: 'none' }} type="file" />
+
+      <form className="import-e2e-room-keys__form" onSubmit={(e) => { e.preventDefault(); importE2ERoomKeys(); }}>
+        { keyFile !== null && (
+          <div className="import-e2e-room-keys__file">
+            <IconButton onClick={removeImportKeysFile} src={CirclePlusIC} tooltip="Remove file" />
+            <Text>{keyFile.name}</Text>
+          </div>
+        )}
+        {keyFile === null && <Button onClick={() => inputRef.current.click()}>Import keys</Button>}
+        <Input forwardRef={passwordRef} type="password" placeholder="Password" required />
+        <Button disabled={status.isOngoing} variant="primary" type="submit">Decrypt</Button>
+      </form>
+      { status.type === cons.status.IN_FLIGHT && (
+        <div className="import-e2e-room-keys__process">
+          <Spinner size="small" />
+          <Text variant="b2">{status.msg}</Text>
+        </div>
+      )}
+      {status.type === cons.status.SUCCESS && <Text className="import-e2e-room-keys__success" variant="b2">{status.msg}</Text>}
+      {status.type === cons.status.ERROR && <Text className="import-e2e-room-keys__error" variant="b2">{status.msg}</Text>}
+    </div>
+  );
+}
+
+export default ImportE2ERoomKeys;
diff --git a/src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.scss b/src/app/molecules/import-export-e2e-room-keys/ImportE2ERoomKeys.scss
new file mode 100644 (file)
index 0000000..ec63892
--- /dev/null
@@ -0,0 +1,67 @@
+
+.import-e2e-room-keys {
+  &__file {
+    display: inline-flex;
+    align-items: center;
+    background: var(--bg-surface-low);
+    border-radius: var(--bo-radius);
+    box-shadow: var(--bs-surface-border);
+
+    & button {
+      --parent-height: 46px;
+      width: var(--parent-height);
+      height: 100%;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+
+    & .ic-raw {
+      background-color: var(--bg-caution);
+      transform: rotate(45deg);
+    }
+    
+    & .text {
+      margin-left: var(--sp-tight);
+      margin-right: var(--sp-loose);
+      max-width: 86px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+
+      [dir=rtl] {
+        margin-right: var(--sp-tight);
+        margin-left: var(--sp-loose);
+      }
+    }
+  }
+
+  &__form {
+    display: flex;
+    margin-top: var(--sp-extra-tight);
+    
+    
+    & .input-container {
+      flex: 1;
+      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);
+  }
+  &__success {
+    margin-top: var(--sp-tight);
+    color: var(--tc-positive-high);
+  }
+}
\ No newline at end of file
index 79a9638d9fea5d5f88ceec514143e97514c85270..9644817e195f71be1a648a26d1300aa8d5c91c66 100644 (file)
@@ -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={<Text variant="b3">Use this device ID-key combo to verify or manage this session from Element client.</Text>}
       />
+      <SettingTile
+        title="Export E2E room keys"
+        content={(
+          <>
+            <Text variant="b3">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.</Text>
+            <ExportE2ERoomKeys />
+          </>
+        )}
+      />
       <SettingTile
         title="Import E2E room keys"
         content={(
index 546bb4d53d99908d8b172a127e719ef517fc4699..6b01ec61011834225cf8aa163f7c5f564763cac9 100644 (file)
@@ -19,6 +19,12 @@ const cons = {
     MENTIONS_AND_KEYWORDS: 'mentions_and_keywords',
     MUTE: 'mute',
   },
+  status: {
+    PRE_FLIGHT: 'pre-flight',
+    IN_FLIGHT: 'in-flight',
+    SUCCESS: 'success',
+    ERROR: 'error',
+  },
   actions: {
     navigation: {
       SELECT_TAB: 'SELECT_TAB',
diff --git a/src/util/cryptE2ERoomKeys.js b/src/util/cryptE2ERoomKeys.js
new file mode 100644 (file)
index 0000000..50254a3
--- /dev/null
@@ -0,0 +1,340 @@
+// https://github.com/matrix-org/matrix-react-sdk/blob/e78a1adb6f1af2ea425b0bae9034fb7344a4b2e8/src/utils/MegolmExportEncryption.js
+
+const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle;
+
+/**
+ * Make an Error object which has a friendlyText property which is already
+ * translated and suitable for showing to the user.
+ *
+ * @param {string} msg   message for the exception
+ * @param {string} friendlyText
+ * @returns {Error}
+ */
+function friendlyError(msg, friendlyText) {
+  const e = new Error(msg);
+  e.friendlyText = friendlyText;
+  return e;
+}
+
+function cryptoFailMsg() {
+  return 'Your browser does not support the required cryptography extensions';
+}
+/**
+ * Derive the AES and HMAC-SHA-256 keys for the file
+ *
+ * @param {Unit8Array} salt  salt for pbkdf
+ * @param {Number} iterations number of pbkdf iterations
+ * @param {String} password  password
+ * @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key]
+ */
+async function deriveKeys(salt, iterations, password) {
+  const start = new Date();
+
+  let key;
+  try {
+    key = await subtleCrypto.importKey(
+      'raw',
+      new TextEncoder().encode(password),
+      { name: 'PBKDF2' },
+      false,
+      ['deriveBits'],
+    );
+  } catch (e) {
+    throw friendlyError(`subtleCrypto.importKey failed: ${e}`, cryptoFailMsg());
+  }
+
+  let keybits;
+  try {
+    keybits = await subtleCrypto.deriveBits(
+      {
+        name: 'PBKDF2',
+        salt,
+        iterations,
+        hash: 'SHA-512',
+      },
+      key,
+      512,
+    );
+  } catch (e) {
+    throw friendlyError(`subtleCrypto.deriveBits failed: ${e}`, cryptoFailMsg());
+  }
+
+  const now = new Date();
+  console.log(`E2e import/export: deriveKeys took ${(now - start)}ms`);
+
+  const aesKey = keybits.slice(0, 32);
+  const hmacKey = keybits.slice(32);
+
+  const aesProm = subtleCrypto.importKey(
+    'raw',
+    aesKey,
+    { name: 'AES-CTR' },
+    false,
+    ['encrypt', 'decrypt'],
+  ).catch((e) => {
+    throw friendlyError(`subtleCrypto.importKey failed for AES key: ${e}`, cryptoFailMsg());
+  });
+
+  const hmacProm = subtleCrypto.importKey(
+    'raw',
+    hmacKey,
+    {
+      name: 'HMAC',
+      hash: { name: 'SHA-256' },
+    },
+    false,
+    ['sign', 'verify'],
+  ).catch((e) => {
+    throw friendlyError(`subtleCrypto.importKey failed for HMAC key: ${e}`, cryptoFailMsg());
+  });
+
+  // eslint-disable-next-line no-return-await
+  return await Promise.all([aesProm, hmacProm]);
+}
+
+/**
+ * Decode a base64 string to a typed array of uint8.
+ * @param {string} base64 The base64 to decode.
+ * @return {Uint8Array} The decoded data.
+ */
+function decodeBase64(base64) {
+  // window.atob returns a unicode string with codepoints in the range 0-255.
+  const latin1String = window.atob(base64);
+  // Encode the string as a Uint8Array
+  const uint8Array = new Uint8Array(latin1String.length);
+  for (let i = 0; i < latin1String.length; i += 1) {
+    uint8Array[i] = latin1String.charCodeAt(i);
+  }
+  return uint8Array;
+}
+
+/**
+ * Encode a typed array of uint8 as base64.
+ * @param {Uint8Array} uint8Array The data to encode.
+ * @return {string} The base64.
+ */
+function encodeBase64(uint8Array) {
+  // Misinterpt the Uint8Array as Latin-1.
+  // window.btoa expects a unicode string with codepoints in the range 0-255.
+  const latin1String = String.fromCharCode.apply(null, uint8Array);
+  // Use the builtin base64 encoder.
+  return window.btoa(latin1String);
+}
+
+const HEADER_LINE = '-----BEGIN MEGOLM SESSION DATA-----';
+const TRAILER_LINE = '-----END MEGOLM SESSION DATA-----';
+
+/**
+ * Unbase64 an ascii-armoured megolm key file
+ *
+ * Strips the header and trailer lines, and unbase64s the content
+ *
+ * @param {ArrayBuffer} data  input file
+ * @return {Uint8Array} unbase64ed content
+ */
+function unpackMegolmKeyFile(data) {
+  // parse the file as a great big String. This should be safe, because there
+  // should be no non-ASCII characters, and it means that we can do string
+  // comparisons to find the header and footer, and feed it into window.atob.
+  const fileStr = new TextDecoder().decode(new Uint8Array(data));
+
+  // look for the start line
+  let lineStart = 0;
+  while (1) {
+    const lineEnd = fileStr.indexOf('\n', lineStart);
+    if (lineEnd < 0) {
+      throw new Error('Header line not found');
+    }
+    const line = fileStr.slice(lineStart, lineEnd).trim();
+
+    // start the next line after the newline
+    lineStart = lineEnd + 1;
+
+    if (line === HEADER_LINE) {
+      break;
+    }
+  }
+
+  const dataStart = lineStart;
+
+  // look for the end line
+  while (1) {
+    const lineEnd = fileStr.indexOf('\n', lineStart);
+    const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).trim();
+    if (line === TRAILER_LINE) {
+      break;
+    }
+
+    if (lineEnd < 0) {
+      throw new Error('Trailer line not found');
+    }
+
+    // start the next line after the newline
+    lineStart = lineEnd + 1;
+  }
+
+  const dataEnd = lineStart;
+  return decodeBase64(fileStr.slice(dataStart, dataEnd));
+}
+
+
+/**
+ * ascii-armour a  megolm key file
+ *
+ * base64s the content, and adds header and trailer lines
+ *
+ * @param {Uint8Array} data  raw data
+ * @return {ArrayBuffer} formatted file
+ */
+function packMegolmKeyFile(data) {
+  // we split into lines before base64ing, because encodeBase64 doesn't deal
+  // terribly well with large arrays.
+  const LINE_LENGTH = ((72 * 4) / 3);
+  const nLines = Math.ceil(data.length / LINE_LENGTH);
+  const lines = new Array(nLines + 3);
+  lines[0] = HEADER_LINE;
+  let o = 0;
+  let i;
+  for (i = 1; i <= nLines; i += 1) {
+    lines[i] = encodeBase64(data.subarray(o, o+LINE_LENGTH));
+    o += LINE_LENGTH;
+  }
+  lines[i] = TRAILER_LINE;
+  i += 1;
+  lines[i] = '';
+  return (new TextEncoder().encode(lines.join('\n'))).buffer;
+}
+
+export async function decryptMegolmKeyFile(data, password) {
+  const body = unpackMegolmKeyFile(data);
+
+  // check we have a version byte
+  if (body.length < 1) {
+    throw friendlyError('Invalid file: too short', 'Not a valid keyfile');
+  }
+
+  const version = body[0];
+  if (version !== 1) {
+    throw friendlyError('Unsupported version', 'Not a valid keyfile');
+  }
+
+  const ciphertextLength = body.length - (1 + 16 + 16 + 4 + 32);
+  if (ciphertextLength < 0) {
+    throw friendlyError('Invalid file: too short', 'Not a valid keyfile');
+  }
+
+  const salt = body.subarray(1, 1 + 16);
+  const iv = body.subarray(17, 17 + 16);
+  const iterations = body[33] << 24 | body[34] << 16 | body[35] << 8 | body[36];
+  const ciphertext = body.subarray(37, 37 + ciphertextLength);
+  const hmac = body.subarray(-32);
+
+  const [aesKey, hmacKey] = await deriveKeys(salt, iterations, password);
+  const toVerify = body.subarray(0, -32);
+
+  let isValid;
+  try {
+    isValid = await subtleCrypto.verify(
+      { name: 'HMAC' },
+      hmacKey,
+      hmac,
+      toVerify,
+    );
+  } catch (e) {
+    throw friendlyError(`subtleCrypto.verify failed: ${e}`, cryptoFailMsg());
+  }
+  if (!isValid) {
+    throw friendlyError('hmac mismatch', 'Authentication check failed: Incorrect password?');
+  }
+
+  let plaintext;
+  try {
+    plaintext = await subtleCrypto.decrypt(
+      {
+        name: 'AES-CTR',
+        counter: iv,
+        length: 64,
+      },
+      aesKey,
+      ciphertext,
+    );
+  } catch (e) {
+    throw friendlyError(`subtleCrypto.decrypt failed: ${e}`, cryptoFailMsg());
+  }
+
+  return new TextDecoder().decode(new Uint8Array(plaintext));
+}
+
+/**
+ * Encrypt a megolm key file
+ *
+ * @param {String} data
+ * @param {String} password
+ * @param {Object=} options
+ * @param {Number=} options.kdf_rounds Number of iterations to perform of the
+ *    key-derivation function.
+ * @return {Promise<ArrayBuffer>} 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);
+}
diff --git a/src/util/decryptE2ERoomKeys.js b/src/util/decryptE2ERoomKeys.js
deleted file mode 100644 (file)
index 2b00872..0000000
+++ /dev/null
@@ -1,225 +0,0 @@
-// https://github.com/matrix-org/matrix-react-sdk/blob/e78a1adb6f1af2ea425b0bae9034fb7344a4b2e8/src/utils/MegolmExportEncryption.js
-
-const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle;
-
-/**
- * Make an Error object which has a friendlyText property which is already
- * translated and suitable for showing to the user.
- *
- * @param {string} msg   message for the exception
- * @param {string} friendlyText
- * @returns {Error}
- */
-function friendlyError(msg, friendlyText) {
-  const e = new Error(msg);
-  e.friendlyText = friendlyText;
-  return e;
-}
-
-function cryptoFailMsg() {
-  return 'Your browser does not support the required cryptography extensions';
-}
-/**
- * Derive the AES and HMAC-SHA-256 keys for the file
- *
- * @param {Unit8Array} salt  salt for pbkdf
- * @param {Number} iterations number of pbkdf iterations
- * @param {String} password  password
- * @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key]
- */
-async function deriveKeys(salt, iterations, password) {
-  const start = new Date();
-
-  let key;
-  try {
-    key = await subtleCrypto.importKey(
-      'raw',
-      new TextEncoder().encode(password),
-      { name: 'PBKDF2' },
-      false,
-      ['deriveBits'],
-    );
-  } catch (e) {
-    throw friendlyError(`subtleCrypto.importKey failed: ${e}`, cryptoFailMsg());
-  }
-
-  let keybits;
-  try {
-    keybits = await subtleCrypto.deriveBits(
-      {
-        name: 'PBKDF2',
-        salt,
-        iterations,
-        hash: 'SHA-512',
-      },
-      key,
-      512,
-    );
-  } catch (e) {
-    throw friendlyError(`subtleCrypto.deriveBits failed: ${e}`, cryptoFailMsg());
-  }
-
-  const now = new Date();
-  console.log(`E2e import/export: deriveKeys took ${(now - start)}ms`);
-
-  const aesKey = keybits.slice(0, 32);
-  const hmacKey = keybits.slice(32);
-
-  const aesProm = subtleCrypto.importKey(
-    'raw',
-    aesKey,
-    { name: 'AES-CTR' },
-    false,
-    ['encrypt', 'decrypt'],
-  ).catch((e) => {
-    throw friendlyError(`subtleCrypto.importKey failed for AES key: ${e}`, cryptoFailMsg());
-  });
-
-  const hmacProm = subtleCrypto.importKey(
-    'raw',
-    hmacKey,
-    {
-      name: 'HMAC',
-      hash: { name: 'SHA-256' },
-    },
-    false,
-    ['sign', 'verify'],
-  ).catch((e) => {
-    throw friendlyError(`subtleCrypto.importKey failed for HMAC key: ${e}`, cryptoFailMsg());
-  });
-
-  // eslint-disable-next-line no-return-await
-  return await Promise.all([aesProm, hmacProm]);
-}
-
-/**
- * Decode a base64 string to a typed array of uint8.
- * @param {string} base64 The base64 to decode.
- * @return {Uint8Array} The decoded data.
- */
-function decodeBase64(base64) {
-  // window.atob returns a unicode string with codepoints in the range 0-255.
-  const latin1String = window.atob(base64);
-  // Encode the string as a Uint8Array
-  const uint8Array = new Uint8Array(latin1String.length);
-  for (let i = 0; i < latin1String.length; i += 1) {
-    uint8Array[i] = latin1String.charCodeAt(i);
-  }
-  return uint8Array;
-}
-
-const HEADER_LINE = '-----BEGIN MEGOLM SESSION DATA-----';
-const TRAILER_LINE = '-----END MEGOLM SESSION DATA-----';
-
-/**
- * Unbase64 an ascii-armoured megolm key file
- *
- * Strips the header and trailer lines, and unbase64s the content
- *
- * @param {ArrayBuffer} data  input file
- * @return {Uint8Array} unbase64ed content
- */
-function unpackMegolmKeyFile(data) {
-  // parse the file as a great big String. This should be safe, because there
-  // should be no non-ASCII characters, and it means that we can do string
-  // comparisons to find the header and footer, and feed it into window.atob.
-  const fileStr = new TextDecoder().decode(new Uint8Array(data));
-
-  // look for the start line
-  let lineStart = 0;
-  while (1) {
-    const lineEnd = fileStr.indexOf('\n', lineStart);
-    if (lineEnd < 0) {
-      throw new Error('Header line not found');
-    }
-    const line = fileStr.slice(lineStart, lineEnd).trim();
-
-    // start the next line after the newline
-    lineStart = lineEnd + 1;
-
-    if (line === HEADER_LINE) {
-      break;
-    }
-  }
-
-  const dataStart = lineStart;
-
-  // look for the end line
-  while (1) {
-    const lineEnd = fileStr.indexOf('\n', lineStart);
-    const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).trim();
-    if (line === TRAILER_LINE) {
-      break;
-    }
-
-    if (lineEnd < 0) {
-      throw new Error('Trailer line not found');
-    }
-
-    // start the next line after the newline
-    lineStart = lineEnd + 1;
-  }
-
-  const dataEnd = lineStart;
-  return decodeBase64(fileStr.slice(dataStart, dataEnd));
-}
-
-export default async function decryptMegolmKeyFile(data, password) {
-  const body = unpackMegolmKeyFile(data);
-
-  // check we have a version byte
-  if (body.length < 1) {
-    throw friendlyError('Invalid file: too short', 'Not a valid keyfile');
-  }
-
-  const version = body[0];
-  if (version !== 1) {
-    throw friendlyError('Unsupported version', 'Not a valid keyfile');
-  }
-
-  const ciphertextLength = body.length - (1 + 16 + 16 + 4 + 32);
-  if (ciphertextLength < 0) {
-    throw friendlyError('Invalid file: too short', 'Not a valid keyfile');
-  }
-
-  const salt = body.subarray(1, 1 + 16);
-  const iv = body.subarray(17, 17 + 16);
-  const iterations = body[33] << 24 | body[34] << 16 | body[35] << 8 | body[36];
-  const ciphertext = body.subarray(37, 37 + ciphertextLength);
-  const hmac = body.subarray(-32);
-
-  const [aesKey, hmacKey] = await deriveKeys(salt, iterations, password);
-  const toVerify = body.subarray(0, -32);
-
-  let isValid;
-  try {
-    isValid = await subtleCrypto.verify(
-      { name: 'HMAC' },
-      hmacKey,
-      hmac,
-      toVerify,
-    );
-  } catch (e) {
-    throw friendlyError(`subtleCrypto.verify failed: ${e}`, cryptoFailMsg());
-  }
-  if (!isValid) {
-    throw friendlyError('hmac mismatch', 'Authentication check failed: Incorrect password?');
-  }
-
-  let plaintext;
-  try {
-    plaintext = await subtleCrypto.decrypt(
-      {
-        name: 'AES-CTR',
-        counter: iv,
-        length: 64,
-      },
-      aesKey,
-      ciphertext,
-    );
-  } catch (e) {
-    throw friendlyError(`subtleCrypto.decrypt failed: ${e}`, cryptoFailMsg());
-  }
-
-  return new TextDecoder().decode(new Uint8Array(plaintext));
-}