"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",
"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",
+++ /dev/null
-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;
+++ /dev/null
-
-.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
--- /dev/null
+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;
--- /dev/null
+.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
--- /dev/null
+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;
--- /dev/null
+
+.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
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';
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={(
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',
--- /dev/null
+// 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);
+}
+++ /dev/null
-// 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));
-}