mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	Add export E2E key (#178)
This commit is contained in:
		
							parent
							
								
									5b109c2b79
								
							
						
					
					
						commit
						50db137dea
					
				
					 10 changed files with 411 additions and 111 deletions
				
			
		
							
								
								
									
										11
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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={(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -108,6 +108,19 @@ function decodeBase64(base64) {
 | 
			
		|||
  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-----';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -164,7 +177,35 @@ function unpackMegolmKeyFile(data) {
 | 
			
		|||
  return decodeBase64(fileStr.slice(dataStart, dataEnd));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default async function decryptMegolmKeyFile(data, password) {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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
 | 
			
		||||
| 
						 | 
				
			
			@ -223,3 +264,77 @@ export default async function decryptMegolmKeyFile(data, password) {
 | 
			
		|||
 | 
			
		||||
  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);
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue