Merge pull request #548 from matrix-org/markjh/encrypted-attachments

Encrypt attachments in encrypted rooms
This commit is contained in:
David Baker 2016-11-11 14:49:52 +00:00 committed by GitHub
commit 1ff3a86457
7 changed files with 379 additions and 109 deletions

View file

@ -42,6 +42,7 @@
}, },
"dependencies": { "dependencies": {
"babel-runtime": "^6.11.6", "babel-runtime": "^6.11.6",
"browser-encrypt-attachment": "^0.1.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"classnames": "^2.1.2", "classnames": "^2.1.2",
"draft-js": "^0.8.1", "draft-js": "^0.8.1",

View file

@ -81,6 +81,24 @@ function infoForVideoFile(videoFile) {
return deferred.promise; return deferred.promise;
} }
/**
* Read the file as an ArrayBuffer.
* @return {Promise} A promise that resolves with an ArrayBuffer when the file
* is read.
*/
function readFileAsArrayBuffer(file) {
const deferred = q.defer();
const reader = new FileReader();
reader.onload = function(e) {
deferred.resolve(e.target.result);
};
reader.onerror = function(e) {
deferred.reject(e);
};
reader.readAsArrayBuffer(file);
return deferred.promise;
}
class ContentMessages { class ContentMessages {
constructor() { constructor() {
@ -149,7 +167,19 @@ class ContentMessages {
dis.dispatch({action: 'upload_progress', upload: upload}); dis.dispatch({action: 'upload_progress', upload: upload});
} }
}).then(function(url) { }).then(function(url) {
content.url = url; if (encryptInfo === null) {
// If the attachment isn't encrypted then include the URL directly.
content.url = url;
} else {
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and
// add it under a file key.
encryptInfo.url = url;
if (file.type) {
encryptInfo.mimetype = file.type;
}
content.file = encryptInfo;
}
return matrixClient.sendMessage(roomId, content); return matrixClient.sendMessage(roomId, content);
}, function(err) { }, function(err) {
error = err; error = err;

View file

@ -21,29 +21,67 @@ import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index'; import sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile';
export default class MAudioBody extends React.Component { export default class MAudioBody extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
playing: false playing: false,
decryptedUrl: null,
} }
} }
onPlayToggle() { onPlayToggle() {
this.setState({ this.setState({
playing: !this.state.playing playing: !this.state.playing
}); });
} }
render() { _getContentUrl() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
}
}
componentDidMount() {
var content = this.props.mxEvent.getContent(); var content = this.props.mxEvent.getContent();
var cli = MatrixClientPeg.get(); if (content.file !== undefined && this.state.decryptedUrl === null) {
decryptFile(content.file).done((url) => {
this.setState({
decryptedUrl: url
});
}, (err) => {
console.warn("Unable to decrypt attachment: ", err)
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
});
}
}
render() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
return (
<span className="mx_MAudioBody">
<img src="img/spinner.gif" ref="image"
alt={content.body} />
</span>
);
}
const contentUrl = this._getContentUrl();
return ( return (
<span className="mx_MAudioBody"> <span className="mx_MAudioBody">
<audio src={cli.mxcUrlToHttp(content.url)} controls /> <audio src={contentUrl} controls />
<MFileBody {...this.props} /> <MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} />
</span> </span>
); );
} }

View file

@ -16,15 +16,22 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
var filesize = require('filesize'); import filesize from 'filesize';
var MatrixClientPeg = require('../../../MatrixClientPeg'); import MatrixClientPeg from '../../../MatrixClientPeg';
var sdk = require('../../../index'); import sdk from '../../../index';
var dis = require("../../../dispatcher"); import {decryptFile} from '../../../utils/DecryptFile';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MFileBody', displayName: 'MFileBody',
getInitialState: function() {
return {
decryptedUrl: (this.props.decryptedUrl ? this.props.decryptedUrl : null),
};
},
presentableTextForFile: function(content) { presentableTextForFile: function(content) {
var linkText = 'Attachment'; var linkText = 'Attachment';
if (content.body && content.body.length > 0) { if (content.body && content.body.length > 0) {
@ -47,22 +54,88 @@ module.exports = React.createClass({
return linkText; return linkText;
}, },
render: function() { _getContentUrl: function() {
var content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
var cli = MatrixClientPeg.get(); if (content.file !== undefined) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
}
},
var httpUrl = cli.mxcUrlToHttp(content.url); componentDidMount: function() {
var text = this.presentableTextForFile(content); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
decryptFile(content.file).done((url) => {
this.setState({
decryptedUrl: url,
});
}, (err) => {
console.warn("Unable to decrypt attachment: ", err)
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
});
}
},
render: function() {
const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content);
var TintableSvg = sdk.getComponent("elements.TintableSvg"); var TintableSvg = sdk.getComponent("elements.TintableSvg");
if (content.file !== undefined && this.state.decryptedUrl === null) {
if (httpUrl) { // Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
return (
<span className="mx_MFileBody" ref="body">
<img src="img/spinner.gif" ref="image"
alt={content.body} />
</span>
);
}
const contentUrl = this._getContentUrl();
const fileName = content.body && content.body.length > 0 ? content.body : "Attachment";
var downloadAttr = undefined;
if (this.state.decryptedUrl) {
// If the file is encrypted then we MUST download it rather than displaying it
// because Firefox is vunerable to XSS attacks in data:// URLs
// and all browsers are vunerable to XSS attacks in blob: URLs
// created with window.URL.createObjectURL
// See https://bugzilla.mozilla.org/show_bug.cgi?id=255107
// See https://w3c.github.io/FileAPI/#originOfBlobURL
//
// This is not a problem for unencrypted links because they are
// either fetched from a different domain so are safe because of
// the same-origin policy or they are fetch from the same domain,
// in which case we trust that the homeserver will set a
// Content-Security-Policy that disables script execution.
// It is reasonable to trust the homeserver in that case since
// it is the same domain that controls this javascript.
//
// We can't apply the same workaround for encrypted files because
// we can't supply HTTP headers when the user clicks on a blob:
// or data:// uri.
//
// We should probably provide a download attribute anyway so that
// the file will have the correct name when the user tries to
// download it. We can't provide a Content-Disposition header
// like we would for HTTP.
downloadAttr = fileName;
}
if (contentUrl) {
if (this.props.tileShape === "file_grid") { if (this.props.tileShape === "file_grid") {
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
<div className="mx_MImageBody_download"> <div className="mx_MImageBody_download">
<a className="mx_ImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> <a className="mx_ImageBody_downloadLink" href={contentUrl} target="_blank" rel="noopener" download={downloadAttr}>
{ content.body && content.body.length > 0 ? content.body : "Attachment" } { fileName }
</a> </a>
<div className="mx_MImageBody_size"> <div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" } { content.info && content.info.size ? filesize(content.info.size) : "" }
@ -75,7 +148,7 @@ module.exports = React.createClass({
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
<div className="mx_MImageBody_download"> <div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> <a href={contentUrl} target="_blank" rel="noopener" download={downloadAttr}>
<TintableSvg src="img/download.svg" width="12" height="14"/> <TintableSvg src="img/download.svg" width="12" height="14"/>
Download {text} Download {text}
</a> </a>

View file

@ -16,14 +16,14 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
var filesize = require('filesize'); import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg';
var MatrixClientPeg = require('../../../MatrixClientPeg'); import ImageUtils from '../../../ImageUtils';
var ImageUtils = require('../../../ImageUtils'); import Modal from '../../../Modal';
var Modal = require('../../../Modal'); import sdk from '../../../index';
var sdk = require('../../../index'); import dis from '../../../dispatcher';
var dis = require("../../../dispatcher"); import {decryptFile} from '../../../utils/DecryptFile';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MImageBody', displayName: 'MImageBody',
@ -33,13 +33,20 @@ module.exports = React.createClass({
mxEvent: React.PropTypes.object.isRequired, mxEvent: React.PropTypes.object.isRequired,
}, },
getInitialState: function() {
return {
decryptedUrl: null,
};
},
onClick: function onClick(ev) { onClick: function onClick(ev) {
if (ev.button == 0 && !ev.metaKey) { if (ev.button == 0 && !ev.metaKey) {
ev.preventDefault(); ev.preventDefault();
var content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(content.url); const httpUrl = this._getContentUrl();
var ImageView = sdk.getComponent("elements.ImageView"); const ImageView = sdk.getComponent("elements.ImageView");
var params = { const params = {
src: httpUrl, src: httpUrl,
mxEvent: this.props.mxEvent mxEvent: this.props.mxEvent
}; };
@ -55,7 +62,7 @@ module.exports = React.createClass({
}, },
_isGif: function() { _isGif: function() {
var content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
return (content && content.info && content.info.mimetype === "image/gif"); return (content && content.info && content.info.mimetype === "image/gif");
}, },
@ -64,9 +71,7 @@ module.exports = React.createClass({
return; return;
} }
var imgElement = e.target; var imgElement = e.target;
imgElement.src = MatrixClientPeg.get().mxcUrlToHttp( imgElement.src = this._getContentUrl();
this.props.mxEvent.getContent().url
);
}, },
onImageLeave: function(e) { onImageLeave: function(e) {
@ -77,14 +82,40 @@ module.exports = React.createClass({
imgElement.src = this._getThumbUrl(); imgElement.src = this._getThumbUrl();
}, },
_getContentUrl: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
}
},
_getThumbUrl: function() { _getThumbUrl: function() {
var content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600); if (content.file !== undefined) {
// TODO: Decrypt and use the thumbnail file if one is present.
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600);
}
}, },
componentDidMount: function() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
this.fixupHeight(); this.fixupHeight();
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
decryptFile(content.file).done((url) => {
this.setState({
decryptedUrl: url,
});
}, (err) => {
console.warn("Unable to decrypt attachment: ", err)
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
});
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -103,14 +134,13 @@ module.exports = React.createClass({
return; return;
} }
var content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const timelineWidth = this.refs.body.offsetWidth;
var thumbHeight = null; const maxHeight = 600; // let images take up as much width as they can so long as the height doesn't exceed 600px.
var timelineWidth = this.refs.body.offsetWidth;
var maxHeight = 600; // let images take up as much width as they can so long as the height doesn't exceed 600px.
// the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box // the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box
//console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth); //console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
var thumbHeight = null;
if (content.info) { if (content.info) {
thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight); thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight);
} }
@ -119,45 +149,35 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
var content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
var cli = MatrixClientPeg.get();
var download; if (content.file !== undefined && this.state.decryptedUrl === null) {
if (this.props.tileShape === "file_grid") {
download = ( // Need to decrypt the attachment
<div className="mx_MImageBody_download"> // The attachment is decrypted in componentDidMount.
<a className="mx_MImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> // For now add an img tag with a spinner.
{content.body} return (
</a> <span className="mx_MImageBody" ref="body">
<div className="mx_MImageBody_size"> <img className="mx_MImageBody_thumbnail" src="img/spinner.gif" ref="image"
{ content.info && content.info.size ? filesize(content.info.size) : "" } alt={content.body} />
</div> </span>
</div>
);
}
else {
download = (
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" })
</a>
</div>
); );
} }
var thumbUrl = this._getThumbUrl(); const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl();
if (thumbUrl) { if (thumbUrl) {
return ( return (
<span className="mx_MImageBody" ref="body"> <span className="mx_MImageBody" ref="body">
<a href={cli.mxcUrlToHttp(content.url)} onClick={ this.onClick }> <a href={contentUrl} onClick={ this.onClick }>
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image" <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
alt={content.body} alt={content.body}
onMouseEnter={this.onImageEnter} onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} /> onMouseLeave={this.onImageLeave} />
</a> </a>
{ download } <MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} />
</span> </span>
); );
} else if (content.body) { } else if (content.body) {

View file

@ -16,16 +16,24 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
var filesize = require('filesize'); import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg';
var MatrixClientPeg = require('../../../MatrixClientPeg'); import Model from '../../../Modal';
var Modal = require('../../../Modal'); import sdk from '../../../index';
var sdk = require('../../../index'); import { decryptFile } from '../../../utils/DecryptFile';
import q from 'q';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MVideoBody', displayName: 'MVideoBody',
getInitialState: function() {
return {
decryptedUrl: null,
decryptedThumbnailUrl: null,
};
},
thumbScale: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { thumbScale: function(fullWidth, fullHeight, thumbWidth, thumbHeight) {
if (!fullWidth || !fullHeight) { if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
@ -48,59 +56,92 @@ module.exports = React.createClass({
} }
}, },
_getContentUrl: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
}
},
_getThumbUrl: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl;
} else if (content.info.thumbnail_url) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url);
} else {
return null;
}
},
componentDidMount: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
var thumbnailPromise = q(null);
if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file
);
}
thumbnailPromise.then((thumbnailUrl) => {
decryptFile(content.file).then((contentUrl) => {
this.setState({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
});
});
}).catch((err) => {
console.warn("Unable to decrypt attachment: ", err)
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
}).done();
}
},
render: function() { render: function() {
var content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
var cli = MatrixClientPeg.get();
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
return (
<span className="mx_MImageBody" ref="body">
<img className="mx_MImageBody_thumbnail" src="img/spinner.gif" ref="image"
alt={content.body} />
</span>
);
}
const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl();
var height = null; var height = null;
var width = null; var width = null;
var poster = null; var poster = null;
var preload = "metadata"; var preload = "metadata";
if (content.info) { if (content.info) {
var scale = this.thumbScale(content.info.w, content.info.h, 480, 360); const scale = this.thumbScale(content.info.w, content.info.h, 480, 360);
if (scale) { if (scale) {
width = Math.floor(content.info.w * scale); width = Math.floor(content.info.w * scale);
height = Math.floor(content.info.h * scale); height = Math.floor(content.info.h * scale);
} }
if (content.info.thumbnail_url) { if (thumbUrl) {
poster = cli.mxcUrlToHttp(content.info.thumbnail_url); poster = thumbUrl;
preload = "none"; preload = "none";
} }
} }
var download;
if (this.props.tileShape === "file_grid") {
download = (
<div className="mx_MImageBody_download">
<a className="mx_MImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
{content.body}
</a>
<div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div>
</div>
);
}
else {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
download = (
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" })
</a>
</div>
);
}
return ( return (
<span className="mx_MVideoBody"> <span className="mx_MVideoBody">
<video className="mx_MVideoBody" src={cli.mxcUrlToHttp(content.url)} alt={content.body} <video className="mx_MVideoBody" src={contentUrl} alt={content.body}
controls preload={preload} autoPlay={false} controls preload={preload} autoPlay={false}
height={height} width={width} poster={poster}> height={height} width={width} poster={poster}>
</video> </video>
{ download } <MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} />
</span> </span>
); );
}, },

67
src/utils/DecryptFile.js Normal file
View file

@ -0,0 +1,67 @@
/*
Copyright 2016 OpenMarket 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.
*/
// Pull in the encryption lib so that we can decrypt attachments.
import encrypt from 'browser-encrypt-attachment';
// Pull in a fetch polyfill so we can download encrypted attachments.
import 'isomorphic-fetch';
// Grab the client so that we can turn mxc:// URLs into https:// URLS.
import MatrixClientPeg from '../MatrixClientPeg';
import q from 'q';
/**
* Read blob as a data:// URI.
* @return {Promise} A promise that resolves with the data:// URI.
*/
function readBlobAsDataUri(file) {
var deferred = q.defer();
var reader = new FileReader();
reader.onload = function(e) {
deferred.resolve(e.target.result);
};
reader.onerror = function(e) {
deferred.reject(e);
};
reader.readAsDataURL(file);
return deferred.promise;
}
/**
* Decrypt a file attached to a matrix event.
* @param file {Object} The json taken from the matrix event.
* This passed to [link]{@link https://github.com/matrix-org/browser-encrypt-attachments}
* as the encryption info object, so will also have the those keys in addition to
* the keys below.
* @param file.url {string} An mxc:// URL for the encrypted file.
* @param file.mimetype {string} The MIME-type of the plaintext file.
*/
export function decryptFile(file) {
const url = MatrixClientPeg.get().mxcUrlToHttp(file.url);
// Download the encrypted file as an array buffer.
return q(fetch(url)).then(function(response) {
return response.arrayBuffer();
}).then(function(responseData) {
// Decrypt the array buffer using the information taken from
// the event content.
return encrypt.decryptAttachment(responseData, file);
}).then(function(dataArray) {
// Turn the array into a Blob and give it the correct MIME-type.
var blob = new Blob([dataArray], {type: file.mimetype});
return readBlobAsDataUri(blob);
});
}