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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
340
src/util/cryptE2ERoomKeys.js
Normal file
340
src/util/cryptE2ERoomKeys.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue