Merge remote-tracking branch 'origin/develop' into dbkr/sanitize_chatinvitedialog

This commit is contained in:
David Baker 2017-01-19 16:26:59 +00:00
commit d8bcc1f067
18 changed files with 910 additions and 23 deletions

View file

@ -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"
},

View file

@ -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() {
@ -48,6 +50,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();
@ -57,11 +60,9 @@ export default class Markdown {
}
toHTML() {
const parser = new commonmark.Parser();
const real_paragraph = this.renderer.paragraph;
const renderer = new commonmark.HtmlRenderer({safe: true});
const real_paragraph = renderer.paragraph;
renderer.paragraph = function(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
@ -76,7 +77,48 @@ export default class Markdown {
}
}
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;
}
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 = this.parser.parse(this.input);
var rendered = this.renderer.render(parsed);
this.renderer.paragraph = real_paragraph;
return rendered;
}
}

View file

@ -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 <Component {...otherProps} />;
} else {
// show a spinner until the component is loaded.
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});
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(['<module>'], 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 = (
<div className={"mx_Dialog_wrapper " + className}>
<div className="mx_Dialog">
<Element {...props} onFinished={closeDialog}/>
<AsyncWrapper loader={loader} {...props} onFinished={closeDialog}/>
</div>
<div className="mx_Dialog_background" onClick={ closeDialog.bind(this, false) }></div>
</div>

View file

@ -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 = (
<div className="mx_Dialog_content">
<p>
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.
</p>
<p>
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.
</p>
<form onSubmit={this._onPassphraseFormSubmit}>
<div className="mx_TextInputDialog_label">
<label htmlFor="passphrase1">Enter passphrase</label>
</div>
<div>
<input ref="passphrase1" id="passphrase1"
className="mx_TextInputDialog_input"
autoFocus={true} size="64" type="password"/>
</div>
<div className="mx_Dialog_buttons">
<input className="mx_Dialog_primary" type="submit" value="Export" />
</div>
</form>
</div>
);
}
return (
<div className="mx_exportE2eKeysDialog">
<div className="mx_Dialog_title">
Export room keys
</div>
{content}
</div>
);
},
});

View file

@ -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';

View file

@ -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) {

View file

@ -605,7 +605,7 @@ module.exports = React.createClass({
<label htmlFor={id}>{this.nameForMedium(val.medium)}</label>
</div>
<div className="mx_UserSettings_profileInputCell">
<input key={val.address} id={id} value={val.address} disabled />
<input type="text" key={val.address} id={id} value={val.address} disabled />
</div>
<div className="mx_UserSettings_threepidButton">
<img src="img/icon_context_delete.svg" width="14" height="14" alt="Remove" onClick={this.onRemoveThreepidClicked.bind(this, val)} />

View file

@ -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,
}),
@ -179,6 +184,26 @@ module.exports = React.createClass({
accessToken: response.access_token
});
// Auto-join rooms
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);
MatrixClientPeg.get().joinRoom(room.id);
}
});
break;
}
}
}
if (self.props.brand) {
MatrixClientPeg.get().getPushers().done((resp)=>{
var pushers = resp.pushers;

View file

@ -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) {

View file

@ -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,
});
},

View file

@ -523,7 +523,9 @@ export default class MessageComposerInput extends React.Component {
);
} else {
const md = new Markdown(contentText);
if (!md.isPlainText()) {
if (md.isPlainText()) {
contentText = md.toPlaintext();
} else {
contentHTML = md.toHTML();
}
}

View file

@ -331,6 +331,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
}
else {
const contentText = mdown.toPlaintext();
sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);

View file

@ -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);
});
};

View file

@ -0,0 +1,319 @@
/*
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
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
let TextDecoder = window.TextDecoder;
if (!TextDecoder) {
TextDecoder = TextEncodingUtf8.TextDecoder;
}
const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle;
/**
* Decrypt a megolm key file
*
* @param {ArrayBuffer} file
* @param {String} password
* @return {Promise<String>} 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, hmac_key] = keys;
const toVerify = body.subarray(0, -32);
return subtleCrypto.verify(
{name: 'HMAC'},
hmac_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<ArrayBuffer>} 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);
// 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);
return deriveKeys(salt, kdf_rounds, password).then((keys) => {
const [aes_key, hmac_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'},
hmac_key,
toSign,
).then((hmac) => {
hmac = new Uint8Array(hmac);
resultBuffer.set(hmac, idx);
return packMegolmKeyFile(resultBuffer);
});
});
});
}
/**
* Derive the AES and HMAC-SHA-256 keys for the file
*
* @param {Unit8Array} salt salt for pbkdf
* @param {Number} iterations number of pbkdf iterations
* @param {String} password password
* @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key]
*/
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 hmac_key = keybits.slice(32);
const aes_prom = subtleCrypto.importKey(
'raw',
aes_key,
{name: 'AES-CTR'},
false,
['encrypt', 'decrypt']
);
const hmac_prom = subtleCrypto.importKey(
'raw',
hmac_key,
{
name: 'HMAC',
hash: {name: 'SHA-256'},
},
false,
['sign', 'verify']
);
return Promise.all([aes_prom, hmac_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;
}

View file

@ -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* <em>italic</em>');
});
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 <del> tags in Markdown mode', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(false);
addTextToDraft('<del>striked-out</del>');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('<del>striked-out</del>');
expect(spy.args[0][2]).toEqual('<del>striked-out</del>');
});
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.');
});
});

View file

@ -0,0 +1,116 @@
/*
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';
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() {
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);
});
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);
});
});
});

View file

@ -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))