From d5bc24d9f76fbcef01263f99f39cdc788de7cde7 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 19 May 2017 09:54:29 +0100 Subject: [PATCH 001/240] Initial implementation of KeyRequestHandler, KeyShareDialog etc --- src/KeyRequestHandler.js | 89 ++++++++++ src/components/structures/MatrixChat.js | 6 + .../views/dialogs/KeyShareDialog.js | 164 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 src/KeyRequestHandler.js create mode 100644 src/components/views/dialogs/KeyShareDialog.js diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js new file mode 100644 index 0000000000..90ff00d47e --- /dev/null +++ b/src/KeyRequestHandler.js @@ -0,0 +1,89 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import sdk from './index'; +import Modal from './Modal'; + +export default class KeyRequestHandler { + constructor(matrixClient) { + this._matrixClient = matrixClient; + + this._isDialogOpen = false; + + // userId -> deviceId -> [keyRequest] + this._pendingKeyRequests = {}; + } + + + handleKeyRequest(keyRequest) { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + + if (!this._pendingKeyRequests[userId]) { + this._pendingKeyRequests[userId] = {}; + } + if (!this._pendingKeyRequests[userId][deviceId]) { + this._pendingKeyRequests[userId][deviceId] = []; + } + this._pendingKeyRequests[userId][deviceId].push(keyRequest); + + if (this._isDialogOpen) { + // ignore for now + console.log("Key request, but we already have a dialog open"); + return; + } + + this._processNextRequest(); + } + + _processNextRequest() { + const userId = Object.keys(this._pendingKeyRequests)[0]; + if (!userId) { + return; + } + const deviceId = Object.keys(this._pendingKeyRequests[userId])[0]; + if (!deviceId) { + return; + } + console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`); + + const finished = (r) => { + this._isDialogOpen = false; + + if (r) { + for (const req of this._pendingKeyRequests[userId][deviceId]) { + req.share(); + } + } + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + + this._processNextRequest(); + }; + + const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog"); + Modal.createDialog(KeyShareDialog, { + matrixClient: this._matrixClient, + userId: userId, + deviceId: deviceId, + onFinished: finished, + }); + this._isDialogOpen = true; + } +} + diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index f53128fba9..544213edc6 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -38,6 +38,7 @@ import PageTypes from '../../PageTypes'; import createRoom from "../../createRoom"; import * as UDEHandler from '../../UnknownDeviceErrorHandler'; +import KeyRequestHandler from '../../KeyRequestHandler'; import { _t } from '../../languageHandler'; module.exports = React.createClass({ @@ -914,6 +915,11 @@ module.exports = React.createClass({ } } }); + + const krh = new KeyRequestHandler(cli); + cli.on("crypto.roomKeyRequest", (req) => { + krh.handleKeyRequest(req); + }); }, showScreen: function(screen, params) { diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js new file mode 100644 index 0000000000..5b66944dbc --- /dev/null +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -0,0 +1,164 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Modal from '../../../Modal'; +import React from 'react'; +import sdk from '../../../index'; + +import { _t } from '../../../languageHandler'; + +export default React.createClass({ + propTypes: { + matrixClient: React.PropTypes.object.isRequired, + userId: React.PropTypes.string.isRequired, + deviceId: React.PropTypes.string.isRequired, + onFinished: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + deviceInfo: null, + wasNewDevice: false, + }; + }, + + componentDidMount: function() { + this._unmounted = false; + const userId = this.props.userId; + const deviceId = this.props.deviceId; + + // give the client a chance to refresh the device list + this.props.matrixClient.downloadKeys([userId], false).then((r) => { + if (this._unmounted) { return; } + + const deviceInfo = r[userId][deviceId]; + + if(!deviceInfo) { + console.warn(`No details found for device ${userId}:${deviceId}`); + + this.props.onFinished(false); + return; + } + + const wasNewDevice = !deviceInfo.isKnown(); + + this.setState({ + deviceInfo: deviceInfo, + wasNewDevice: wasNewDevice, + }); + + // if the device was new before, it's not any more. + if (wasNewDevice) { + this.props.matrixClient.setDeviceKnown( + userId, + deviceId, + true, + ); + } + }).done(); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + + _onVerifyClicked: function() { + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + + console.log("KeyShareDialog: Starting verify dialog"); + Modal.createDialog(DeviceVerifyDialog, { + userId: this.props.userId, + device: this.state.deviceInfo, + onFinished: (verified) => { + if (verified) { + // can automatically share the keys now. + this.props.onFinished(true); + } + }, + }); + }, + + _onShareClicked: function() { + console.log("KeyShareDialog: User clicked 'share'"); + this.props.onFinished(true); + }, + + _onIgnoreClicked: function() { + console.log("KeyShareDialog: User clicked 'ignore'"); + this.props.onFinished(false); + }, + + _renderContent: function() { + const displayName = this.state.deviceInfo.getDisplayName() || + this.state.deviceInfo.deviceId; + + let text; + if (this.state.wasNewDevice) { + text = "You added a new device '%(displayName)s', which is" + + " requesting encryption keys."; + } else { + text = "Your unverified device '%(displayName)s' is requesting" + + " encryption keys."; + } + text = _t(text, {displayName: displayName}); + + return ( +
+

{text}

+ +
+ + + +
+
+ ); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const Spinner = sdk.getComponent('views.elements.Spinner'); + + let content; + + if (this.state.deviceInfo) { + content = this._renderContent(); + } else { + content = ( +
+

{_t('Loading device info...')}

+ +
+ ); + } + + return ( + + {content} + + ); + }, +}); From 7d55f3e75d55edef5a0303ab8c0f8909cce2d57e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 1 Jun 2017 17:49:24 +0100 Subject: [PATCH 002/240] handle room key request cancellations --- src/KeyRequestHandler.js | 55 ++++++++++++++++++++++--- src/components/structures/MatrixChat.js | 3 ++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 90ff00d47e..fbb869d468 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -21,16 +21,18 @@ export default class KeyRequestHandler { constructor(matrixClient) { this._matrixClient = matrixClient; - this._isDialogOpen = false; + // the user/device for which we currently have a dialog open + this._currentUser = null; + this._currentDevice = null; // userId -> deviceId -> [keyRequest] this._pendingKeyRequests = {}; } - handleKeyRequest(keyRequest) { const userId = keyRequest.userId; const deviceId = keyRequest.deviceId; + const requestId = keyRequest.requestId; if (!this._pendingKeyRequests[userId]) { this._pendingKeyRequests[userId] = {}; @@ -38,9 +40,17 @@ export default class KeyRequestHandler { if (!this._pendingKeyRequests[userId][deviceId]) { this._pendingKeyRequests[userId][deviceId] = []; } - this._pendingKeyRequests[userId][deviceId].push(keyRequest); - if (this._isDialogOpen) { + // check if we already have this request + const requests = this._pendingKeyRequests[userId][deviceId]; + if (requests.find((r) => r.requestId === requestId)) { + console.log("Already have this key request, ignoring"); + return; + } + + requests.push(keyRequest); + + if (this._currentUser) { // ignore for now console.log("Key request, but we already have a dialog open"); return; @@ -49,6 +59,37 @@ export default class KeyRequestHandler { this._processNextRequest(); } + handleKeyRequestCancellation(cancellation) { + // see if we can find the request in the queue + const userId = cancellation.userId; + const deviceId = cancellation.deviceId; + const requestId = cancellation.requestId; + + if (userId === this._currentUser && deviceId === this._currentDevice) { + console.log( + "room key request cancellation for the user we currently have a" + + " dialog open for", + ); + // TODO: update the dialog. For now, we just ignore the + // cancellation. + return; + } + + if (!this._pendingKeyRequests[userId]) { + return; + } + const requests = this._pendingKeyRequests[userId][deviceId]; + if (!requests) { + return; + } + const idx = requests.findIndex((r) => r.requestId === requestId); + if (idx < 0) { + return; + } + console.log("Forgetting room key request"); + requests.splice(idx, 1); + } + _processNextRequest() { const userId = Object.keys(this._pendingKeyRequests)[0]; if (!userId) { @@ -61,7 +102,8 @@ export default class KeyRequestHandler { console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`); const finished = (r) => { - this._isDialogOpen = false; + this._currentUser = null; + this._currentDevice = null; if (r) { for (const req of this._pendingKeyRequests[userId][deviceId]) { @@ -83,7 +125,8 @@ export default class KeyRequestHandler { deviceId: deviceId, onFinished: finished, }); - this._isDialogOpen = true; + this._currentUser = userId; + this._currentDevice = deviceId; } } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 544213edc6..afb3c57818 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -920,6 +920,9 @@ module.exports = React.createClass({ cli.on("crypto.roomKeyRequest", (req) => { krh.handleKeyRequest(req); }); + cli.on("crypto.roomKeyRequestCancellation", (req) => { + krh.handleKeyRequestCancellation(req); + }); }, showScreen: function(screen, params) { From 21277557f80a725ac0ee0175633d198ecbae263a Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 2 Jun 2017 10:10:40 +0100 Subject: [PATCH 003/240] Add translations for new dialog --- src/i18n/strings/en_EN.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 95baf1f50e..6e7648f00d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -779,5 +779,10 @@ "Disable URL previews for this room (affects only you)": "Disable URL previews for this room (affects only you)", "$senderDisplayName changed the room avatar to ": "$senderDisplayName changed the room avatar to ", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", - "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s" + "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", + "Start verification": "Start verification", + "Share without verifying": "Share without verifying", + "Ignore request": "Ignore request", + "You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.", + "Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys." } From 7a9784fd6ef39e4fc214e8416456b9d92cb9f7e8 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 5 Jun 2017 17:26:25 +0100 Subject: [PATCH 004/240] KeyRequestHandler: clear redundant users/devices on cancellations ... otherwise _processNextRequest will get confused --- src/KeyRequestHandler.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index fbb869d468..4354fba269 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -88,6 +88,12 @@ export default class KeyRequestHandler { } console.log("Forgetting room key request"); requests.splice(idx, 1); + if (requests.length === 0) { + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + } } _processNextRequest() { From 32e3ea0601355fc366fddee43194f9c7b1492238 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 5 Jun 2017 17:58:11 +0100 Subject: [PATCH 005/240] Address review comments --- src/KeyRequestHandler.js | 4 ++-- src/components/views/dialogs/KeyShareDialog.js | 10 +++++++++- src/i18n/strings/en_EN.json | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 4354fba269..1da4922153 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -26,7 +26,7 @@ export default class KeyRequestHandler { this._currentDevice = null; // userId -> deviceId -> [keyRequest] - this._pendingKeyRequests = {}; + this._pendingKeyRequests = Object.create(null); } handleKeyRequest(keyRequest) { @@ -35,7 +35,7 @@ export default class KeyRequestHandler { const requestId = keyRequest.requestId; if (!this._pendingKeyRequests[userId]) { - this._pendingKeyRequests[userId] = {}; + this._pendingKeyRequests[userId] = Object.create(null); } if (!this._pendingKeyRequests[userId][deviceId]) { this._pendingKeyRequests[userId][deviceId] = []; diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index 5b66944dbc..61391d281c 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -20,6 +20,14 @@ import sdk from '../../../index'; import { _t } from '../../../languageHandler'; +/** + * Dialog which asks the user whether they want to share their keys with + * an unverified device. + * + * onFinished is called with `true` if the key should be shared, `false` if it + * should not, and `undefined` if the dialog is cancelled. (In other words: + * truthy: do the key share. falsy: don't share the keys). + */ export default React.createClass({ propTypes: { matrixClient: React.PropTypes.object.isRequired, @@ -155,7 +163,7 @@ export default React.createClass({ return ( {content} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7421ccad76..f6c044deac 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -809,5 +809,7 @@ "Share without verifying": "Share without verifying", "Ignore request": "Ignore request", "You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.", - "Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys." + "Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys.", + "Encryption key request": "Encryption key request" + } From 3d59d72aaa4a8128b82c1d9d999b5bb3a3a6b71a Mon Sep 17 00:00:00 2001 From: Oliver Hunt Date: Wed, 7 Jun 2017 04:49:41 +0100 Subject: [PATCH 006/240] Fixed pagination infinite loop caused by long messages Signed-off-by: Oliver Hunt --- src/components/structures/ScrollPanel.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index a652bcc827..f035efee92 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -352,13 +352,14 @@ module.exports = React.createClass({ const tile = tiles[backwards ? i : tiles.length - 1 - i]; // Subtract height of tile as if it were unpaginated excessHeight -= tile.clientHeight; + //If removing the tile would lead to future pagination, break before setting scroll token + if (tile.clientHeight > excessHeight) { + break; + } // The tile may not have a scroll token, so guard it if (tile.dataset.scrollTokens) { markerScrollToken = tile.dataset.scrollTokens.split(',')[0]; } - if (tile.clientHeight > excessHeight) { - break; - } } if (markerScrollToken) { From b16e652acc1c9876606879458483c5bf9e9b75f2 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 8 Jun 2017 07:54:47 +0100 Subject: [PATCH 007/240] rewrite MegolmExportEncryption using async/await ... to make it easier to add exception handling --- src/utils/MegolmExportEncryption.js | 193 +++++++++++----------- test/utils/MegolmExportEncryption-test.js | 24 ++- 2 files changed, 112 insertions(+), 105 deletions(-) diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js index de39ea71cc..e91dc06b04 100644 --- a/src/utils/MegolmExportEncryption.js +++ b/src/utils/MegolmExportEncryption.js @@ -36,7 +36,7 @@ const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle; * @param {String} password * @return {Promise} promise for decrypted output */ -export function decryptMegolmKeyFile(data, password) { +export async function decryptMegolmKeyFile(data, password) { const body = unpackMegolmKeyFile(data); // check we have a version byte @@ -60,33 +60,30 @@ export function decryptMegolmKeyFile(data, password) { const ciphertext = body.subarray(37, 37+ciphertextLength); const hmac = body.subarray(-32); - return deriveKeys(salt, iterations, password).then((keys) => { - const [aesKey, hmacKey] = keys; + const [aesKey, hmacKey] = await deriveKeys(salt, iterations, password); - const toVerify = body.subarray(0, -32); - return subtleCrypto.verify( - {name: 'HMAC'}, - hmacKey, - hmac, - toVerify, - ).then((isValid) => { - if (!isValid) { - throw new Error('Authentication check failed: incorrect password?'); - } + const toVerify = body.subarray(0, -32); + const isValid = await subtleCrypto.verify( + {name: 'HMAC'}, + hmacKey, + hmac, + toVerify, + ); + if (!isValid) { + throw new Error('Authentication check failed: incorrect password?'); + } - return subtleCrypto.decrypt( - { - name: "AES-CTR", - counter: iv, - length: 64, - }, - aesKey, - ciphertext, - ); - }); - }).then((plaintext) => { - return new TextDecoder().decode(new Uint8Array(plaintext)); - }); + const plaintext = await subtleCrypto.decrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aesKey, + ciphertext, + ); + + return new TextDecoder().decode(new Uint8Array(plaintext)); } @@ -100,7 +97,7 @@ export function decryptMegolmKeyFile(data, password) { * key-derivation function. * @return {Promise} promise for encrypted output */ -export function encryptMegolmKeyFile(data, password, options) { +export async function encryptMegolmKeyFile(data, password, options) { options = options || {}; const kdfRounds = options.kdf_rounds || 500000; @@ -115,44 +112,42 @@ export function encryptMegolmKeyFile(data, password, options) { // of a single bit of iv is a price we have to pay. iv[9] &= 0x7f; - return deriveKeys(salt, kdfRounds, password).then((keys) => { - const [aesKey, hmacKey] = keys; + const [aesKey, hmacKey] = await deriveKeys(salt, kdfRounds, password); - return subtleCrypto.encrypt( - { - name: "AES-CTR", - counter: iv, - length: 64, - }, - aesKey, - new TextEncoder().encode(data), - ).then((ciphertext) => { - 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 ciphertext = await subtleCrypto.encrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aesKey, + new TextEncoder().encode(data), + ); - const toSign = resultBuffer.subarray(0, idx); + 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; - return subtleCrypto.sign( - {name: 'HMAC'}, - hmacKey, - toSign, - ).then((hmac) => { - hmac = new Uint8Array(hmac); - resultBuffer.set(hmac, idx); - return packMegolmKeyFile(resultBuffer); - }); - }); - }); + const toSign = resultBuffer.subarray(0, idx); + + const hmac = await subtleCrypto.sign( + {name: 'HMAC'}, + hmacKey, + toSign, + ); + + const hmacArray = new Uint8Array(hmac); + resultBuffer.set(hmacArray, idx); + return packMegolmKeyFile(resultBuffer); } /** @@ -163,51 +158,51 @@ export function encryptMegolmKeyFile(data, password, options) { * @param {String} password password * @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key] */ -function deriveKeys(salt, iterations, password) { +async function deriveKeys(salt, iterations, password) { const start = new Date(); - return subtleCrypto.importKey( + const key = await subtleCrypto.importKey( 'raw', new TextEncoder().encode(password), {name: 'PBKDF2'}, false, ['deriveBits'], - ).then((key) => { - return subtleCrypto.deriveBits( - { - name: 'PBKDF2', - salt: salt, - iterations: iterations, - hash: 'SHA-512', - }, - key, - 512, - ); - }).then((keybits) => { - 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 keybits = await subtleCrypto.deriveBits( + { + name: 'PBKDF2', + salt: salt, + iterations: iterations, + hash: 'SHA-512', + }, + key, + 512, + ); - const aesProm = subtleCrypto.importKey( - 'raw', - aesKey, - {name: 'AES-CTR'}, - false, - ['encrypt', 'decrypt'], - ); - const hmacProm = subtleCrypto.importKey( - 'raw', - hmacKey, - { - name: 'HMAC', - hash: {name: 'SHA-256'}, - }, - false, - ['sign', 'verify'], - ); - return Promise.all([aesProm, hmacProm]); - }); + 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'], + ); + const hmacProm = subtleCrypto.importKey( + 'raw', + hmacKey, + { + name: 'HMAC', + hash: {name: 'SHA-256'}, + }, + false, + ['sign', 'verify'], + ); + return await Promise.all([aesProm, hmacProm]); } const HEADER_LINE = '-----BEGIN MEGOLM SESSION DATA-----'; diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js index 2637097837..fbd945ced6 100644 --- a/test/utils/MegolmExportEncryption-test.js +++ b/test/utils/MegolmExportEncryption-test.js @@ -81,15 +81,23 @@ describe('MegolmExportEncryption', function() { describe('decrypt', function() { it('should handle missing header', function() { const input=stringToArray(`-----`); - expect(()=>MegolmExportEncryption.decryptMegolmKeyFile(input, '')) - .toThrow('Header line not found'); + return MegolmExportEncryption.decryptMegolmKeyFile(input, '') + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Header line not found'); + }); }); it('should handle missing trailer', function() { const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA----- -----`); - expect(()=>MegolmExportEncryption.decryptMegolmKeyFile(input, '')) - .toThrow('Trailer line not found'); + return MegolmExportEncryption.decryptMegolmKeyFile(input, '') + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Trailer line not found'); + }); }); it('should handle a too-short body', function() { @@ -98,8 +106,12 @@ AXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx cissyYBxjsfsAn -----END MEGOLM SESSION DATA----- `); - expect(()=>MegolmExportEncryption.decryptMegolmKeyFile(input, '')) - .toThrow('Invalid file: too short'); + return MegolmExportEncryption.decryptMegolmKeyFile(input, '') + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Invalid file: too short'); + }); }); it('should decrypt a range of inputs', function(done) { From 175599bedaa4a30e79dd5e7f3d01297ed7303847 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 8 Jun 2017 14:21:25 +0100 Subject: [PATCH 008/240] Improve error logging/reporting in megolm import/export I saw a rageshake where somebody had apparently failed to import a key file. I have no idea why it happened. Also try to make the errors the users see useful. --- .../views/dialogs/ExportE2eKeysDialog.js | 4 +- .../views/dialogs/ImportE2eKeysDialog.js | 4 +- src/i18n/strings/en_EN.json | 5 +- src/utils/MegolmExportEncryption.js | 172 ++++++++++++------ 4 files changed, 129 insertions(+), 56 deletions(-) diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 045ea63c34..8f113353d9 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -81,11 +81,13 @@ export default React.createClass({ FileSaver.saveAs(blob, 'riot-keys.txt'); this.props.onFinished(true); }).catch((e) => { + console.error("Error exporting e2e keys:", e); if (this._unmounted) { return; } + const msg = e.friendlyText || _t('Unknown error'); this.setState({ - errStr: e.message, + errStr: msg, phase: PHASE_EDIT, }); }); diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 91010d33b9..9eac7f78b2 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -89,11 +89,13 @@ export default React.createClass({ // TODO: it would probably be nice to give some feedback about what we've imported here. this.props.onFinished(true); }).catch((e) => { + console.error("Error importing e2e keys:", e); if (this._unmounted) { return; } + const msg = e.friendlyText || _t('Unknown error'); this.setState({ - errStr: e.message, + errStr: msg, phase: PHASE_EDIT, }); }); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8dbbb98423..9199c3b103 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -860,5 +860,8 @@ "Username not available": "Username not available", "Something went wrong!": "Something went wrong!", "This will be your account name on the homeserver, or you can pick a different server.": "This will be your account name on the homeserver, or you can pick a different server.", - "If you already have a Matrix account you can log in instead.": "If you already have a Matrix account you can log in instead." + "If you already have a Matrix account you can log in instead.": "If you already have a Matrix account you can log in instead.", + "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", + "Not a valid Riot keyfile": "Not a valid Riot keyfile", + "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?" } diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js index e91dc06b04..11f9d86816 100644 --- a/src/utils/MegolmExportEncryption.js +++ b/src/utils/MegolmExportEncryption.js @@ -27,31 +27,57 @@ if (!TextDecoder) { TextDecoder = TextEncodingUtf8.TextDecoder; } +import { _t } from '../languageHandler'; + + 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 _t('Your browser does not support the required cryptography extensions'); +} + /** * Decrypt a megolm key file * * @param {ArrayBuffer} data file to decrypt * @param {String} password * @return {Promise} promise for decrypted output + * + * */ export async function decryptMegolmKeyFile(data, password) { const body = unpackMegolmKeyFile(data); // check we have a version byte if (body.length < 1) { - throw new Error('Invalid file: too short'); + throw friendlyError('Invalid file: too short', + _t('Not a valid Riot keyfile')); } const version = body[0]; if (version !== 1) { - throw new Error('Unsupported version'); + throw friendlyError('Unsupported version', + _t('Not a valid Riot keyfile')); } const ciphertextLength = body.length-(1+16+16+4+32); if (ciphertextLength < 0) { - throw new Error('Invalid file: too short'); + throw friendlyError('Invalid file: too short', + _t('Not a valid Riot keyfile')); } const salt = body.subarray(1, 1+16); @@ -61,27 +87,38 @@ export async function decryptMegolmKeyFile(data, password) { const hmac = body.subarray(-32); const [aesKey, hmacKey] = await deriveKeys(salt, iterations, password); - const toVerify = body.subarray(0, -32); - const isValid = await subtleCrypto.verify( - {name: 'HMAC'}, - hmacKey, - hmac, - toVerify, - ); + + let isValid; + try { + isValid = await subtleCrypto.verify( + {name: 'HMAC'}, + hmacKey, + hmac, + toVerify, + ); + } catch (e) { + throw friendlyError('subtleCrypto.verify failed: ' + e, cryptoFailMsg()); + } if (!isValid) { - throw new Error('Authentication check failed: incorrect password?'); + throw friendlyError('hmac mismatch', + _t('Authentication check failed: incorrect password?')); } - const plaintext = await subtleCrypto.decrypt( - { - name: "AES-CTR", - counter: iv, - length: 64, - }, - aesKey, - ciphertext, - ); + 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)); } @@ -113,16 +150,22 @@ export async function encryptMegolmKeyFile(data, password, options) { iv[9] &= 0x7f; const [aesKey, hmacKey] = await deriveKeys(salt, kdfRounds, password); + const encodedData = new TextEncoder().encode(data); - const ciphertext = await subtleCrypto.encrypt( - { - name: "AES-CTR", - counter: iv, - length: 64, - }, - aesKey, - 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); @@ -139,11 +182,17 @@ export async function encryptMegolmKeyFile(data, password, options) { const toSign = resultBuffer.subarray(0, idx); - const hmac = await subtleCrypto.sign( - {name: 'HMAC'}, - hmacKey, - toSign, - ); + 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); @@ -160,24 +209,35 @@ export async function encryptMegolmKeyFile(data, password, options) { */ async function deriveKeys(salt, iterations, password) { const start = new Date(); - const key = await subtleCrypto.importKey( - 'raw', - new TextEncoder().encode(password), - {name: 'PBKDF2'}, - false, - ['deriveBits'], - ); - const keybits = await subtleCrypto.deriveBits( - { - name: 'PBKDF2', - salt: salt, - iterations: iterations, - hash: 'SHA-512', - }, - key, - 512, - ); + 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: salt, + iterations: 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"); @@ -191,7 +251,10 @@ async function deriveKeys(salt, iterations, password) { {name: 'AES-CTR'}, false, ['encrypt', 'decrypt'], - ); + ).catch((e) => { + throw friendlyError('subtleCrypto.importKey failed for AES key: ' + e, cryptoFailMsg()); + }); + const hmacProm = subtleCrypto.importKey( 'raw', hmacKey, @@ -201,7 +264,10 @@ async function deriveKeys(salt, iterations, password) { }, false, ['sign', 'verify'], - ); + ).catch((e) => { + throw friendlyError('subtleCrypto.importKey failed for HMAC key: ' + e, cryptoFailMsg()); + }); + return await Promise.all([aesProm, hmacProm]); } From 20bdae6079e16d07a498e373d40e31ddc95c118b Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 8 Jun 2017 18:35:45 +0100 Subject: [PATCH 009/240] delint UserSettings.js --- .eslintignore.errorfiles | 1 - src/components/structures/UserSettings.js | 153 ++++++++++++++-------- 2 files changed, 95 insertions(+), 59 deletions(-) diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index f1b63d7367..20c4ca3b4a 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -30,7 +30,6 @@ src/components/structures/RoomView.js src/components/structures/ScrollPanel.js src/components/structures/TimelinePanel.js src/components/structures/UploadBar.js -src/components/structures/UserSettings.js src/components/views/avatars/BaseAvatar.js src/components/views/avatars/MemberAvatar.js src/components/views/avatars/RoomAvatar.js diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 9059378d32..e87bde6d87 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -389,7 +389,10 @@ module.exports = React.createClass({ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: _t("Success"), - description: _t("Your password was successfully changed. You will not receive push notifications on other devices until you log back in to them") + ".", + description: _t( + "Your password was successfully changed. You will not receive " + + "push notifications on other devices until you log back in to them", + ) + ".", }); dis.dispatch({action: 'password_changed'}); }, @@ -427,7 +430,10 @@ module.exports = React.createClass({ this._addThreepid.addEmailAddress(emailAddress, true).done(() => { Modal.createDialog(QuestionDialog, { title: _t("Verification Pending"), - description: _t("Please check your email and click on the link it contains. Once this is done, click continue."), + description: _t( + "Please check your email and click on the link it contains. Once this " + + "is done, click continue.", + ), button: _t('Continue'), onFinished: this.onEmailDialogFinished, }); @@ -447,7 +453,7 @@ module.exports = React.createClass({ const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createDialog(QuestionDialog, { title: _t("Remove Contact Information?"), - description: _t("Remove %(threePid)s?", { threePid : threepid.address }), + description: _t("Remove %(threePid)s?", { threePid: threepid.address }), button: _t('Remove'), onFinished: (submit) => { if (submit) { @@ -489,8 +495,8 @@ module.exports = React.createClass({ this.setState({email_add_pending: false}); if (err.errcode == 'M_THREEPID_AUTH_FAILED') { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - let message = _t("Unable to verify email address.") + " " + - _t("Please check your email and click on the link it contains. Once this is done, click continue."); + const message = _t("Unable to verify email address.") + " " + + _t("Please check your email and click on the link it contains. Once this is done, click continue."); Modal.createDialog(QuestionDialog, { title: _t("Verification Pending"), description: message, @@ -608,7 +614,7 @@ module.exports = React.createClass({ } }, - _renderLanguageSetting: function () { + _renderLanguageSetting: function() { const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); return
@@ -639,7 +645,7 @@ module.exports = React.createClass({ UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) } + onChange={ this._onPreviewsDisabledChanged } />
; }, + _onPreviewsDisabledChanged: function(e) { + UserSettingsStore.setUrlPreviewsDisabled(e.target.checked); + }, + _renderSyncedSetting: function(setting) { + // TODO: this ought to be a separate component so that we don't need + // to rebind the onChange each time we render + + const onChange = (e) => { + UserSettingsStore.setSyncedSetting(setting.id, e.target.checked); + if (setting.fn) setting.fn(e.target.checked); + }; + return
{ - UserSettingsStore.setSyncedSetting(setting.id, e.target.checked); - if (setting.fn) setting.fn(e.target.checked); - } - } + onChange={ onChange } />