Merge remote-tracking branch 'origin/develop' into dbkr/sanitize_chatinvitedialog
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
73
src/Modal.js
73
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>
|
||||
|
||||
84
src/async-components/views/dialogs/ExportE2eKeysDialog.js
Normal file
84
src/async-components/views/dialogs/ExportE2eKeysDialog.js
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
24
src/index.js
24
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);
|
||||
});
|
||||
};
|
||||
|
||||
319
src/utils/MegolmExportEncryption.js
Normal file
319
src/utils/MegolmExportEncryption.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user