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 ( -
- - -
{ e.preventDefault(); importE2ERoomKeys(); }}> - { keyFile !== null && ( -
- - {keyFile.name} -
- )} - {keyFile === null && } - - -
- { 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 ( +
+
{ e.preventDefault(); exportE2ERoomKeys(); }}> + + + +
+ { 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 ( +
+ + +
{ e.preventDefault(); importE2ERoomKeys(); }}> + { keyFile !== null && ( +
+ + {keyFile.name} +
+ )} + {keyFile === null && } + + +
+ { 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); +}