diff --git a/package.json b/package.json
index 906417a953..e2a8fe2c35 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/Markdown.js b/src/Markdown.js
index 18c888b541..3506e3cb59 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() {
@@ -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;
     }
 }
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 <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>
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/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 = (
+                <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>
+        );
+    },
+});
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/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) {
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({
                         <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)} />
diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js
index fb24b61504..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,
         }),
 
@@ -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;
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) {
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index ef578d47db..42393ad87b 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,
         });
     },
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js
index 37d937d6f5..b6af5a9f09 100644
--- a/src/components/views/rooms/MessageComposerInput.js
+++ b/src/components/views/rooms/MessageComposerInput.js
@@ -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();
             }
         }
diff --git a/src/components/views/rooms/MessageComposerInputOld.js b/src/components/views/rooms/MessageComposerInputOld.js
index 28e3186c50..ed4533737f 100644
--- a/src/components/views/rooms/MessageComposerInputOld.js
+++ b/src/components/views/rooms/MessageComposerInputOld.js
@@ -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);
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);
+    });
+};
diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js
new file mode 100644
index 0000000000..983ec2c75f
--- /dev/null
+++ b/src/utils/MegolmExportEncryption.js
@@ -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;
+}
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* <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.');
+    });
 });
diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js
new file mode 100644
index 0000000000..28752ae529
--- /dev/null
+++ b/test/utils/MegolmExportEncryption-test.js
@@ -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);
+        });
+    });
+});
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))