Merge pull request #582 from mozilla/split

Add optional password to the download url
This commit is contained in:
Danny Coates 2017-10-10 12:42:53 -07:00 committed by GitHub
commit e56d92334f
28 changed files with 805 additions and 241 deletions

View File

@ -153,6 +153,7 @@ export default function(state, emitter) {
state.storage.totalUploads += 1; state.storage.totalUploads += 1;
emitter.emit('pushState', `/share/${info.id}`); emitter.emit('pushState', `/share/${info.id}`);
} catch (err) { } catch (err) {
console.error(err);
state.transfer = null; state.transfer = null;
if (err.message === '0') { if (err.message === '0') {
//cancelled. do nothing //cancelled. do nothing
@ -161,23 +162,51 @@ export default function(state, emitter) {
} }
state.raven.captureException(err); state.raven.captureException(err);
metrics.stoppedUpload({ size, type, err }); metrics.stoppedUpload({ size, type, err });
emitter.emit('replaceState', '/error'); emitter.emit('pushState', '/error');
} }
}); });
emitter.on('download', async file => { emitter.on('password', async ({ password, file }) => {
const size = file.size; try {
await FileSender.setPassword(password, file);
metrics.addedPassword({ size: file.size });
file.password = password;
state.storage.writeFiles();
} catch (e) {
console.error(e);
}
render();
});
emitter.on('preview', async () => {
const file = state.fileInfo;
const url = `/api/download/${file.id}`; const url = `/api/download/${file.id}`;
const receiver = new FileReceiver(url, file.key); const receiver = new FileReceiver(url, file);
receiver.on('progress', updateProgress); receiver.on('progress', updateProgress);
receiver.on('decrypting', render); receiver.on('decrypting', render);
state.transfer = receiver; state.transfer = receiver;
const links = openLinksInNewTab(); try {
await receiver.getMetadata(file.nonce);
} catch (e) {
if (e.message === '401') {
file.password = null;
if (!file.pwd) {
return emitter.emit('pushState', '/404');
}
}
}
render(); render();
});
emitter.on('download', async file => {
state.transfer.on('progress', render);
state.transfer.on('decrypting', render);
const links = openLinksInNewTab();
const size = file.size;
try { try {
const start = Date.now(); const start = Date.now();
metrics.startedDownload({ size: file.size, ttl: file.ttl }); metrics.startedDownload({ size: file.size, ttl: file.ttl });
const f = await receiver.download(); const f = await state.transfer.download(file.nonce);
const time = Date.now() - start; const time = Date.now() - start;
const speed = size / (time / 1000); const speed = size / (time / 1000);
await delay(1000); await delay(1000);
@ -187,13 +216,14 @@ export default function(state, emitter) {
metrics.completedDownload({ size, time, speed }); metrics.completedDownload({ size, time, speed });
emitter.emit('pushState', '/completed'); emitter.emit('pushState', '/completed');
} catch (err) { } catch (err) {
console.error(err);
// TODO cancelled download // TODO cancelled download
const location = err.message === 'notfound' ? '/404' : '/error'; const location = err.message === 'notfound' ? '/404' : '/error';
if (location === '/error') { if (location === '/error') {
state.raven.captureException(err); state.raven.captureException(err);
metrics.stoppedDownload({ size, err }); metrics.stoppedDownload({ size, err });
} }
emitter.emit('replaceState', location); emitter.emit('pushState', location);
} finally { } finally {
state.transfer = null; state.transfer = null;
openLinksInNewTab(links, false); openLinksInNewTab(links, false);

View File

@ -1,25 +1,104 @@
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import { hexToArray, bytes } from './utils'; import { arrayToB64, b64ToArray, bytes } from './utils';
export default class FileReceiver extends Nanobus { export default class FileReceiver extends Nanobus {
constructor(url, k) { constructor(url, file) {
super('FileReceiver'); super('FileReceiver');
this.key = window.crypto.subtle.importKey( this.secretKeyPromise = window.crypto.subtle.importKey(
'jwk', 'raw',
{ b64ToArray(file.key),
k, 'HKDF',
kty: 'oct',
alg: 'A128GCM',
ext: true
},
{
name: 'AES-GCM'
},
false, false,
['decrypt'] ['deriveKey']
); );
this.encryptKeyPromise = this.secretKeyPromise.then(sk => {
const encoder = new TextEncoder();
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('encryption'),
hash: 'SHA-256'
},
sk,
{
name: 'AES-GCM',
length: 128
},
false,
['decrypt']
);
});
if (file.pwd) {
const encoder = new TextEncoder();
console.log(file.password + file.url);
this.authKeyPromise = window.crypto.subtle
.importKey(
'raw',
encoder.encode(file.password),
{ name: 'PBKDF2' },
false,
['deriveKey']
)
.then(pwdKey =>
window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(file.url),
iterations: 100,
hash: 'SHA-256'
},
pwdKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
)
);
} else {
this.authKeyPromise = this.secretKeyPromise.then(sk => {
const encoder = new TextEncoder();
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('authentication'),
hash: 'SHA-256'
},
sk,
{
name: 'HMAC',
hash: { name: 'SHA-256' }
},
false,
['sign']
);
});
}
this.metaKeyPromise = this.secretKeyPromise.then(sk => {
const encoder = new TextEncoder();
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('metadata'),
hash: 'SHA-256'
},
sk,
{
name: 'AES-GCM',
length: 128
},
false,
['decrypt']
);
});
this.file = file;
this.url = url; this.url = url;
this.msg = 'fileSizeProgress'; this.msg = 'fileSizeProgress';
this.state = 'initialized';
this.progress = [0, 1]; this.progress = [0, 1];
} }
@ -38,7 +117,65 @@ export default class FileReceiver extends Nanobus {
// TODO // TODO
} }
downloadFile() { fetchMetadata(sig) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
this.file.nonce = nonce;
if (xhr.status === 200) {
return resolve(xhr.response);
}
reject(new Error(xhr.status));
}
};
xhr.onerror = () => reject(new Error(0));
xhr.ontimeout = () => reject(new Error(0));
xhr.open('get', `/api/metadata/${this.file.id}`);
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`);
xhr.responseType = 'json';
xhr.timeout = 2000;
xhr.send();
});
}
async getMetadata(nonce) {
try {
const authKey = await this.authKeyPromise;
const sig = await window.crypto.subtle.sign(
{
name: 'HMAC'
},
authKey,
b64ToArray(nonce)
);
const data = await this.fetchMetadata(new Uint8Array(sig));
const metaKey = await this.metaKeyPromise;
const json = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(12),
tagLength: 128
},
metaKey,
b64ToArray(data.metadata)
);
const decoder = new TextDecoder();
const meta = JSON.parse(decoder.decode(json));
this.file.name = meta.name;
this.file.type = meta.type;
this.file.iv = meta.iv;
this.file.size = data.size;
this.file.ttl = data.ttl;
this.state = 'ready';
} catch (e) {
this.state = 'invalid';
throw e;
}
}
downloadFile(sig) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@ -49,52 +186,67 @@ export default class FileReceiver extends Nanobus {
} }
}; };
xhr.onload = function(event) { xhr.onload = event => {
if (xhr.status === 404) { if (xhr.status === 404) {
reject(new Error('notfound')); reject(new Error('notfound'));
return; return;
} }
const blob = new Blob([this.response]); if (xhr.status !== 200) {
const meta = JSON.parse(xhr.getResponseHeader('X-File-Metadata')); return reject(new Error(xhr.status));
}
const blob = new Blob([xhr.response]);
const fileReader = new FileReader(); const fileReader = new FileReader();
fileReader.onload = function() { fileReader.onload = function() {
resolve({ resolve(this.result);
data: this.result,
name: meta.filename,
type: meta.mimeType,
iv: meta.id
});
}; };
fileReader.readAsArrayBuffer(blob); fileReader.readAsArrayBuffer(blob);
}; };
xhr.open('get', this.url); xhr.open('get', this.url);
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`);
xhr.responseType = 'blob'; xhr.responseType = 'blob';
xhr.send(); xhr.send();
}); });
} }
async download() { async download(nonce) {
const key = await this.key; this.state = 'downloading';
const file = await this.downloadFile(); this.emit('progress', this.progress);
this.msg = 'decryptingFile'; try {
this.emit('decrypting'); const encryptKey = await this.encryptKeyPromise;
const plaintext = await window.crypto.subtle.decrypt( const authKey = await this.authKeyPromise;
{ const sig = await window.crypto.subtle.sign(
name: 'AES-GCM', {
iv: hexToArray(file.iv), name: 'HMAC'
tagLength: 128 },
}, authKey,
key, b64ToArray(nonce)
file.data );
); const ciphertext = await this.downloadFile(new Uint8Array(sig));
this.msg = 'downloadFinish'; this.msg = 'decryptingFile';
return { this.emit('decrypting');
plaintext, const plaintext = await window.crypto.subtle.decrypt(
name: decodeURIComponent(file.name), {
type: file.type name: 'AES-GCM',
}; iv: b64ToArray(this.file.iv),
tagLength: 128
},
encryptKey,
ciphertext
);
this.msg = 'downloadFinish';
this.state = 'complete';
return {
plaintext,
name: decodeURIComponent(this.file.name),
type: this.file.type
};
} catch (e) {
this.state = 'invalid';
throw e;
}
} }
} }

View File

@ -1,5 +1,5 @@
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import { arrayToHex, bytes } from './utils'; import { arrayToB64, b64ToArray, bytes } from './utils';
export default class FileSender extends Nanobus { export default class FileSender extends Nanobus {
constructor(file) { constructor(file) {
@ -10,13 +10,13 @@ export default class FileSender extends Nanobus {
this.cancelled = false; this.cancelled = false;
this.iv = window.crypto.getRandomValues(new Uint8Array(12)); this.iv = window.crypto.getRandomValues(new Uint8Array(12));
this.uploadXHR = new XMLHttpRequest(); this.uploadXHR = new XMLHttpRequest();
this.key = window.crypto.subtle.generateKey( this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
{ this.secretKey = window.crypto.subtle.importKey(
name: 'AES-GCM', 'raw',
length: 128 this.rawSecret,
}, 'HKDF',
true, false,
['encrypt'] ['deriveKey']
); );
} }
@ -71,14 +71,12 @@ export default class FileSender extends Nanobus {
}); });
} }
uploadFile(encrypted, keydata) { uploadFile(encrypted, metadata, rawAuth) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const file = this.file;
const id = arrayToHex(this.iv);
const dataView = new DataView(encrypted); const dataView = new DataView(encrypted);
const blob = new Blob([dataView], { type: file.type }); const blob = new Blob([dataView], { type: 'application/octet-stream' });
const fd = new FormData(); const fd = new FormData();
fd.append('data', blob, file.name); fd.append('data', blob);
const xhr = this.uploadXHR; const xhr = this.uploadXHR;
@ -92,14 +90,18 @@ export default class FileSender extends Nanobus {
xhr.onreadystatechange = () => { xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) { if (xhr.status === 200) {
const nonce = xhr
.getResponseHeader('WWW-Authenticate')
.split(' ')[1];
this.progress = [1, 1]; this.progress = [1, 1];
this.msg = 'notifyUploadDone'; this.msg = 'notifyUploadDone';
const responseObj = JSON.parse(xhr.responseText); const responseObj = JSON.parse(xhr.responseText);
return resolve({ return resolve({
url: responseObj.url, url: responseObj.url,
id: responseObj.id, id: responseObj.id,
secretKey: keydata.k, secretKey: arrayToB64(this.rawSecret),
deleteToken: responseObj.delete deleteToken: responseObj.delete,
nonce
}); });
} }
this.msg = 'errorPageHeader'; this.msg = 'errorPageHeader';
@ -110,18 +112,62 @@ export default class FileSender extends Nanobus {
xhr.open('post', '/api/upload', true); xhr.open('post', '/api/upload', true);
xhr.setRequestHeader( xhr.setRequestHeader(
'X-File-Metadata', 'X-File-Metadata',
JSON.stringify({ arrayToB64(new Uint8Array(metadata))
id: id,
filename: encodeURIComponent(file.name)
})
); );
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`);
xhr.send(fd); xhr.send(fd);
this.msg = 'fileSizeProgress'; this.msg = 'fileSizeProgress';
}); });
} }
async upload() { async upload() {
const key = await this.key; const encoder = new TextEncoder();
const secretKey = await this.secretKey;
const encryptKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('encryption'),
hash: 'SHA-256'
},
secretKey,
{
name: 'AES-GCM',
length: 128
},
false,
['encrypt']
);
const authKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('authentication'),
hash: 'SHA-256'
},
secretKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
const metaKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('metadata'),
hash: 'SHA-256'
},
secretKey,
{
name: 'AES-GCM',
length: 128
},
false,
['encrypt']
);
const plaintext = await this.readFile(); const plaintext = await this.readFile();
if (this.cancelled) { if (this.cancelled) {
throw new Error(0); throw new Error(0);
@ -134,13 +180,112 @@ export default class FileSender extends Nanobus {
iv: this.iv, iv: this.iv,
tagLength: 128 tagLength: 128
}, },
key, encryptKey,
plaintext plaintext
); );
const metadata = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(12),
tagLength: 128
},
metaKey,
encoder.encode(
JSON.stringify({
iv: arrayToB64(this.iv),
name: this.file.name,
type: this.file.type
})
)
);
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
if (this.cancelled) { if (this.cancelled) {
throw new Error(0); throw new Error(0);
} }
const keydata = await window.crypto.subtle.exportKey('jwk', key); return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth));
return this.uploadFile(encrypted, keydata); }
static async setPassword(password, file) {
const encoder = new TextEncoder();
const secretKey = await window.crypto.subtle.importKey(
'raw',
b64ToArray(file.secretKey),
'HKDF',
false,
['deriveKey']
);
const authKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('authentication'),
hash: 'SHA-256'
},
secretKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
const sig = await window.crypto.subtle.sign(
{
name: 'HMAC'
},
authKey,
b64ToArray(file.nonce)
);
const pwdKey = await window.crypto.subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
const newAuthKey = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(file.url),
iterations: 100,
hash: 'SHA-256'
},
pwdKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
const rawAuth = await window.crypto.subtle.exportKey('raw', newAuthKey);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
return resolve(xhr.response);
}
if (xhr.status === 401) {
const nonce = xhr
.getResponseHeader('WWW-Authenticate')
.split(' ')[1];
file.nonce = nonce;
}
reject(new Error(xhr.status));
}
};
xhr.onerror = () => reject(new Error(0));
xhr.ontimeout = () => reject(new Error(0));
xhr.open('post', `/api/password/${file.id}`);
xhr.setRequestHeader(
'Authorization',
`send-v1 ${arrayToB64(new Uint8Array(sig))}`
);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.responseType = 'json';
xhr.timeout = 2000;
xhr.send(JSON.stringify({ auth: arrayToB64(new Uint8Array(rawAuth)) }));
});
} }
} }

View File

@ -147,6 +147,15 @@ function completedUpload(params) {
}); });
} }
function addedPassword(params) {
return sendEvent('sender', 'password-added', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads
});
}
function startedDownload(params) { function startedDownload(params) {
return sendEvent('recipient', 'download-started', { return sendEvent('recipient', 'download-started', {
cm1: params.size, cm1: params.size,
@ -262,6 +271,7 @@ export {
cancelledDownload, cancelledDownload,
stoppedDownload, stoppedDownload,
completedDownload, completedDownload,
addedPassword,
restart, restart,
unsupported unsupported
}; };

View File

@ -3,7 +3,10 @@ const download = require('../templates/download');
module.exports = function(state, emit) { module.exports = function(state, emit) {
if (state.transfer) { if (state.transfer) {
return download(state, emit); const s = state.transfer.state;
if (s === 'downloading' || s === 'complete') {
return download(state, emit);
}
} }
return preview(state, emit); return preview(state, emit);
}; };

View File

@ -2,7 +2,8 @@ const welcome = require('../templates/welcome');
const upload = require('../templates/upload'); const upload = require('../templates/upload');
module.exports = function(state, emit) { module.exports = function(state, emit) {
if (state.transfer) { if (state.transfer && state.transfer.iv) {
//TODO relying on 'iv' is gross
return upload(state, emit); return upload(state, emit);
} }
return welcome(state, emit); return welcome(state, emit);

View File

@ -92,11 +92,7 @@ class Storage {
} }
getFileById(id) { getFileById(id) {
try { return this._files.find(f => f.id === id);
return JSON.parse(this.engine.getItem(id));
} catch (e) {
return null;
}
} }
get(id) { get(id) {
@ -114,6 +110,10 @@ class Storage {
this._files.push(file); this._files.push(file);
this.engine.setItem(file.id, JSON.stringify(file)); this.engine.setItem(file.id, JSON.stringify(file));
} }
writeFiles() {
this._files.forEach(f => this.engine.setItem(f.id, JSON.stringify(f)));
}
} }
export default new Storage(); export default new Storage();

View File

@ -0,0 +1,41 @@
const html = require('choo/html');
module.exports = function(state, emit) {
const fileInfo = state.fileInfo;
const label =
fileInfo.password === null
? html`
<label class="red"
for="unlock-input">${state.translate('incorrectPassword')}</label>`
: html`
<label for="unlock-input">
${state.translate('unlockInputLabel')}
</label>`;
const div = html`
<div class="enterPassword">
${label}
<form id="unlock" onsubmit=${checkPassword}>
<input id="unlock-input"
autocomplete="off"
placeholder="${state.translate('unlockInputPlaceholder')}"
type="password"/>
<input type="submit"
id="unlock-btn"
class="btn"
value="${state.translate('unlockButtonLabel')}"/>
</form>
</div>`;
function checkPassword(event) {
event.preventDefault();
const password = document.getElementById('unlock-input').value;
if (password.length > 0) {
document.getElementById('unlock-btn').disabled = true;
state.fileInfo.url = window.location.href;
state.fileInfo.password = password;
emit('preview');
}
}
return div;
};

View File

@ -1,6 +1,7 @@
const html = require('choo/html'); const html = require('choo/html');
const assets = require('../../common/assets'); const assets = require('../../common/assets');
const notFound = require('./notFound'); const notFound = require('./notFound');
const downloadPassword = require('./downloadPassword');
const { bytes } = require('../utils'); const { bytes } = require('../utils');
function getFileFromDOM() { function getFileFromDOM() {
@ -8,11 +9,9 @@ function getFileFromDOM() {
if (!el) { if (!el) {
return null; return null;
} }
const data = el.dataset;
return { return {
name: data.name, nonce: el.getAttribute('data-nonce'),
size: parseInt(data.size, 10), pwd: !!+el.getAttribute('data-requires-password')
ttl: parseInt(data.ttl, 10)
}; };
} }
@ -24,40 +23,47 @@ module.exports = function(state, emit) {
state.fileInfo.id = state.params.id; state.fileInfo.id = state.params.id;
state.fileInfo.key = state.params.key; state.fileInfo.key = state.params.key;
const fileInfo = state.fileInfo; const fileInfo = state.fileInfo;
const size = bytes(fileInfo.size); const size = fileInfo.size
? state.translate('downloadFileSize', { size: bytes(fileInfo.size) })
: '';
let action = html`
<div>
<img src="${assets.get('illustration_download.svg')}"
id="download-img"
alt="${state.translate('downloadAltText')}"/>
<div>
<button id="download-btn"
class="btn"
onclick=${download}>${state.translate('downloadButtonLabel')}
</button>
</div>
</div>`;
if (fileInfo.pwd && !fileInfo.password) {
action = downloadPassword(state, emit);
} else if (!state.transfer) {
emit('preview');
}
const title = fileInfo.name
? state.translate('downloadFileName', { filename: fileInfo.name })
: state.translate('downloadFileTitle');
const div = html` const div = html`
<div id="page-one"> <div id="page-one">
<div id="download"> <div id="download">
<div id="download-page-one"> <div id="download-page-one">
<div class="title"> <div class="title">
<span id="dl-file" <span id="dl-file"
data-name="${fileInfo.name}" data-nonce="${fileInfo.nonce}"
data-size="${fileInfo.size}" data-requires-password="${fileInfo.pwd}">${title}</span>
data-ttl="${fileInfo.ttl}">${state.translate('downloadFileName', { <span id="dl-filesize">${' ' + size}</span>
filename: fileInfo.name
})}</span>
<span id="dl-filesize">${' ' +
state.translate('downloadFileSize', { size })}</span>
</div> </div>
<div class="description">${state.translate('downloadMessage')}</div> <div class="description">${state.translate('downloadMessage')}</div>
<img ${action}
src="${assets.get('illustration_download.svg')}"
id="download-img"
alt="${state.translate('downloadAltText')}"/>
<div>
<button
id="download-btn"
class="btn"
title="${state.translate('downloadButtonLabel')}"
onclick=${download}>${state.translate(
'downloadButtonLabel'
)}</button>
</div>
</div> </div>
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a> <a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
</div> </div>
</div> </div>
`; `;
function download(event) { function download(event) {
event.preventDefault(); event.preventDefault();
emit('download', fileInfo); emit('download', fileInfo);

View File

@ -1,6 +1,7 @@
const html = require('choo/html'); const html = require('choo/html');
const assets = require('../../common/assets'); const assets = require('../../common/assets');
const notFound = require('./notFound'); const notFound = require('./notFound');
const uploadPassword = require('./uploadPassword');
const { allowedCopy, delay, fadeOut } = require('../utils'); const { allowedCopy, delay, fadeOut } = require('../utils');
module.exports = function(state, emit) { module.exports = function(state, emit) {
@ -8,25 +9,37 @@ module.exports = function(state, emit) {
if (!file) { if (!file) {
return notFound(state, emit); return notFound(state, emit);
} }
const passwordComplete = html`
<div class="selectPassword">
Password: ${file.password}
</div>`;
const passwordSection = file.password
? passwordComplete
: uploadPassword(state, emit);
const div = html` const div = html`
<div id="share-link" class="fadeIn"> <div id="share-link" class="fadeIn">
<div class="title">${state.translate('uploadSuccessTimingHeader')}</div> <div class="title">${state.translate('uploadSuccessTimingHeader')}</div>
<div id="share-window"> <div id="share-window">
<div id="copy-text">${state.translate('copyUrlFormLabelWithName', { <div id="copy-text">
filename: file.name ${state.translate('copyUrlFormLabelWithName', {
})}</div> filename: file.name
})}</div>
<div id="copy"> <div id="copy">
<input id="link" type="url" value="${file.url}" readonly="true"/> <input id="link" type="url" value="${file.url}" readonly="true"/>
<button id="copy-btn" class="btn" title="${state.translate( <button id="copy-btn"
'copyUrlFormButton' class="btn"
)}" onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button> title="${state.translate('copyUrlFormButton')}"
onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
</div> </div>
<button id="delete-file" class="btn" title="${state.translate( ${passwordSection}
'deleteFileButton' <button id="delete-file"
)}" onclick=${deleteFile}>${state.translate('deleteFileButton')}</button> class="btn"
<a class="send-new" data-state="completed" href="/" onclick=${sendNew}>${state.translate( title="${state.translate('deleteFileButton')}"
'sendAnotherFileLink' onclick=${deleteFile}>${state.translate('deleteFileButton')}</button>
)}</a> <a class="send-new"
data-state="completed"
href="/"
onclick=${sendNew}>${state.translate('sendAnotherFileLink')}</a>
</div> </div>
</div> </div>
`; `;

View File

@ -0,0 +1,37 @@
const html = require('choo/html');
module.exports = function(state, emit) {
const file = state.storage.getFileById(state.params.id);
const div = html`
<div class="selectPassword">
<div>
<input id="addPassword" type="checkbox" onchange=${togglePasswordInput}/>
<label for="addPassword">${state.translate(
'requirePasswordCheckbox'
)}</label>
</div>
<form class="setPassword hidden" onsubmit=${setPassword}>
<input id="unlock-input"
autocomplete="off"
placeholder="${state.translate('unlockInputPlaceholder')}"/>
<input type="submit"
id="unlock-btn"
class="btn"
value="${state.translate('addPasswordButton')}"/>
</form>
</div>`;
function togglePasswordInput() {
document.querySelector('.setPassword').classList.toggle('hidden');
}
function setPassword(event) {
event.preventDefault();
const password = document.getElementById('unlock-input').value;
if (password.length > 0) {
emit('password', { password, file });
}
}
return div;
};

View File

@ -9,24 +9,31 @@ module.exports = function(state, emit) {
<div class="title">${state.translate('uploadPageHeader')}</div> <div class="title">${state.translate('uploadPageHeader')}</div>
<div class="description"> <div class="description">
<div>${state.translate('uploadPageExplainer')}</div> <div>${state.translate('uploadPageExplainer')}</div>
<a href="https://testpilot.firefox.com/experiments/send" class="link">${state.translate( <a href="https://testpilot.firefox.com/experiments/send"
'uploadPageLearnMore' class="link">${state.translate('uploadPageLearnMore')}</a>
)}</a>
</div> </div>
<div class="upload-window" ondragover=${dragover} ondragleave=${dragleave}> <div class="upload-window"
<div id="upload-img"><img src="${assets.get( ondragover=${dragover}
'upload.svg' ondragleave=${dragleave}>
)}" title="${state.translate('uploadSvgAlt')}"/></div> <div id="upload-img">
<img src="${assets.get('upload.svg')}"
title="${state.translate('uploadSvgAlt')}"/>
</div>
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div> <div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
<span id="file-size-msg"><em>${state.translate( <span id="file-size-msg">
'uploadPageSizeMessage' <em>${state.translate('uploadPageSizeMessage')}</em>
)}</em></span> </span>
<form method="post" action="upload" enctype="multipart/form-data"> <label for="file-upload"
<input id="file-upload" type="file" name="fileUploaded" onchange=${upload} onfocus=${onfocus} onblur=${onblur} /> id="browse"
<label for="file-upload" id="browse" class="btn browse" title="${state.translate( class="btn browse"
'uploadPageBrowseButton1' title="${state.translate('uploadPageBrowseButton1')}">
)}">${state.translate('uploadPageBrowseButton1')}</label> ${state.translate('uploadPageBrowseButton1')}</label>
</form> <input id="file-upload"
type="file"
name="fileUploaded"
onfocus=${onfocus}
onblur=${onblur}
onchange=${upload} />
</div> </div>
${fileList(state, emit)} ${fileList(state, emit)}
</div> </div>

View File

@ -1,23 +1,18 @@
function arrayToHex(iv) { const b64 = require('base64-js');
let hexStr = '';
// eslint-disable-next-line prefer-const function arrayToB64(array) {
for (let i in iv) { return b64
if (iv[i] < 16) { .fromByteArray(array)
hexStr += '0' + iv[i].toString(16); .replace(/\+/g, '-')
} else { .replace(/\//g, '_')
hexStr += iv[i].toString(16); .replace(/=/g, '');
}
}
return hexStr;
} }
function hexToArray(str) { function b64ToArray(str) {
const iv = new Uint8Array(str.length / 2); str = (str + '==='.slice((str.length + 3) % 4))
for (let i = 0; i < str.length; i += 2) { .replace(/-/g, '+')
iv[i / 2] = parseInt(str.charAt(i) + str.charAt(i + 1), 16); .replace(/_/g, '/');
} return b64.toByteArray(str);
return iv;
} }
function notify(str) { function notify(str) {
@ -105,6 +100,9 @@ const LOCALIZE_NUMBERS = !!(
const UNITS = ['B', 'kB', 'MB', 'GB']; const UNITS = ['B', 'kB', 'MB', 'GB'];
function bytes(num) { function bytes(num) {
if (num < 1) {
return '0B';
}
const exponent = Math.min(Math.floor(Math.log10(num) / 3), UNITS.length - 1); const exponent = Math.min(Math.floor(Math.log10(num) / 3), UNITS.length - 1);
const n = Number(num / Math.pow(1000, exponent)); const n = Number(num / Math.pow(1000, exponent));
const nStr = LOCALIZE_NUMBERS const nStr = LOCALIZE_NUMBERS
@ -147,8 +145,8 @@ module.exports = {
bytes, bytes,
percent, percent,
copyToClipboard, copyToClipboard,
arrayToHex, arrayToB64,
hexToArray, b64ToArray,
notify, notify,
canHasSend, canHasSend,
isFile, isFile,

View File

@ -638,6 +638,23 @@ tbody {
color: #0287e8; color: #0287e8;
} }
.hidden {
visibility: hidden;
}
.selectPassword {
padding: 10px 0;
align-self: left;
}
.setPassword {
align-self: left;
display: flex;
flex-wrap: nowrap;
width: 80%;
padding: 10px 20px;
}
/* upload-error */ /* upload-error */
#upload-error { #upload-error {
display: flex; display: flex;
@ -766,6 +783,55 @@ tbody {
height: 196px; height: 196px;
} }
.enterPassword {
text-align: left;
padding: 40px;
}
.red {
color: red;
}
#unlock {
display: flex;
flex-wrap: nowrap;
width: 100%;
padding: 10px 0;
}
#unlock-input {
flex: 1;
height: 46px;
border: 1px solid #0297f8;
border-radius: 6px 0 0 6px;
font-size: 20px;
color: #737373;
font-family: 'SF Pro Text', sans-serif;
letter-spacing: 0;
line-height: 23px;
font-weight: 300;
padding-left: 10px;
padding-right: 10px;
}
#unlock-btn {
flex: 0 1 165px;
background: #0297f8;
border-radius: 0 6px 6px 0;
border: 1px solid #0297f8;
color: white;
cursor: pointer;
font-size: 15px;
height: 50px;
padding-left: 10px;
padding-right: 10px;
white-space: nowrap;
}
#unlock-btn:hover {
background-color: #0287e8;
}
/* footer */ /* footer */
.footer { .footer {
right: 0; right: 0;

View File

@ -67,6 +67,14 @@ Triggered whenever a user stops uploading a file. Includes:
- `cd2` - `cd2`
- `cd6` - `cd6`
#### `password-added`
Triggered whenever a password is added to a file. Includes:
- `cm1`
- `cm5`
- `cm6`
- `cm7`
#### `download-started` #### `download-started`
Triggered whenever a user begins downloading a file. Includes: Triggered whenever a user begins downloading a file. Includes:

View File

@ -51,6 +51,7 @@
"babel-preset-env": "^1.6.0", "babel-preset-env": "^1.6.0",
"babel-preset-es2015": "^6.24.1", "babel-preset-es2015": "^6.24.1",
"babel-preset-stage-2": "^6.24.1", "babel-preset-stage-2": "^6.24.1",
"base64-js": "^1.2.1",
"copy-webpack-plugin": "^4.1.1", "copy-webpack-plugin": "^4.1.1",
"cross-env": "^5.0.5", "cross-env": "^5.0.5",
"css-loader": "^0.28.7", "css-loader": "^0.28.7",

View File

@ -34,6 +34,10 @@ sendAnotherFileLink = Send another file
downloadAltText = Download downloadAltText = Download
downloadFileName = Download { $filename } downloadFileName = Download { $filename }
downloadFileSize = ({ $size }) downloadFileSize = ({ $size })
unlockInputLabel = Enter Password
unlockInputPlaceholder = Password
unlockButtonLabel = Unlock
downloadFileTitle = Download Encrypted File
// Firefox Send is a brand name and should not be localized. // Firefox Send is a brand name and should not be localized.
downloadMessage = Your friend is sending you a file with Firefox Send, a service that allows you to share files with a safe, private, and encrypted link that automatically expires to ensure your stuff does not remain online forever. downloadMessage = Your friend is sending you a file with Firefox Send, a service that allows you to share files with a safe, private, and encrypted link that automatically expires to ensure your stuff does not remain online forever.
// Text and title used on the download link/button (indicates an action). // Text and title used on the download link/button (indicates an action).
@ -80,3 +84,6 @@ footerLinkAbout = About Test Pilot
footerLinkPrivacy = Privacy footerLinkPrivacy = Privacy
footerLinkTerms = Terms footerLinkTerms = Terms
footerLinkCookies = Cookies footerLinkCookies = Cookies
requirePasswordCheckbox = Require a password to download this file
addPasswordButton = Add Password
incorrectPassword = Incorrect password. Try again?

View File

@ -1,6 +1,7 @@
const storage = require('../storage'); const storage = require('../storage');
const mozlog = require('../log'); const mozlog = require('../log');
const log = mozlog('send.download'); const log = mozlog('send.download');
const crypto = require('crypto');
function validateID(route_id) { function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null; return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
@ -13,13 +14,24 @@ module.exports = async function(req, res) {
} }
try { try {
const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id); const meta = await storage.metadata(id);
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
return res.sendStatus(401);
}
const contentLength = await storage.length(id); const contentLength = await storage.length(id);
res.writeHead(200, { res.writeHead(200, {
'Content-Disposition': `attachment; filename=${meta.filename}`, 'Content-Disposition': 'attachment',
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'Content-Length': contentLength, 'Content-Length': contentLength,
'X-File-Metadata': JSON.stringify(meta) 'X-File-Metadata': meta.metadata,
'WWW-Authenticate': `send-v1 ${nonce}`
}); });
const file_stream = storage.get(id); const file_stream = storage.get(id);

View File

@ -59,10 +59,12 @@ module.exports = function(app) {
app.get('/download/:id', pages.download); app.get('/download/:id', pages.download);
app.get('/completed', pages.blank); app.get('/completed', pages.blank);
app.get('/unsupported/:reason', pages.unsupported); app.get('/unsupported/:reason', pages.unsupported);
app.post('/api/upload', require('./upload'));
app.get('/api/download/:id', require('./download')); app.get('/api/download/:id', require('./download'));
app.get('/api/exists/:id', require('./exists')); app.get('/api/exists/:id', require('./exists'));
app.get('/api/metadata/:id', require('./metadata'));
app.post('/api/upload', require('./upload'));
app.post('/api/delete/:id', require('./delete')); app.post('/api/delete/:id', require('./delete'));
app.post('/api/password/:id', require('./password'));
app.get('/__version__', function(req, res) { app.get('/__version__', function(req, res) {
res.sendFile(require.resolve('../../dist/version.json')); res.sendFile(require.resolve('../../dist/version.json'));

36
server/routes/metadata.js Normal file
View File

@ -0,0 +1,36 @@
const storage = require('../storage');
const crypto = require('crypto');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
}
module.exports = async function(req, res) {
const id = req.params.id;
if (!validateID(id)) {
return res.sendStatus(404);
}
try {
const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id);
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
return res.sendStatus(401);
}
const size = await storage.length(id);
const ttl = await storage.ttl(id);
res.send({
metadata: meta.metadata,
size,
ttl
});
} catch (e) {
res.sendStatus(404);
}
};

View File

@ -28,16 +28,14 @@ module.exports = {
} }
try { try {
const efilename = await storage.filename(id); const { nonce, pwd } = await storage.metadata(id);
const name = decodeURIComponent(efilename); res.set('WWW-Authenticate', `send-v1 ${nonce}`);
const size = await storage.length(id);
const ttl = await storage.ttl(id);
res.send( res.send(
stripEvents( stripEvents(
routes.toString( routes.toString(
`/download/${req.params.id}`, `/download/${req.params.id}`,
Object.assign(state(req), { Object.assign(state(req), {
fileInfo: { name, size, ttl } fileInfo: { nonce, pwd: +pwd }
}) })
) )
) )

35
server/routes/password.js Normal file
View File

@ -0,0 +1,35 @@
const storage = require('../storage');
const crypto = require('crypto');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
}
module.exports = async function(req, res) {
const id = req.params.id;
if (!validateID(id)) {
return res.sendStatus(404);
}
if (!req.body.auth) {
return res.sendStatus(400);
}
try {
const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id);
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
return res.sendStatus(401);
}
} catch (e) {
res.sendStatus(404);
}
storage.setField(id, 'auth', req.body.auth);
storage.setField(id, 'pwd', 1);
res.sendStatus(200);
};

View File

@ -5,55 +5,42 @@ const mozlog = require('../log');
const log = mozlog('send.upload'); const log = mozlog('send.upload');
const validateIV = route_id => {
return route_id.match(/^[0-9a-fA-F]{24}$/) !== null;
};
module.exports = function(req, res) { module.exports = function(req, res) {
const newId = crypto.randomBytes(5).toString('hex'); const newId = crypto.randomBytes(5).toString('hex');
let meta; const metadata = req.header('X-File-Metadata');
const auth = req.header('Authorization');
try { if (!metadata || !auth) {
meta = JSON.parse(req.header('X-File-Metadata')); return res.sendStatus(400);
} catch (e) {
res.sendStatus(400);
return;
} }
if ( const meta = {
!meta.hasOwnProperty('id') || delete: crypto.randomBytes(10).toString('hex'),
!meta.hasOwnProperty('filename') || metadata,
!validateIV(meta.id) pwd: 0,
) { auth: auth.split(' ')[1],
res.sendStatus(404); nonce: crypto.randomBytes(16).toString('base64')
return; };
}
meta.delete = crypto.randomBytes(10).toString('hex');
req.pipe(req.busboy); req.pipe(req.busboy);
req.busboy.on( req.busboy.on('file', async (fieldname, file) => {
'file', try {
async (fieldname, file, filename, encoding, mimeType) => { await storage.set(newId, file, meta);
try { const protocol = config.env === 'production' ? 'https' : req.protocol;
meta.mimeType = mimeType || 'application/octet-stream'; const url = `${protocol}://${req.get('host')}/download/${newId}/`;
await storage.set(newId, file, filename, meta); res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
const protocol = config.env === 'production' ? 'https' : req.protocol; res.json({
const url = `${protocol}://${req.get('host')}/download/${newId}/`; url,
res.json({ delete: meta.delete,
url, id: newId
delete: meta.delete, });
id: newId } catch (e) {
}); log.error('upload', e);
} catch (e) { if (e.message === 'limit') {
log.error('upload', e); return res.sendStatus(413);
if (e.message === 'limit') {
return res.sendStatus(413);
}
res.sendStatus(500);
} }
res.sendStatus(500);
} }
); });
req.on('close', async err => { req.on('close', async err => {
try { try {

View File

@ -29,7 +29,6 @@ const fileDir = config.file_dir;
if (config.s3_bucket) { if (config.s3_bucket) {
module.exports = { module.exports = {
filename: filename,
exists: exists, exists: exists,
ttl: ttl, ttl: ttl,
length: awsLength, length: awsLength,
@ -47,7 +46,6 @@ if (config.s3_bucket) {
mkdirp.sync(config.file_dir); mkdirp.sync(config.file_dir);
log.info('fileDir', fileDir); log.info('fileDir', fileDir);
module.exports = { module.exports = {
filename: filename,
exists: exists, exists: exists,
ttl: ttl, ttl: ttl,
length: localLength, length: localLength,
@ -93,17 +91,6 @@ function ttl(id) {
}); });
} }
function filename(id) {
return new Promise((resolve, reject) => {
redis_client.hget(id, 'filename', (err, reply) => {
if (err || !reply) {
return reject();
}
resolve(reply);
});
});
}
function exists(id) { function exists(id) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
redis_client.exists(id, (rediserr, reply) => { redis_client.exists(id, (rediserr, reply) => {
@ -134,7 +121,7 @@ function localGet(id) {
return fs.createReadStream(path.join(fileDir, id)); return fs.createReadStream(path.join(fileDir, id));
} }
function localSet(newId, file, filename, meta) { function localSet(newId, file, meta) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const filepath = path.join(fileDir, newId); const filepath = path.join(fileDir, newId);
const fstream = fs.createWriteStream(filepath); const fstream = fs.createWriteStream(filepath);
@ -216,7 +203,7 @@ function awsGet(id) {
} }
} }
function awsSet(newId, file, filename, meta) { function awsSet(newId, file, meta) {
const params = { const params = {
Bucket: config.s3_bucket, Bucket: config.s3_bucket,
Key: newId, Key: newId,

View File

@ -18,5 +18,5 @@ window.sinon = require('sinon');
window.server = window.sinon.fakeServer.create(); window.server = window.sinon.fakeServer.create();
window.assert = require('assert'); window.assert = require('assert');
const utils = require('../../app/utils'); const utils = require('../../app/utils');
window.hexToArray = utils.hexToArray; window.b64ToArray = utils.b64ToArray;
window.arrayToHex = utils.arrayToHex; window.arrayToB64 = utils.arrayToB64;

View File

@ -3,7 +3,7 @@ const FileReceiver = window.FileReceiver;
const FakeFile = window.FakeFile; const FakeFile = window.FakeFile;
const assert = window.assert; const assert = window.assert;
const server = window.server; const server = window.server;
const hexToArray = window.hexToArray; const b64ToArray = window.b64ToArray;
const sinon = window.sinon; const sinon = window.sinon;
let file; let file;
@ -112,7 +112,7 @@ describe('File Sender', function() {
.encrypt( .encrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
iv: hexToArray(IV), iv: b64ToArray(IV),
tagLength: 128 tagLength: 128
}, },
cryptoKey, cryptoKey,

View File

@ -56,24 +56,6 @@ describe('Testing Exists from local filesystem', function() {
}); });
}); });
describe('Testing Filename from local filesystem', function() {
it('Filename returns properly if id exists', function() {
hget.callsArgWith(2, null, 'Filename.moz');
return storage
.filename('test')
.then(_reply => assert(1))
.catch(err => assert.fail());
});
it('Filename fails if id does not exist', function() {
hget.callsArgWith(2, null, 'Filename.moz');
return storage
.filename('test')
.then(_reply => assert.fail())
.catch(err => assert(1));
});
});
describe('Testing Length from local filesystem', function() { describe('Testing Length from local filesystem', function() {
it('Filesize returns properly if id exists', function() { it('Filesize returns properly if id exists', function() {
fsStub.statSync.returns({ size: 10 }); fsStub.statSync.returns({ size: 10 });

View File

@ -143,6 +143,6 @@ module.exports = {
], ],
devServer: { devServer: {
compress: true, compress: true,
setup: IS_DEV ? require('./server/dev') : undefined before: IS_DEV ? require('./server/dev') : undefined
} }
}; };