From 1d5d44d63d2c07ac51f9886eb426c8ee8f9a22ab Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 12 Jan 2017 11:45:47 +0000 Subject: [PATCH 01/22] TextEncoder polyfill Apparently Safari doesn't sport a TextEncoder, so here's a polyfill for it. --- src/utils/TextDecoderPolyfill.js | 131 +++++++++++++++++++++++++ src/utils/TextEncoderPolyfill.js | 78 +++++++++++++++ test/utils/TextDecoderPolyfill-test.js | 85 ++++++++++++++++ test/utils/TextEncoderPolyfill-test.js | 39 ++++++++ 4 files changed, 333 insertions(+) create mode 100644 src/utils/TextDecoderPolyfill.js create mode 100644 src/utils/TextEncoderPolyfill.js create mode 100644 test/utils/TextDecoderPolyfill-test.js create mode 100644 test/utils/TextEncoderPolyfill-test.js diff --git a/src/utils/TextDecoderPolyfill.js b/src/utils/TextDecoderPolyfill.js new file mode 100644 index 0000000000..e203676bb7 --- /dev/null +++ b/src/utils/TextDecoderPolyfill.js @@ -0,0 +1,131 @@ +/* +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. +*/ + +"use strict"; + +// Polyfill for TextDecoder. + +const REPLACEMENT_CHAR = '\uFFFD'; + +export default class TextDecoder { + /** + * Decode a UTF-8 byte array as a javascript string + * + * @param {Uint8Array} u8Array UTF-8-encoded onput + * @return {str} + */ + decode(u8Array) { + let u0, u1, u2, u3; + + let str = ''; + let idx = 0; + while (idx < u8Array.length) { + u0 = u8Array[idx++]; + if (!(u0 & 0x80)) { + str += String.fromCharCode(u0); + continue; + } + + if ((u0 & 0xC0) != 0xC0) { + // continuation byte where we expect a leading byte + str += REPLACEMENT_CHAR; + continue; + } + + if (u0 > 0xF4) { + // this would imply a 5-byte or longer encoding, which is + // invalid and unsupported here. + str += REPLACEMENT_CHAR; + continue; + } + + u1 = u8Array[idx++]; + if (u1 === undefined) { + str += REPLACEMENT_CHAR; + continue; + } + + if ((u1 & 0xC0) != 0x80) { + // leading byte where we expect a continuation byte + str += REPLACEMENT_CHAR.repeat(2); + continue; + } + u1 &= 0x3F; + if (!(u0 & 0x20)) { + const u = ((u0 & 0x1F) << 6) | u1; + if (u < 0x80) { + // over-long + str += REPLACEMENT_CHAR.repeat(2); + } else { + str += String.fromCharCode(u); + } + continue; + } + + u2 = u8Array[idx++]; + if (u2 === undefined) { + str += REPLACEMENT_CHAR.repeat(2); + continue; + } + if ((u2 & 0xC0) != 0x80) { + // leading byte where we expect a continuation byte + str += REPLACEMENT_CHAR.repeat(3); + continue; + } + u2 &= 0x3F; + if (!(u0 & 0x10)) { + const u = ((u0 & 0x0F) << 12) | (u1 << 6) | u2; + if (u < 0x800) { + // over-long + str += REPLACEMENT_CHAR.repeat(3); + } else if (u == 0xFEFF && idx == 3) { + // byte-order mark: do not add to output + } else { + str += String.fromCharCode(u); + } + continue; + } + + u3 = u8Array[idx++]; + if (u3 === undefined) { + str += REPLACEMENT_CHAR.repeat(3); + continue; + } + if ((u3 & 0xC0) != 0x80) { + // leading byte where we expect a continuation byte + str += REPLACEMENT_CHAR.repeat(4); + continue; + } + u3 &= 0x3F; + const u = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | u3; + if (u < 0x10000) { + // over-long + str += REPLACEMENT_CHAR.repeat(4); + continue; + } + if (u > 0x1FFFF) { + // unicode stops here. + str += REPLACEMENT_CHAR.repeat(4); + continue; + } + + // encode as utf-16 + const v = u - 0x10000; + str += String.fromCharCode(0xD800 | (v >> 10), 0xDC00 | (v & 0x3FF)); + } + return str; + } +} diff --git a/src/utils/TextEncoderPolyfill.js b/src/utils/TextEncoderPolyfill.js new file mode 100644 index 0000000000..2da09710f2 --- /dev/null +++ b/src/utils/TextEncoderPolyfill.js @@ -0,0 +1,78 @@ +/* +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. +*/ + +"use strict"; + +// Polyfill for TextEncoder. Based on emscripten's stringToUTF8Array. + +function utf8len(str) { + var len = 0; + for (var i = 0; i < str.length; ++i) { + var u = str.charCodeAt(i); + if (u >= 0xD800 && u <= 0xDFFF && i < str.length-1) { + // lead surrogate - combine with next surrogate + u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF); + } + + if (u <= 0x7F) { + ++len; + } else if (u <= 0x7FF) { + len += 2; + } else if (u <= 0xFFFF) { + len += 3; + } else { + len += 4; + } + } + return len; +} + +export default class TextEncoder { + /** + * Encode a javascript string as utf-8 + * + * @param {String} str String to encode + * @return {Uint8Array} UTF-8-encoded output + */ + encode(str) { + const outU8Array = new Uint8Array(utf8len(str)); + var outIdx = 0; + for (var i = 0; i < str.length; ++i) { + var u = str.charCodeAt(i); + if (u >= 0xD800 && u <= 0xDFFF && i < str.length-1) { + // lead surrogate - combine with next surrogate + u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF); + } + + if (u <= 0x7F) { + outU8Array[outIdx++] = u; + } else if (u <= 0x7FF) { + outU8Array[outIdx++] = 0xC0 | (u >> 6); + outU8Array[outIdx++] = 0x80 | (u & 63); + } else if (u <= 0xFFFF) { + outU8Array[outIdx++] = 0xE0 | (u >> 12); + outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63); + outU8Array[outIdx++] = 0x80 | (u & 63); + } else { + outU8Array[outIdx++] = 0xF0 | (u >> 18); + outU8Array[outIdx++] = 0x80 | ((u >> 12) & 63); + outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63); + outU8Array[outIdx++] = 0x80 | (u & 63); + } + } + return outU8Array; + } +} diff --git a/test/utils/TextDecoderPolyfill-test.js b/test/utils/TextDecoderPolyfill-test.js new file mode 100644 index 0000000000..84f5edf187 --- /dev/null +++ b/test/utils/TextDecoderPolyfill-test.js @@ -0,0 +1,85 @@ +/* +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. +*/ + +"use strict"; + +import TextDecoderPolyfill from 'utils/TextDecoderPolyfill'; + +import * as testUtils from '../test-utils'; +import expect from 'expect'; + +describe('textDecoderPolyfill', function() { + beforeEach(function() { + testUtils.beforeEach(this); + }); + + it('should correctly decode a range of strings', function() { + const decoder = new TextDecoderPolyfill(); + + expect(decoder.decode(Uint8Array.of(65, 66, 67))).toEqual('ABC'); + expect(decoder.decode(Uint8Array.of(0xC3, 0xA6))).toEqual('æ'); + expect(decoder.decode(Uint8Array.of(0xE2, 0x82, 0xAC))).toEqual('€'); + expect(decoder.decode(Uint8Array.of(0xF0, 0x9F, 0x92, 0xA9))).toEqual('\uD83D\uDCA9'); + }); + + it('should ignore byte-order marks', function() { + const decoder = new TextDecoderPolyfill(); + expect(decoder.decode(Uint8Array.of(0xEF, 0xBB, 0xBF, 65))) + .toEqual('A'); + }); + + it('should not ignore byte-order marks in the middle of the array', function() { + const decoder = new TextDecoderPolyfill(); + expect(decoder.decode(Uint8Array.of(65, 0xEF, 0xBB, 0xBF, 66))) + .toEqual('A\uFEFFB'); + }); + + it('should reject overlong encodings', function() { + const decoder = new TextDecoderPolyfill(); + + // euro, as 4 bytes + expect(decoder.decode(Uint8Array.of(65, 0xF0, 0x82, 0x82, 0xAC, 67))) + .toEqual('A\uFFFD\uFFFD\uFFFD\uFFFDC'); + }); + + it('should reject 5 and 6-byte encodings', function() { + const decoder = new TextDecoderPolyfill(); + + expect(decoder.decode(Uint8Array.of(65, 0xF8, 0x82, 0x82, 0x82, 0x82, 67))) + .toEqual('A\uFFFD\uFFFD\uFFFD\uFFFD\uFFFDC'); + }); + + it('should reject code points beyond 0x10000', function() { + const decoder = new TextDecoderPolyfill(); + + expect(decoder.decode(Uint8Array.of(0xF4, 0xA0, 0x80, 0x80))) + .toEqual('\uFFFD\uFFFD\uFFFD\uFFFD'); + }); + + it('should cope with end-of-string', function() { + const decoder = new TextDecoderPolyfill(); + + expect(decoder.decode(Uint8Array.of(65, 0xC3))) + .toEqual('A\uFFFD'); + + expect(decoder.decode(Uint8Array.of(65, 0xE2, 0x82))) + .toEqual('A\uFFFD\uFFFD'); + + expect(decoder.decode(Uint8Array.of(65, 0xF0, 0x9F, 0x92))) + .toEqual('A\uFFFD\uFFFD\uFFFD'); + }); + +}); diff --git a/test/utils/TextEncoderPolyfill-test.js b/test/utils/TextEncoderPolyfill-test.js new file mode 100644 index 0000000000..4f422ec375 --- /dev/null +++ b/test/utils/TextEncoderPolyfill-test.js @@ -0,0 +1,39 @@ +/* +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. +*/ + +"use strict"; + +import TextEncoderPolyfill from 'utils/TextEncoderPolyfill'; + +import * as testUtils from '../test-utils'; +import expect from 'expect'; + +describe('textEncoderPolyfill', function() { + beforeEach(function() { + testUtils.beforeEach(this); + }); + + it('should correctly encode a range of strings', function() { + const encoder = new TextEncoderPolyfill(); + + expect(encoder.encode('ABC')).toEqual(Uint8Array.of(65, 66, 67)); + expect(encoder.encode('æ')).toEqual(Uint8Array.of(0xC3, 0xA6)); + expect(encoder.encode('€')).toEqual(Uint8Array.of(0xE2, 0x82, 0xAC)); + + // PILE OF POO (💩) + expect(encoder.encode('\uD83D\uDCA9')).toEqual(Uint8Array.of(0xF0, 0x9F, 0x92, 0xA9)); + }); +}); From f8e56778ea5b0577d0d594f4416305b79ee6d281 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 11 Jan 2017 22:22:11 +0000 Subject: [PATCH 02/22] Encryption and decryption for megolm backups --- src/utils/MegolmExportEncryption.js | 312 +++++++++++++++++++++ test/utils/MegolmExportEncryption-test.js | 115 ++++++++ test/utils/generate-megolm-test-vectors.py | 117 ++++++++ 3 files changed, 544 insertions(+) create mode 100644 src/utils/MegolmExportEncryption.js create mode 100644 test/utils/MegolmExportEncryption-test.js create mode 100755 test/utils/generate-megolm-test-vectors.py diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js new file mode 100644 index 0000000000..5b2e16ef29 --- /dev/null +++ b/src/utils/MegolmExportEncryption.js @@ -0,0 +1,312 @@ +/* +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. +*/ + +"use strict"; + +// polyfill textencoder if necessary +let TextEncoder = window.TextEncoder; +if (!TextEncoder) { + TextEncoder = require('./TextEncoderPolyfill'); +} +let TextDecoder = window.TextDecoder; +if (TextDecoder) { + TextDecoder = require('./TextDecoderPolyfill'); +} + +const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle; + +/** + * Decrypt a megolm key file + * + * @param {ArrayBuffer} file + * @param {String} password + * @return {Promise} promise for decrypted output + */ +export 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'); + } + + const version = body[0]; + if (version !== 1) { + throw new Error('Unsupported version'); + } + + const ciphertextLength = body.length-(1+16+16+4+32); + if (body.length < 0) { + throw new Error('Invalid file: too short'); + } + + 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); + + return deriveKeys(salt, iterations, password).then((keys) => { + const [aes_key, sha_key] = keys; + + const toVerify = body.subarray(0, -32); + return subtleCrypto.verify( + {name: 'HMAC'}, + sha_key, + hmac, + toVerify, + ).then((isValid) => { + if (!isValid) { + throw new Error('Authentication check failed: incorrect password?') + } + + return subtleCrypto.decrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aes_key, + ciphertext, + ); + }); + }).then((plaintext) => { + return new TextDecoder().decode(new Uint8Array(plaintext)); + }); +} + + +/** + * Encrypt a megolm key file + * + * @param {String} data + * @param {String} password + * @param {Object=} options + * @param {Nunber=} options.kdf_rounds Number of iterations to perform of the + * key-derivation function. + * @return {Promise} promise for encrypted output + */ +export function encryptMegolmKeyFile(data, password, options) { + options = options || {}; + const kdf_rounds = options.kdf_rounds || 100000; + + const salt = new Uint8Array(16); + window.crypto.getRandomValues(salt); + const iv = new Uint8Array(16); + window.crypto.getRandomValues(iv); + + return deriveKeys(salt, kdf_rounds, password).then((keys) => { + const [aes_key, sha_key] = keys; + + return subtleCrypto.encrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aes_key, + 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++] = kdf_rounds >> 24; + resultBuffer[idx++] = (kdf_rounds >> 16) & 0xff; + resultBuffer[idx++] = (kdf_rounds >> 8) & 0xff; + resultBuffer[idx++] = kdf_rounds & 0xff; + resultBuffer.set(cipherArray, idx); idx += cipherArray.length; + + const toSign = resultBuffer.subarray(0, idx); + + return subtleCrypto.sign( + {name: 'HMAC'}, + sha_key, + toSign, + ).then((hmac) => { + hmac = new Uint8Array(hmac); + resultBuffer.set(hmac, idx); + return packMegolmKeyFile(resultBuffer); + }); + }); + }); +} + +/** + * Derive the AES and SHA 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, sha key] + */ +function deriveKeys(salt, iterations, password) { + return 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 aes_key = keybits.slice(0, 32); + const sha_key = keybits.slice(32); + + const aes_prom = subtleCrypto.importKey( + 'raw', + aes_key, + {name: 'AES-CTR'}, + false, + ['encrypt', 'decrypt'] + ); + const sha_prom = subtleCrypto.importKey( + 'raw', + sha_key, + { + name: 'HMAC', + hash: {name: 'SHA-256'}, + }, + false, + ['sign', 'verify'] + ); + return Promise.all([aes_prom, sha_prom]); + }); +} + +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++) { + lines[i] = encodeBase64(data.subarray(o, o+LINE_LENGTH)); + o += LINE_LENGTH; + } + lines[i++] = TRAILER_LINE; + lines[i] = ''; + return (new TextEncoder().encode(lines.join('\n'))).buffer; +} + +/** + * 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. + var latin1String = String.fromCharCode.apply(null, uint8Array); + // Use the builtin base64 encoder. + return window.btoa(latin1String); +} + +/** + * 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. + var latin1String = window.atob(base64); + // Encode the string as a Uint8Array + var uint8Array = new Uint8Array(latin1String.length); + for (var i = 0; i < latin1String.length; i++) { + uint8Array[i] = latin1String.charCodeAt(i); + } + return uint8Array; +} diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js new file mode 100644 index 0000000000..fa51d83c6d --- /dev/null +++ b/test/utils/MegolmExportEncryption-test.js @@ -0,0 +1,115 @@ +/* +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. +*/ + +"use strict"; + +import * as MegolmExportEncryption from 'utils/MegolmExportEncryption'; + +import * as testUtils from '../test-utils'; +import expect from 'expect'; + +// polyfill textencoder if necessary +let TextEncoder = window.TextEncoder; +if (!TextEncoder) { + TextEncoder = require('utils/TextEncoderPolyfill'); +} + +const TEST_VECTORS=[ + [ + "plain", + "password", + "-----BEGIN MEGOLM SESSION DATA-----\nAXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx\ncissyYBxjsfsAndErh065A8=\n-----END MEGOLM SESSION DATA-----" + ], + [ + "Hello, World", + "betterpassword", + "-----BEGIN MEGOLM SESSION DATA-----\nAW1vcmVzYWx0bW9yZXNhbHT//////////wAAAAAAAAAAAAAD6KyBpe1Niv5M5NPm4ZATsJo5nghk\nKYu63a0YQ5DRhUWEKk7CcMkrKnAUiZny\n-----END MEGOLM SESSION DATA-----" + ], + [ + "alphanumericallyalphanumericallyalphanumericallyalphanumerically", + "SWORDFISH", + "-----BEGIN MEGOLM SESSION DATA-----\nAXllc3NhbHR5Z29vZG5lc3P//////////wAAAAAAAAAAAAAD6OIW+Je7gwvjd4kYrb+49gKCfExw\nMgJBMD4mrhLkmgAngwR1pHjbWXaoGybtiAYr0moQ93GrBQsCzPbvl82rZhaXO3iH5uHo/RCEpOqp\nPgg29363BGR+/Ripq/VCLKGNbw==\n-----END MEGOLM SESSION DATA-----" + ], + [ + "alphanumericallyalphanumericallyalphanumericallyalphanumerically", + "passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword", + "-----BEGIN MEGOLM SESSION DATA-----\nAf//////////////////////////////////////////AAAD6IAZJy7IQ7Y0idqSw/bmpngEEVVh\ngsH+8ptgqxw6ZVWQnohr8JsuwH9SwGtiebZuBu5smPCO+RFVWH2cQYslZijXv/BEH/txvhUrrtCd\nbWnSXS9oymiqwUIGs08sXI33ZA==\n-----END MEGOLM SESSION DATA-----" + ] +] +; + +function stringToArray(s) { + return new TextEncoder().encode(s).buffer; +} + +describe('MegolmExportEncryption', function() { + beforeEach(function() { + testUtils.beforeEach(this); + }); + + describe('decrypt', function() { + it('should handle missing header', function() { + const input=stringToArray(`-----`); + expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')}) + .toThrow('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'); + }); + + it('should decrypt a range of inputs', function(done) { + function next(i) { + if (i >= TEST_VECTORS.length) { + done(); + return; + } + + const [plain, password, input] = TEST_VECTORS[i]; + return MegolmExportEncryption.decryptMegolmKeyFile( + stringToArray(input), password + ).then((decrypted) => { + expect(decrypted).toEqual(plain); + return next(i+1); + }) + }; + return next(0).catch(done); + }); + }); + + describe('encrypt', function() { + it('should round-trip', function(done) { + const input = + 'words words many words in plain text here'.repeat(100); + + const password = 'my super secret passphrase'; + + return MegolmExportEncryption.encryptMegolmKeyFile( + input, password, {kdf_rounds: 1000}, + ).then((ciphertext) => { + return MegolmExportEncryption.decryptMegolmKeyFile( + ciphertext, password + ); + }).then((plaintext) => { + expect(plaintext).toEqual(input); + done(); + }).catch(done); + }); + }); +}); diff --git a/test/utils/generate-megolm-test-vectors.py b/test/utils/generate-megolm-test-vectors.py new file mode 100755 index 0000000000..0ce5f5e4b3 --- /dev/null +++ b/test/utils/generate-megolm-test-vectors.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python + +from __future__ import print_function + +import base64 +import json +import struct + +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import ciphers, hashes, hmac +from cryptography.hazmat.primitives.kdf import pbkdf2 +from cryptography.hazmat.primitives.ciphers import algorithms, modes + +backend = backends.default_backend() + +def parse_u128(s): + a, b = struct.unpack(">QQ", s) + return (a << 64) | b + +def encrypt_ctr(key, iv, plaintext, counter_bits=64): + alg = algorithms.AES(key) + + # Some AES-CTR implementations treat some parts of the IV as a nonce (which + # remains constant throughought encryption), and some as a counter (which + # increments every block, ie 16 bytes, and wraps after a while). Different + # implmententations use different amounts of the IV for each part. + # + # The python cryptography library uses the whole IV as a counter; to make + # it match other implementations with a given counter size, we manually + # implement wrapping the counter. + + # number of AES blocks between each counter wrap + limit = 1 << counter_bits + + # parse IV as a 128-bit int + parsed_iv = parse_u128(iv) + + # split IV into counter and nonce + counter = parsed_iv & (limit - 1) + nonce = parsed_iv & ~(limit - 1) + + # encrypt up to the first counter wraparound + size = 16 * (limit - counter) + encryptor = ciphers.Cipher( + alg, + modes.CTR(iv), + backend=backend + ).encryptor() + input = plaintext[:size] + result = encryptor.update(input) + encryptor.finalize() + offset = size + + # do remaining data starting with a counter of zero + iv = struct.pack(">QQ", nonce >> 64, nonce & ((1 << 64) - 1)) + size = 16 * limit + + while offset < len(plaintext): + encryptor = ciphers.Cipher( + alg, + modes.CTR(iv), + backend=backend + ).encryptor() + input = plaintext[offset:offset+size] + result += encryptor.update(input) + encryptor.finalize() + offset += size + + return result + +def hmac_sha256(key, message): + h = hmac.HMAC(key, hashes.SHA256(), backend=backend) + h.update(message) + return h.finalize() + +def encrypt(key, iv, salt, plaintext, iterations=1000): + """ + Returns: + (bytes) ciphertext + """ + if len(salt) != 16: + raise Exception("Expected 128 bits of salt - got %i bits" % len((salt) * 8)) + if len(iv) != 16: + raise Exception("Expected 128 bits of IV - got %i bits" % (len(iv) * 8)) + + sha = hashes.SHA512() + kdf = pbkdf2.PBKDF2HMAC(sha, 64, salt, iterations, backend) + k = kdf.derive(key) + + aes_key = k[0:32] + sha_key = k[32:] + + packed_file = ( + b"\x01" # version + + salt + + iv + + struct.pack(">L", iterations) + + encrypt_ctr(aes_key, iv, plaintext) + ) + packed_file += hmac_sha256(sha_key, packed_file) + + return ( + b"-----BEGIN MEGOLM SESSION DATA-----\n" + + base64.encodestring(packed_file) + + b"-----END MEGOLM SESSION DATA-----" + ) + +def gen(password, iv, salt, plaintext, iterations=1000): + ciphertext = encrypt( + password.encode('utf-8'), iv, salt, plaintext.encode('utf-8'), iterations + ) + return (plaintext, password, ciphertext.decode('utf-8')) + +print (json.dumps([ + gen("password", b"\x88"*16, b"saltsaltsaltsalt", "plain", 10), + gen("betterpassword", b"\xFF"*8 + b"\x00"*8, b"moresaltmoresalt", "Hello, World"), + gen("SWORDFISH", b"\xFF"*8 + b"\x00"*8, b"yessaltygoodness", "alphanumerically" * 4), + gen("password"*32, b"\xFF"*16, b"\xFF"*16, "alphanumerically" * 4), +], indent=4)) From d63f7e83599937a8b9e5dbff638b0a93efa7fafa Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Sat, 14 Jan 2017 01:21:26 +0000 Subject: [PATCH 03/22] Expose megolm import/export via the devtools --- src/index.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/index.js b/src/index.js index 4b920d95d4..5d4145a39b 100644 --- a/src/index.js +++ b/src/index.js @@ -28,3 +28,27 @@ module.exports.getComponent = function(componentName) { return Skinner.getComponent(componentName); }; + +/* hacky functions for megolm import/export until we give it a UI */ +import * as MegolmExportEncryption from './utils/MegolmExportEncryption'; +import MatrixClientPeg from './MatrixClientPeg'; + +window.exportKeys = function(password) { + return MatrixClientPeg.get().exportRoomKeys().then((k) => { + return MegolmExportEncryption.encryptMegolmKeyFile( + JSON.stringify(k), password + ); + }).then((f) => { + console.log(new TextDecoder().decode(new Uint8Array(f))); + }).done(); +}; + +window.importKeys = function(password, data) { + const arrayBuffer = new TextEncoder().encode(data).buffer; + return MegolmExportEncryption.decryptMegolmKeyFile( + arrayBuffer, password + ).then((j) => { + const k = JSON.parse(j); + return MatrixClientPeg.get().importRoomKeys(k); + }); +}; From e37bf6b7be824caade5019da1bc1a4b92c9d6114 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Sat, 14 Jan 2017 01:41:48 +0000 Subject: [PATCH 04/22] Skip crypto tests on PhantomJS --- test/utils/MegolmExportEncryption-test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js index fa51d83c6d..db38a931ed 100644 --- a/test/utils/MegolmExportEncryption-test.js +++ b/test/utils/MegolmExportEncryption-test.js @@ -56,6 +56,13 @@ function stringToArray(s) { } describe('MegolmExportEncryption', function() { + before(function() { + // if we don't have subtlecrypto, go home now + if (!window.crypto.subtle && !window.crypto.webkitSubtle) { + this.skip(); + } + }) + beforeEach(function() { testUtils.beforeEach(this); }); From ac22803ba004b519d88a16a37487efb833575ce9 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 16 Jan 2017 17:01:26 +0000 Subject: [PATCH 05/22] Allow Modal to be used with async-loaded components Add Modal.createDialogAsync, which can be used to display asynchronously-loaded React components. Also make EncryptedEventDialog use it as a handy demonstration. --- src/Modal.js | 73 ++++++++++++++++++- .../views/dialogs/EncryptedEventDialog.js | 0 src/component-index.js | 2 - src/components/views/rooms/EventTile.js | 5 +- 4 files changed, 74 insertions(+), 6 deletions(-) rename src/{components => async-components}/views/dialogs/EncryptedEventDialog.js (100%) diff --git a/src/Modal.js b/src/Modal.js index 44072b9278..c2ce04c4e8 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,6 +19,53 @@ limitations under the License. var React = require('react'); var ReactDOM = require('react-dom'); +import sdk from './index'; + +/** + * Wrap an asynchronous loader function with a react component which shows a + * spinner until the real component loads. + */ +const AsyncWrapper = React.createClass({ + propTypes: { + /** A function which takes a 'callback' argument which it will call + * with the real component once it loads. + */ + loader: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + component: null, + } + }, + + componentWillMount: function() { + this._unmounted = false; + this.props.loader((e) => { + if (this._unmounted) { + return; + } + this.setState({component: e}); + }); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + render: function() { + const {loader, ...otherProps} = this.props; + + if (this.state.component) { + const Component = this.state.component; + return ; + } else { + // show a spinner until the component is loaded. + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + }, +}); module.exports = { DialogContainerId: "mx_Dialog_Container", @@ -36,8 +83,30 @@ module.exports = { }, createDialog: function (Element, props, className) { - var self = this; + return this.createDialogAsync((cb) => {cb(Element)}, props, className); + }, + /** + * Open a modal view. + * + * This can be used to display a react component which is loaded as an asynchronous + * webpack component. To do this, set 'loader' as: + * + * (cb) => { + * require([''], cb); + * } + * + * @param {Function} loader a function which takes a 'callback' argument, + * which it should call with a React component which will be displayed as + * the modal view. + * + * @param {Object} props properties to pass to the displayed + * component. (We will also pass an 'onFinished' property.) + * + * @param {String} className CSS class to apply to the modal wrapper + */ + createDialogAsync: function (loader, props, className) { + var self = this; // never call this via modal.close() from onFinished() otherwise it will loop var closeDialog = function() { if (props && props.onFinished) props.onFinished.apply(null, arguments); @@ -49,7 +118,7 @@ module.exports = { var dialog = (
- +
diff --git a/src/components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js similarity index 100% rename from src/components/views/dialogs/EncryptedEventDialog.js rename to src/async-components/views/dialogs/EncryptedEventDialog.js diff --git a/src/component-index.js b/src/component-index.js index bc3d698cac..e83de8739d 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -75,8 +75,6 @@ import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInvit views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; views$dialogs$DeactivateAccountDialog && (module.exports.components['views.dialogs.DeactivateAccountDialog'] = views$dialogs$DeactivateAccountDialog); -import views$dialogs$EncryptedEventDialog from './components/views/dialogs/EncryptedEventDialog'; -views$dialogs$EncryptedEventDialog && (module.exports.components['views.dialogs.EncryptedEventDialog'] = views$dialogs$EncryptedEventDialog); import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog'; views$dialogs$ErrorDialog && (module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog); import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog'; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 42dbe78630..d5a2acfdd6 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -366,10 +366,11 @@ module.exports = WithMatrixClient(React.createClass({ }, onCryptoClicked: function(e) { - var EncryptedEventDialog = sdk.getComponent("dialogs.EncryptedEventDialog"); var event = this.props.mxEvent; - Modal.createDialog(EncryptedEventDialog, { + Modal.createDialogAsync((cb) => { + require(['../../../async-components/views/dialogs/EncryptedEventDialog'], cb) + }, { event: event, }); }, From 09ce74cc767a5f98021f5980ce7f4e2b2aece7c4 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 16 Jan 2017 18:44:46 +0000 Subject: [PATCH 06/22] Fix a couple of minor review comments --- src/utils/MegolmExportEncryption.js | 20 ++++++++++---------- src/utils/TextEncoderPolyfill.js | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js index 5b2e16ef29..351f58aaa6 100644 --- a/src/utils/MegolmExportEncryption.js +++ b/src/utils/MegolmExportEncryption.js @@ -60,12 +60,12 @@ export function decryptMegolmKeyFile(data, password) { const hmac = body.subarray(-32); return deriveKeys(salt, iterations, password).then((keys) => { - const [aes_key, sha_key] = keys; + const [aes_key, hmac_key] = keys; const toVerify = body.subarray(0, -32); return subtleCrypto.verify( {name: 'HMAC'}, - sha_key, + hmac_key, hmac, toVerify, ).then((isValid) => { @@ -109,7 +109,7 @@ export function encryptMegolmKeyFile(data, password, options) { window.crypto.getRandomValues(iv); return deriveKeys(salt, kdf_rounds, password).then((keys) => { - const [aes_key, sha_key] = keys; + const [aes_key, hmac_key] = keys; return subtleCrypto.encrypt( { @@ -137,7 +137,7 @@ export function encryptMegolmKeyFile(data, password, options) { return subtleCrypto.sign( {name: 'HMAC'}, - sha_key, + hmac_key, toSign, ).then((hmac) => { hmac = new Uint8Array(hmac); @@ -149,12 +149,12 @@ export function encryptMegolmKeyFile(data, password, options) { } /** - * Derive the AES and SHA keys for the file + * 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, sha key] + * @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key] */ function deriveKeys(salt, iterations, password) { return subtleCrypto.importKey( @@ -176,7 +176,7 @@ function deriveKeys(salt, iterations, password) { ); }).then((keybits) => { const aes_key = keybits.slice(0, 32); - const sha_key = keybits.slice(32); + const hmac_key = keybits.slice(32); const aes_prom = subtleCrypto.importKey( 'raw', @@ -185,9 +185,9 @@ function deriveKeys(salt, iterations, password) { false, ['encrypt', 'decrypt'] ); - const sha_prom = subtleCrypto.importKey( + const hmac_prom = subtleCrypto.importKey( 'raw', - sha_key, + hmac_key, { name: 'HMAC', hash: {name: 'SHA-256'}, @@ -195,7 +195,7 @@ function deriveKeys(salt, iterations, password) { false, ['sign', 'verify'] ); - return Promise.all([aes_prom, sha_prom]); + return Promise.all([aes_prom, hmac_prom]); }); } diff --git a/src/utils/TextEncoderPolyfill.js b/src/utils/TextEncoderPolyfill.js index 2da09710f2..41ee4782a9 100644 --- a/src/utils/TextEncoderPolyfill.js +++ b/src/utils/TextEncoderPolyfill.js @@ -61,16 +61,16 @@ export default class TextEncoder { outU8Array[outIdx++] = u; } else if (u <= 0x7FF) { outU8Array[outIdx++] = 0xC0 | (u >> 6); - outU8Array[outIdx++] = 0x80 | (u & 63); + outU8Array[outIdx++] = 0x80 | (u & 0x3F); } else if (u <= 0xFFFF) { outU8Array[outIdx++] = 0xE0 | (u >> 12); - outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63); - outU8Array[outIdx++] = 0x80 | (u & 63); + outU8Array[outIdx++] = 0x80 | ((u >> 6) & 0x3F); + outU8Array[outIdx++] = 0x80 | (u & 0x3F); } else { outU8Array[outIdx++] = 0xF0 | (u >> 18); - outU8Array[outIdx++] = 0x80 | ((u >> 12) & 63); - outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63); - outU8Array[outIdx++] = 0x80 | (u & 63); + outU8Array[outIdx++] = 0x80 | ((u >> 12) & 0x3F); + outU8Array[outIdx++] = 0x80 | ((u >> 6) & 0x3F); + outU8Array[outIdx++] = 0x80 | (u & 0x3F); } } return outU8Array; From 893a5c971fd43186637f50cb3725eefa09b6d365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Fri, 2 Dec 2016 19:58:35 +0100 Subject: [PATCH 07/22] Fix escaping markdown by rendering plaintext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We still need to parse "plaintext" messages through the markdown renderer so that escappes are rendered properly. Fixes vector-im/riot-web#2870. Signed-off-by: Johannes Löthberg --- src/Markdown.js | 32 ++++++++++++------- .../views/rooms/MessageComposerInput.js | 8 +++-- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Markdown.js b/src/Markdown.js index 18c888b541..2eb84b9041 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -56,23 +56,31 @@ export default class Markdown { return is_plain; } - toHTML() { + render(html) { const parser = new commonmark.Parser(); const renderer = new commonmark.HtmlRenderer({safe: true}); const real_paragraph = renderer.paragraph; - renderer.paragraph = function(node, entering) { - // If there is only one top level node, just return the - // bare text: it's a single line of text and so should be - // 'inline', rather than unnecessarily wrapped in its own - // p tag. If, however, we have multiple nodes, each gets - // its own p tag to keep them as separate paragraphs. - var par = node; - while (par.parent) { - par = par.parent + if (html) { + renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + var par = node; + while (par.parent) { + par = par.parent + } + if (par.firstChild != par.lastChild) { + real_paragraph.call(this, node, entering); + } } - if (par.firstChild != par.lastChild) { - real_paragraph.call(this, node, entering); + } else { + renderer.paragraph = function(node, entering) { + if (entering) { + this.lit('\n\n'); + } } } diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 37d937d6f5..5e8df592da 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -401,7 +401,7 @@ export default class MessageComposerInput extends React.Component { let contentState = null; if (enabled) { const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); - contentState = RichText.HTMLtoContentState(md.toHTML()); + contentState = RichText.HTMLtoContentState(md.render(true)); } else { let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); if (markdown[markdown.length - 1] === '\n') { @@ -523,8 +523,10 @@ export default class MessageComposerInput extends React.Component { ); } else { const md = new Markdown(contentText); - if (!md.isPlainText()) { - contentHTML = md.toHTML(); + if (md.isPlainText()) { + contentText = md.render(false); + } else { + contentHTML = md.render(true); } } From 35d70f0b35aeefa4af43379078b63922f0f70afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Tue, 17 Jan 2017 20:32:06 +0100 Subject: [PATCH 08/22] markdown: Only add \n\n on multiple paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/Markdown.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Markdown.js b/src/Markdown.js index 2eb84b9041..17723e42f8 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -78,8 +78,20 @@ export default class Markdown { } } else { renderer.paragraph = function(node, entering) { - if (entering) { - this.lit('\n\n'); + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + var par = node; + while (par.parent) { + node = par; + par = par.parent; + } + if (node != par.lastChild) { + if (!entering) { + this.lit('\n\n'); + } } } } From c819b433a247ca502daf600c3bc763f138262f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Tue, 17 Jan 2017 20:37:27 +0100 Subject: [PATCH 09/22] Make old message composer use new markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/components/views/rooms/MessageComposerInputOld.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposerInputOld.js b/src/components/views/rooms/MessageComposerInputOld.js index 28e3186c50..3b0100278b 100644 --- a/src/components/views/rooms/MessageComposerInputOld.js +++ b/src/components/views/rooms/MessageComposerInputOld.js @@ -325,12 +325,13 @@ module.exports = React.createClass({ } if (send_markdown) { - const htmlText = mdown.toHTML(); + const htmlText = mdown.render(true); sendMessagePromise = isEmote ? MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) : MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); } else { + const contentText = mdown.render(false); sendMessagePromise = isEmote ? MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); From 49d60ff879aa677e5befb10c831b0d89e9952d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Tue, 17 Jan 2017 21:04:12 +0100 Subject: [PATCH 10/22] Markdown: softbreak is not HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/Markdown.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Markdown.js b/src/Markdown.js index 17723e42f8..ad7ec5ef0c 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -48,6 +48,7 @@ export default class Markdown { } // text and paragraph are just text dummy_renderer.text = function(t) { return t; } + dummy_renderer.softbreak = function(t) { return t; } dummy_renderer.paragraph = function(t) { return t; } const dummy_parser = new commonmark.Parser(); From 2e3bdcf5c697009b0d7b6b82ab5f902ec49115de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Tue, 17 Jan 2017 22:20:05 +0100 Subject: [PATCH 11/22] Markdown: Don't XML escape the output when not HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/Markdown.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Markdown.js b/src/Markdown.js index ad7ec5ef0c..e6f5f59f01 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -78,6 +78,10 @@ export default class Markdown { } } } else { + renderer.out = function(s) { + this.lit(s); + } + renderer.paragraph = function(node, entering) { // If there is only one top level node, just return the // bare text: it's a single line of text and so should be From 31df78f946ef64f73007c6183d17e1c4dc326448 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 18 Jan 2017 11:39:44 +0000 Subject: [PATCH 12/22] Use text-encoding-utf-8 as a TextEncoder polyfill Somebody else seems to have done a good job of polyfilling TextEncoder, so let's use that. --- package.json | 1 + .../views/dialogs/ExportE2eKeysDialog.js | 84 +++++++++++ src/utils/MegolmExportEncryption.js | 7 +- src/utils/TextDecoderPolyfill.js | 131 ------------------ src/utils/TextEncoderPolyfill.js | 78 ----------- test/utils/TextDecoderPolyfill-test.js | 85 ------------ test/utils/TextEncoderPolyfill-test.js | 39 ------ 7 files changed, 89 insertions(+), 336 deletions(-) create mode 100644 src/async-components/views/dialogs/ExportE2eKeysDialog.js delete mode 100644 src/utils/TextDecoderPolyfill.js delete mode 100644 src/utils/TextEncoderPolyfill.js delete mode 100644 test/utils/TextDecoderPolyfill-test.js delete mode 100644 test/utils/TextEncoderPolyfill-test.js diff --git a/package.json b/package.json index 1eaee39c41..e0cfb72148 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.11.1", + "text-encoding-utf-8": "^1.0.1", "velocity-vector": "vector-im/velocity#059e3b2", "whatwg-fetch": "^1.0.0" }, diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js new file mode 100644 index 0000000000..284d299f4b --- /dev/null +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -0,0 +1,84 @@ +/* +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 React from 'react'; + +import sdk from '../../../index'; + +import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; + +export default React.createClass({ + displayName: 'ExportE2eKeysDialog', + + getInitialState: function() { + return { + collectedPassword: false, + }; + }, + + _onPassphraseFormSubmit: function(ev) { + ev.preventDefault(); + console.log(this.refs.passphrase1.value); + return false; + }, + + render: function() { + let content; + if (!this.state.collectedPassword) { + content = ( +
+

+ This process will allow you to export the keys for messages + you have received in encrypted rooms to a local file. You + will then be able to import the file into another Matrix + client in the future, so that client will also be able to + decrypt these messages. +

+

+ The exported file will allow anyone who can read it to decrypt + any encrypted messages that you can see, so you should be + careful to keep it secure. To help with this, you should enter + a passphrase below, which will be used to encrypt the exported + data. It will only be possible to import the data by using the + same passphrase. +

+
+
+ +
+
+ +
+
+ +
+
+
+ ); + } + + return ( +
+
+ Export room keys +
+ {content} +
+ ); + }, +}); diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js index 351f58aaa6..e3ca7e68f2 100644 --- a/src/utils/MegolmExportEncryption.js +++ b/src/utils/MegolmExportEncryption.js @@ -17,13 +17,14 @@ limitations under the License. "use strict"; // polyfill textencoder if necessary +import * as TextEncodingUtf8 from 'text-encoding-utf-8'; let TextEncoder = window.TextEncoder; if (!TextEncoder) { - TextEncoder = require('./TextEncoderPolyfill'); + TextEncoder = TextEncodingUtf8.TextEncoder; } let TextDecoder = window.TextDecoder; -if (TextDecoder) { - TextDecoder = require('./TextDecoderPolyfill'); +if (!TextDecoder) { + TextDecoder = TextEncodingUtf8.TextDecoder; } const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle; diff --git a/src/utils/TextDecoderPolyfill.js b/src/utils/TextDecoderPolyfill.js deleted file mode 100644 index e203676bb7..0000000000 --- a/src/utils/TextDecoderPolyfill.js +++ /dev/null @@ -1,131 +0,0 @@ -/* -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. -*/ - -"use strict"; - -// Polyfill for TextDecoder. - -const REPLACEMENT_CHAR = '\uFFFD'; - -export default class TextDecoder { - /** - * Decode a UTF-8 byte array as a javascript string - * - * @param {Uint8Array} u8Array UTF-8-encoded onput - * @return {str} - */ - decode(u8Array) { - let u0, u1, u2, u3; - - let str = ''; - let idx = 0; - while (idx < u8Array.length) { - u0 = u8Array[idx++]; - if (!(u0 & 0x80)) { - str += String.fromCharCode(u0); - continue; - } - - if ((u0 & 0xC0) != 0xC0) { - // continuation byte where we expect a leading byte - str += REPLACEMENT_CHAR; - continue; - } - - if (u0 > 0xF4) { - // this would imply a 5-byte or longer encoding, which is - // invalid and unsupported here. - str += REPLACEMENT_CHAR; - continue; - } - - u1 = u8Array[idx++]; - if (u1 === undefined) { - str += REPLACEMENT_CHAR; - continue; - } - - if ((u1 & 0xC0) != 0x80) { - // leading byte where we expect a continuation byte - str += REPLACEMENT_CHAR.repeat(2); - continue; - } - u1 &= 0x3F; - if (!(u0 & 0x20)) { - const u = ((u0 & 0x1F) << 6) | u1; - if (u < 0x80) { - // over-long - str += REPLACEMENT_CHAR.repeat(2); - } else { - str += String.fromCharCode(u); - } - continue; - } - - u2 = u8Array[idx++]; - if (u2 === undefined) { - str += REPLACEMENT_CHAR.repeat(2); - continue; - } - if ((u2 & 0xC0) != 0x80) { - // leading byte where we expect a continuation byte - str += REPLACEMENT_CHAR.repeat(3); - continue; - } - u2 &= 0x3F; - if (!(u0 & 0x10)) { - const u = ((u0 & 0x0F) << 12) | (u1 << 6) | u2; - if (u < 0x800) { - // over-long - str += REPLACEMENT_CHAR.repeat(3); - } else if (u == 0xFEFF && idx == 3) { - // byte-order mark: do not add to output - } else { - str += String.fromCharCode(u); - } - continue; - } - - u3 = u8Array[idx++]; - if (u3 === undefined) { - str += REPLACEMENT_CHAR.repeat(3); - continue; - } - if ((u3 & 0xC0) != 0x80) { - // leading byte where we expect a continuation byte - str += REPLACEMENT_CHAR.repeat(4); - continue; - } - u3 &= 0x3F; - const u = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | u3; - if (u < 0x10000) { - // over-long - str += REPLACEMENT_CHAR.repeat(4); - continue; - } - if (u > 0x1FFFF) { - // unicode stops here. - str += REPLACEMENT_CHAR.repeat(4); - continue; - } - - // encode as utf-16 - const v = u - 0x10000; - str += String.fromCharCode(0xD800 | (v >> 10), 0xDC00 | (v & 0x3FF)); - } - return str; - } -} diff --git a/src/utils/TextEncoderPolyfill.js b/src/utils/TextEncoderPolyfill.js deleted file mode 100644 index 41ee4782a9..0000000000 --- a/src/utils/TextEncoderPolyfill.js +++ /dev/null @@ -1,78 +0,0 @@ -/* -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. -*/ - -"use strict"; - -// Polyfill for TextEncoder. Based on emscripten's stringToUTF8Array. - -function utf8len(str) { - var len = 0; - for (var i = 0; i < str.length; ++i) { - var u = str.charCodeAt(i); - if (u >= 0xD800 && u <= 0xDFFF && i < str.length-1) { - // lead surrogate - combine with next surrogate - u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF); - } - - if (u <= 0x7F) { - ++len; - } else if (u <= 0x7FF) { - len += 2; - } else if (u <= 0xFFFF) { - len += 3; - } else { - len += 4; - } - } - return len; -} - -export default class TextEncoder { - /** - * Encode a javascript string as utf-8 - * - * @param {String} str String to encode - * @return {Uint8Array} UTF-8-encoded output - */ - encode(str) { - const outU8Array = new Uint8Array(utf8len(str)); - var outIdx = 0; - for (var i = 0; i < str.length; ++i) { - var u = str.charCodeAt(i); - if (u >= 0xD800 && u <= 0xDFFF && i < str.length-1) { - // lead surrogate - combine with next surrogate - u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF); - } - - if (u <= 0x7F) { - outU8Array[outIdx++] = u; - } else if (u <= 0x7FF) { - outU8Array[outIdx++] = 0xC0 | (u >> 6); - outU8Array[outIdx++] = 0x80 | (u & 0x3F); - } else if (u <= 0xFFFF) { - outU8Array[outIdx++] = 0xE0 | (u >> 12); - outU8Array[outIdx++] = 0x80 | ((u >> 6) & 0x3F); - outU8Array[outIdx++] = 0x80 | (u & 0x3F); - } else { - outU8Array[outIdx++] = 0xF0 | (u >> 18); - outU8Array[outIdx++] = 0x80 | ((u >> 12) & 0x3F); - outU8Array[outIdx++] = 0x80 | ((u >> 6) & 0x3F); - outU8Array[outIdx++] = 0x80 | (u & 0x3F); - } - } - return outU8Array; - } -} diff --git a/test/utils/TextDecoderPolyfill-test.js b/test/utils/TextDecoderPolyfill-test.js deleted file mode 100644 index 84f5edf187..0000000000 --- a/test/utils/TextDecoderPolyfill-test.js +++ /dev/null @@ -1,85 +0,0 @@ -/* -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. -*/ - -"use strict"; - -import TextDecoderPolyfill from 'utils/TextDecoderPolyfill'; - -import * as testUtils from '../test-utils'; -import expect from 'expect'; - -describe('textDecoderPolyfill', function() { - beforeEach(function() { - testUtils.beforeEach(this); - }); - - it('should correctly decode a range of strings', function() { - const decoder = new TextDecoderPolyfill(); - - expect(decoder.decode(Uint8Array.of(65, 66, 67))).toEqual('ABC'); - expect(decoder.decode(Uint8Array.of(0xC3, 0xA6))).toEqual('æ'); - expect(decoder.decode(Uint8Array.of(0xE2, 0x82, 0xAC))).toEqual('€'); - expect(decoder.decode(Uint8Array.of(0xF0, 0x9F, 0x92, 0xA9))).toEqual('\uD83D\uDCA9'); - }); - - it('should ignore byte-order marks', function() { - const decoder = new TextDecoderPolyfill(); - expect(decoder.decode(Uint8Array.of(0xEF, 0xBB, 0xBF, 65))) - .toEqual('A'); - }); - - it('should not ignore byte-order marks in the middle of the array', function() { - const decoder = new TextDecoderPolyfill(); - expect(decoder.decode(Uint8Array.of(65, 0xEF, 0xBB, 0xBF, 66))) - .toEqual('A\uFEFFB'); - }); - - it('should reject overlong encodings', function() { - const decoder = new TextDecoderPolyfill(); - - // euro, as 4 bytes - expect(decoder.decode(Uint8Array.of(65, 0xF0, 0x82, 0x82, 0xAC, 67))) - .toEqual('A\uFFFD\uFFFD\uFFFD\uFFFDC'); - }); - - it('should reject 5 and 6-byte encodings', function() { - const decoder = new TextDecoderPolyfill(); - - expect(decoder.decode(Uint8Array.of(65, 0xF8, 0x82, 0x82, 0x82, 0x82, 67))) - .toEqual('A\uFFFD\uFFFD\uFFFD\uFFFD\uFFFDC'); - }); - - it('should reject code points beyond 0x10000', function() { - const decoder = new TextDecoderPolyfill(); - - expect(decoder.decode(Uint8Array.of(0xF4, 0xA0, 0x80, 0x80))) - .toEqual('\uFFFD\uFFFD\uFFFD\uFFFD'); - }); - - it('should cope with end-of-string', function() { - const decoder = new TextDecoderPolyfill(); - - expect(decoder.decode(Uint8Array.of(65, 0xC3))) - .toEqual('A\uFFFD'); - - expect(decoder.decode(Uint8Array.of(65, 0xE2, 0x82))) - .toEqual('A\uFFFD\uFFFD'); - - expect(decoder.decode(Uint8Array.of(65, 0xF0, 0x9F, 0x92))) - .toEqual('A\uFFFD\uFFFD\uFFFD'); - }); - -}); diff --git a/test/utils/TextEncoderPolyfill-test.js b/test/utils/TextEncoderPolyfill-test.js deleted file mode 100644 index 4f422ec375..0000000000 --- a/test/utils/TextEncoderPolyfill-test.js +++ /dev/null @@ -1,39 +0,0 @@ -/* -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. -*/ - -"use strict"; - -import TextEncoderPolyfill from 'utils/TextEncoderPolyfill'; - -import * as testUtils from '../test-utils'; -import expect from 'expect'; - -describe('textEncoderPolyfill', function() { - beforeEach(function() { - testUtils.beforeEach(this); - }); - - it('should correctly encode a range of strings', function() { - const encoder = new TextEncoderPolyfill(); - - expect(encoder.encode('ABC')).toEqual(Uint8Array.of(65, 66, 67)); - expect(encoder.encode('æ')).toEqual(Uint8Array.of(0xC3, 0xA6)); - expect(encoder.encode('€')).toEqual(Uint8Array.of(0xE2, 0x82, 0xAC)); - - // PILE OF POO (💩) - expect(encoder.encode('\uD83D\uDCA9')).toEqual(Uint8Array.of(0xF0, 0x9F, 0x92, 0xA9)); - }); -}); From 6d2e521421d03032a3e7c5001bec42545c469512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Wed, 18 Jan 2017 14:25:11 +0100 Subject: [PATCH 13/22] Markdown: Add comment about out function override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/Markdown.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Markdown.js b/src/Markdown.js index e6f5f59f01..35ae42f770 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -78,6 +78,9 @@ export default class Markdown { } } } else { + // The default `out` function only sends the input through an XML + // escaping function, which causes messages to be entity encoded, + // which we don't want in this case. renderer.out = function(s) { this.lit(s); } From 30bd01cdf2a2548cf536c7edc72a8950d1238175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Wed, 18 Jan 2017 19:29:11 +0100 Subject: [PATCH 14/22] Markdown: Split up render function into toHTML/toPlaintext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/Markdown.js | 98 +++++++++++-------- .../views/rooms/MessageComposerInput.js | 6 +- .../views/rooms/MessageComposerInputOld.js | 4 +- 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/src/Markdown.js b/src/Markdown.js index 35ae42f770..80d1aa4335 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -23,7 +23,9 @@ import commonmark from 'commonmark'; */ export default class Markdown { constructor(input) { - this.input = input + this.input = input; + this.parser = new commonmark.Parser(); + this.renderer = new commonmark.HtmlRenderer({safe: false}); } isPlainText() { @@ -57,54 +59,66 @@ export default class Markdown { return is_plain; } - render(html) { - const parser = new commonmark.Parser(); + toHTML(html) { + const real_paragraph = this.renderer.paragraph; - const renderer = new commonmark.HtmlRenderer({safe: true}); - const real_paragraph = renderer.paragraph; - if (html) { - renderer.paragraph = function(node, entering) { - // If there is only one top level node, just return the - // bare text: it's a single line of text and so should be - // 'inline', rather than unnecessarily wrapped in its own - // p tag. If, however, we have multiple nodes, each gets - // its own p tag to keep them as separate paragraphs. - var par = node; - while (par.parent) { - par = par.parent - } - if (par.firstChild != par.lastChild) { - real_paragraph.call(this, node, entering); - } + this.renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + var par = node; + while (par.parent) { + par = par.parent } - } else { - // The default `out` function only sends the input through an XML - // escaping function, which causes messages to be entity encoded, - // which we don't want in this case. - renderer.out = function(s) { - this.lit(s); + if (par.firstChild != par.lastChild) { + real_paragraph.call(this, node, entering); } + } - renderer.paragraph = function(node, entering) { - // If there is only one top level node, just return the - // bare text: it's a single line of text and so should be - // 'inline', rather than unnecessarily wrapped in its own - // p tag. If, however, we have multiple nodes, each gets - // its own p tag to keep them as separate paragraphs. - var par = node; - while (par.parent) { - node = par; - par = par.parent; - } - if (node != par.lastChild) { - if (!entering) { - this.lit('\n\n'); - } + var parsed = this.parser.parse(this.input); + var rendered = this.renderer.render(parsed); + + this.renderer.paragraph = real_paragraph; + + return rendered; + } + + toPlaintext() { + const real_paragraph = this.renderer.paragraph; + + // The default `out` function only sends the input through an XML + // escaping function, which causes messages to be entity encoded, + // which we don't want in this case. + this.renderer.out = function(s) { + // The `lit` function adds a string literal to the output buffer. + this.lit(s); + } + + this.renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + var par = node; + while (par.parent) { + node = par; + par = par.parent; + } + if (node != par.lastChild) { + if (!entering) { + this.lit('\n\n'); } } } - var parsed = parser.parse(this.input); - return renderer.render(parsed); + var parsed = this.parser.parse(this.input); + var rendered = this.renderer.render(parsed); + + this.renderer.paragraph = real_paragraph; + + return rendered; } } diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 5e8df592da..41b27c1394 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -401,7 +401,7 @@ export default class MessageComposerInput extends React.Component { let contentState = null; if (enabled) { const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); - contentState = RichText.HTMLtoContentState(md.render(true)); + contentState = RichText.HTMLtoContentState(md.toHTML()); } else { let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); if (markdown[markdown.length - 1] === '\n') { @@ -524,9 +524,9 @@ export default class MessageComposerInput extends React.Component { } else { const md = new Markdown(contentText); if (md.isPlainText()) { - contentText = md.render(false); + contentText = md.toPlaintext(); } else { - contentHTML = md.render(true); + contentHTML = md.toHTML(true); } } diff --git a/src/components/views/rooms/MessageComposerInputOld.js b/src/components/views/rooms/MessageComposerInputOld.js index 3b0100278b..91abd5a2a8 100644 --- a/src/components/views/rooms/MessageComposerInputOld.js +++ b/src/components/views/rooms/MessageComposerInputOld.js @@ -325,13 +325,13 @@ module.exports = React.createClass({ } if (send_markdown) { - const htmlText = mdown.render(true); + const htmlText = mdown.toHTML(); sendMessagePromise = isEmote ? MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) : MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); } else { - const contentText = mdown.render(false); + const contentText = mdown.toPlaintext(false); sendMessagePromise = isEmote ? MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); From 14ead373e2e8c6d1ed612000ddbee668b84f8989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Wed, 18 Jan 2017 20:54:34 +0100 Subject: [PATCH 15/22] Add markdown test-cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- .../views/rooms/MessageComposerInput-test.js | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/components/views/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js index 8d33e0ead3..ca2bbba2eb 100644 --- a/test/components/views/rooms/MessageComposerInput-test.js +++ b/test/components/views/rooms/MessageComposerInput-test.js @@ -158,4 +158,85 @@ describe('MessageComposerInput', () => { expect(['__', '**']).toContain(spy.args[0][1]); }); + it('should not entity-encode " in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('"'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('"'); + }); + + it('should escape characters without other markup in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('\\*escaped\\*'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('*escaped*'); + }); + + it('should escape characters with other markup in Markdown mode', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + addTextToDraft('\\*escaped\\* *italic*'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('\\*escaped\\* *italic*'); + expect(spy.args[0][2]).toEqual('*escaped* italic'); + }); + + it('should not convert -_- into a horizontal rule in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('-_-'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('-_-'); + }); + + it('should not strip tags in Markdown mode', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + addTextToDraft('striked-out'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('striked-out'); + expect(spy.args[0][2]).toEqual('striked-out'); + }); + + it('should not strike-through ~~~ in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('~~~striked-out~~~'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('~~~striked-out~~~'); + }); + + it('should not mark single unmarkedup paragraphs as HTML in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.'); + }); + + it('should not mark two unmarkedup paragraphs as HTML in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.'); + }); }); From 4df968ecdf36334313f30d27aff409d40cdbe859 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 18 Jan 2017 20:06:44 +0000 Subject: [PATCH 16/22] fix css snafu --- src/components/structures/UserSettings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 5ce9ab1a15..498acc1917 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -605,7 +605,7 @@ module.exports = React.createClass({
- +
Remove From 4e5689082de009c3e6214e578831804942c120bd Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 18 Jan 2017 20:06:54 +0000 Subject: [PATCH 17/22] correctly load synced themes without NPE --- src/components/structures/MatrixChat.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 8917f0535e..0336dc99d5 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -588,13 +588,6 @@ module.exports = React.createClass({ _onLoadCompleted: function() { this.props.onLoadCompleted(); this.setState({loading: false}); - - // set up the right theme. - // XXX: this will temporarily flicker the wrong CSS. - dis.dispatch({ - action: 'set_theme', - value: UserSettingsStore.getSyncedSetting('theme') - }); }, /** @@ -730,6 +723,16 @@ module.exports = React.createClass({ action: 'logout' }); }); + cli.on("accountData", function(ev) { + if (ev.getType() === 'im.vector.web.settings') { + if (ev.getContent() && ev.getContent().theme) { + dis.dispatch({ + action: 'set_theme', + value: ev.getContent().theme, + }); + } + } + }); }, onFocus: function(ev) { From e06dd6e34ae5b70723d7f83603e0a100565fd641 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Thu, 19 Jan 2017 10:51:40 +0100 Subject: [PATCH 18/22] Implement auto-join rooms on registration Also: This fixes registration with a team: only the email localpart was being used to register. When a registration is successful, the user will be joined to rooms specified in the config.json teamsConfig: "teamsConfig" : { "supportEmail": "support@riot.im", "teams": [ { "name" : "matrix", "emailSuffix" : "matrix.org", "rooms" : [ { "id" : "#irc_matrix:matrix.org", "autoJoin" : true } ] } ] } autoJoin can of course be set to false if the room should only be displayed on the (forthcoming) welcome page for each team, and not auto-joined. --- src/components/structures/login/Registration.js | 17 +++++++++++++++++ src/components/views/login/RegistrationForm.js | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index fb24b61504..f89b627e8d 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -179,6 +179,23 @@ module.exports = React.createClass({ accessToken: response.access_token }); + // Auto-join rooms + if (self.props.teamsConfig) { + for (let i = 0; i < self.props.teamsConfig.teams.length; i++) { + let team = self.props.teamsConfig.teams[i]; + if (self.state.formVals.email.endsWith(team.emailSuffix)) { + console.log("User successfully registered with team " + team.name); + team.rooms.forEach((room) => { + if (room.autoJoin) { + console.log("Auto-joining " + room.id); + MatrixClientPeg.get().joinRoom(room.id); + } + }); + break; + } + } + } + if (self.props.brand) { MatrixClientPeg.get().getPushers().done((resp)=>{ var pushers = resp.pushers; diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 3e07302a91..4be40bc53a 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -116,10 +116,14 @@ module.exports = React.createClass({ }, _doSubmit: function() { + let email = this.refs.email.value.trim(); + if (this.state.selectedTeam) { + email += "@" + this.state.selectedTeam.emailSuffix; + } var promise = this.props.onRegisterClick({ username: this.refs.username.value.trim() || this.props.guestUsername, password: this.refs.password.value.trim(), - email: this.refs.email.value.trim() + email: email, }); if (promise) { From e9eb38fd74cb13e85a0d31cd359894a1eea5d535 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Thu, 19 Jan 2017 11:05:08 +0100 Subject: [PATCH 19/22] Update propTypes and do null checks --- src/components/structures/login/Registration.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index f89b627e8d..b092e0a9fb 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -57,6 +57,11 @@ module.exports = React.createClass({ "name": React.PropTypes.string, // The suffix with which every team email address ends "emailSuffix": React.PropTypes.string, + // The rooms to use during auto-join + "rooms": React.PropTypes.arrayOf(React.PropTypes.shape({ + "id": React.PropTypes.string, + "autoJoin": React.PropTypes.bool, + })), })).required, }), @@ -180,11 +185,14 @@ module.exports = React.createClass({ }); // Auto-join rooms - if (self.props.teamsConfig) { + if (self.props.teamsConfig && self.props.teamsConfig.teams) { for (let i = 0; i < self.props.teamsConfig.teams.length; i++) { let team = self.props.teamsConfig.teams[i]; if (self.state.formVals.email.endsWith(team.emailSuffix)) { console.log("User successfully registered with team " + team.name); + if (!team.rooms) { + break; + } team.rooms.forEach((room) => { if (room.autoJoin) { console.log("Auto-joining " + room.id); From 8b60cb9df0594bb032a560428dfa1f67f068c08c Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 19 Jan 2017 10:43:41 +0000 Subject: [PATCH 20/22] Megolm export: Clear bit 63 of the salt --- src/utils/MegolmExportEncryption.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js index e3ca7e68f2..983ec2c75f 100644 --- a/src/utils/MegolmExportEncryption.js +++ b/src/utils/MegolmExportEncryption.js @@ -106,6 +106,12 @@ export function encryptMegolmKeyFile(data, password, options) { const salt = new Uint8Array(16); window.crypto.getRandomValues(salt); + + // clear bit 63 of the salt 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 salt is a price we have to pay. + salt[9] &= 0x7f; + const iv = new Uint8Array(16); window.crypto.getRandomValues(iv); From fdc213cbb80f84a202c7d55fa79a7c45a66af4d0 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 19 Jan 2017 10:44:01 +0000 Subject: [PATCH 21/22] Megolm export: fix test --- test/utils/MegolmExportEncryption-test.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js index db38a931ed..28752ae529 100644 --- a/test/utils/MegolmExportEncryption-test.js +++ b/test/utils/MegolmExportEncryption-test.js @@ -21,12 +21,6 @@ import * as MegolmExportEncryption from 'utils/MegolmExportEncryption'; import * as testUtils from '../test-utils'; import expect from 'expect'; -// polyfill textencoder if necessary -let TextEncoder = window.TextEncoder; -if (!TextEncoder) { - TextEncoder = require('utils/TextEncoderPolyfill'); -} - const TEST_VECTORS=[ [ "plain", From 9c1c657a1edf157ec6039bf12b0f8cc848bca5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Thu, 19 Jan 2017 11:55:36 +0100 Subject: [PATCH 22/22] Markdown: delete remaining pre-split relics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/Markdown.js | 2 +- src/components/views/rooms/MessageComposerInput.js | 2 +- src/components/views/rooms/MessageComposerInputOld.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Markdown.js b/src/Markdown.js index 80d1aa4335..3506e3cb59 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -59,7 +59,7 @@ export default class Markdown { return is_plain; } - toHTML(html) { + toHTML() { const real_paragraph = this.renderer.paragraph; this.renderer.paragraph = function(node, entering) { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 41b27c1394..b6af5a9f09 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -526,7 +526,7 @@ export default class MessageComposerInput extends React.Component { if (md.isPlainText()) { contentText = md.toPlaintext(); } else { - contentHTML = md.toHTML(true); + contentHTML = md.toHTML(); } } diff --git a/src/components/views/rooms/MessageComposerInputOld.js b/src/components/views/rooms/MessageComposerInputOld.js index 91abd5a2a8..ed4533737f 100644 --- a/src/components/views/rooms/MessageComposerInputOld.js +++ b/src/components/views/rooms/MessageComposerInputOld.js @@ -331,7 +331,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); } else { - const contentText = mdown.toPlaintext(false); + const contentText = mdown.toPlaintext(); sendMessagePromise = isEmote ? MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);