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;
emitter.emit('pushState', `/share/${info.id}`);
} catch (err) {
console.error(err);
state.transfer = null;
if (err.message === '0') {
//cancelled. do nothing
@ -161,23 +162,51 @@ export default function(state, emitter) {
}
state.raven.captureException(err);
metrics.stoppedUpload({ size, type, err });
emitter.emit('replaceState', '/error');
emitter.emit('pushState', '/error');
}
});
emitter.on('download', async file => {
const size = file.size;
emitter.on('password', async ({ password, file }) => {
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 receiver = new FileReceiver(url, file.key);
const receiver = new FileReceiver(url, file);
receiver.on('progress', updateProgress);
receiver.on('decrypting', render);
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();
});
emitter.on('download', async file => {
state.transfer.on('progress', render);
state.transfer.on('decrypting', render);
const links = openLinksInNewTab();
const size = file.size;
try {
const start = Date.now();
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 speed = size / (time / 1000);
await delay(1000);
@ -187,13 +216,14 @@ export default function(state, emitter) {
metrics.completedDownload({ size, time, speed });
emitter.emit('pushState', '/completed');
} catch (err) {
console.error(err);
// TODO cancelled download
const location = err.message === 'notfound' ? '/404' : '/error';
if (location === '/error') {
state.raven.captureException(err);
metrics.stoppedDownload({ size, err });
}
emitter.emit('replaceState', location);
emitter.emit('pushState', location);
} finally {
state.transfer = null;
openLinksInNewTab(links, false);

View File

@ -1,25 +1,104 @@
import Nanobus from 'nanobus';
import { hexToArray, bytes } from './utils';
import { arrayToB64, b64ToArray, bytes } from './utils';
export default class FileReceiver extends Nanobus {
constructor(url, k) {
constructor(url, file) {
super('FileReceiver');
this.key = window.crypto.subtle.importKey(
'jwk',
this.secretKeyPromise = window.crypto.subtle.importKey(
'raw',
b64ToArray(file.key),
'HKDF',
false,
['deriveKey']
);
this.encryptKeyPromise = this.secretKeyPromise.then(sk => {
const encoder = new TextEncoder();
return window.crypto.subtle.deriveKey(
{
k,
kty: 'oct',
alg: 'A128GCM',
ext: true
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('encryption'),
hash: 'SHA-256'
},
sk,
{
name: 'AES-GCM'
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.msg = 'fileSizeProgress';
this.state = 'initialized';
this.progress = [0, 1];
}
@ -38,7 +117,65 @@ export default class FileReceiver extends Nanobus {
// 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) => {
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) {
reject(new Error('notfound'));
return;
}
const blob = new Blob([this.response]);
const meta = JSON.parse(xhr.getResponseHeader('X-File-Metadata'));
if (xhr.status !== 200) {
return reject(new Error(xhr.status));
}
const blob = new Blob([xhr.response]);
const fileReader = new FileReader();
fileReader.onload = function() {
resolve({
data: this.result,
name: meta.filename,
type: meta.mimeType,
iv: meta.id
});
resolve(this.result);
};
fileReader.readAsArrayBuffer(blob);
};
xhr.open('get', this.url);
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`);
xhr.responseType = 'blob';
xhr.send();
});
}
async download() {
const key = await this.key;
const file = await this.downloadFile();
async download(nonce) {
this.state = 'downloading';
this.emit('progress', this.progress);
try {
const encryptKey = await this.encryptKeyPromise;
const authKey = await this.authKeyPromise;
const sig = await window.crypto.subtle.sign(
{
name: 'HMAC'
},
authKey,
b64ToArray(nonce)
);
const ciphertext = await this.downloadFile(new Uint8Array(sig));
this.msg = 'decryptingFile';
this.emit('decrypting');
const plaintext = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: hexToArray(file.iv),
iv: b64ToArray(this.file.iv),
tagLength: 128
},
key,
file.data
encryptKey,
ciphertext
);
this.msg = 'downloadFinish';
this.state = 'complete';
return {
plaintext,
name: decodeURIComponent(file.name),
type: file.type
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 { arrayToHex, bytes } from './utils';
import { arrayToB64, b64ToArray, bytes } from './utils';
export default class FileSender extends Nanobus {
constructor(file) {
@ -10,13 +10,13 @@ export default class FileSender extends Nanobus {
this.cancelled = false;
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
this.uploadXHR = new XMLHttpRequest();
this.key = window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt']
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
this.secretKey = window.crypto.subtle.importKey(
'raw',
this.rawSecret,
'HKDF',
false,
['deriveKey']
);
}
@ -71,14 +71,12 @@ export default class FileSender extends Nanobus {
});
}
uploadFile(encrypted, keydata) {
uploadFile(encrypted, metadata, rawAuth) {
return new Promise((resolve, reject) => {
const file = this.file;
const id = arrayToHex(this.iv);
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();
fd.append('data', blob, file.name);
fd.append('data', blob);
const xhr = this.uploadXHR;
@ -92,14 +90,18 @@ export default class FileSender extends Nanobus {
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
const nonce = xhr
.getResponseHeader('WWW-Authenticate')
.split(' ')[1];
this.progress = [1, 1];
this.msg = 'notifyUploadDone';
const responseObj = JSON.parse(xhr.responseText);
return resolve({
url: responseObj.url,
id: responseObj.id,
secretKey: keydata.k,
deleteToken: responseObj.delete
secretKey: arrayToB64(this.rawSecret),
deleteToken: responseObj.delete,
nonce
});
}
this.msg = 'errorPageHeader';
@ -110,18 +112,62 @@ export default class FileSender extends Nanobus {
xhr.open('post', '/api/upload', true);
xhr.setRequestHeader(
'X-File-Metadata',
JSON.stringify({
id: id,
filename: encodeURIComponent(file.name)
})
arrayToB64(new Uint8Array(metadata))
);
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`);
xhr.send(fd);
this.msg = 'fileSizeProgress';
});
}
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();
if (this.cancelled) {
throw new Error(0);
@ -134,13 +180,112 @@ export default class FileSender extends Nanobus {
iv: this.iv,
tagLength: 128
},
key,
encryptKey,
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) {
throw new Error(0);
}
const keydata = await window.crypto.subtle.exportKey('jwk', key);
return this.uploadFile(encrypted, keydata);
return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth));
}
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) {
return sendEvent('recipient', 'download-started', {
cm1: params.size,
@ -262,6 +271,7 @@ export {
cancelledDownload,
stoppedDownload,
completedDownload,
addedPassword,
restart,
unsupported
};

View File

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

View File

@ -2,7 +2,8 @@ const welcome = require('../templates/welcome');
const upload = require('../templates/upload');
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 welcome(state, emit);

View File

@ -92,11 +92,7 @@ class Storage {
}
getFileById(id) {
try {
return JSON.parse(this.engine.getItem(id));
} catch (e) {
return null;
}
return this._files.find(f => f.id === id);
}
get(id) {
@ -114,6 +110,10 @@ class Storage {
this._files.push(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();

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 assets = require('../../common/assets');
const notFound = require('./notFound');
const downloadPassword = require('./downloadPassword');
const { bytes } = require('../utils');
function getFileFromDOM() {
@ -8,11 +9,9 @@ function getFileFromDOM() {
if (!el) {
return null;
}
const data = el.dataset;
return {
name: data.name,
size: parseInt(data.size, 10),
ttl: parseInt(data.ttl, 10)
nonce: el.getAttribute('data-nonce'),
pwd: !!+el.getAttribute('data-requires-password')
};
}
@ -24,40 +23,47 @@ module.exports = function(state, emit) {
state.fileInfo.id = state.params.id;
state.fileInfo.key = state.params.key;
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`
<div id="page-one">
<div id="download">
<div id="download-page-one">
<div class="title">
<span id="dl-file"
data-name="${fileInfo.name}"
data-size="${fileInfo.size}"
data-ttl="${fileInfo.ttl}">${state.translate('downloadFileName', {
filename: fileInfo.name
})}</span>
<span id="dl-filesize">${' ' +
state.translate('downloadFileSize', { size })}</span>
data-nonce="${fileInfo.nonce}"
data-requires-password="${fileInfo.pwd}">${title}</span>
<span id="dl-filesize">${' ' + size}</span>
</div>
<div class="description">${state.translate('downloadMessage')}</div>
<img
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>
${action}
</div>
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
</div>
</div>
`;
function download(event) {
event.preventDefault();
emit('download', fileInfo);

View File

@ -1,6 +1,7 @@
const html = require('choo/html');
const assets = require('../../common/assets');
const notFound = require('./notFound');
const uploadPassword = require('./uploadPassword');
const { allowedCopy, delay, fadeOut } = require('../utils');
module.exports = function(state, emit) {
@ -8,25 +9,37 @@ module.exports = function(state, emit) {
if (!file) {
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`
<div id="share-link" class="fadeIn">
<div class="title">${state.translate('uploadSuccessTimingHeader')}</div>
<div id="share-window">
<div id="copy-text">${state.translate('copyUrlFormLabelWithName', {
<div id="copy-text">
${state.translate('copyUrlFormLabelWithName', {
filename: file.name
})}</div>
<div id="copy">
<input id="link" type="url" value="${file.url}" readonly="true"/>
<button id="copy-btn" class="btn" title="${state.translate(
'copyUrlFormButton'
)}" onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
<button id="copy-btn"
class="btn"
title="${state.translate('copyUrlFormButton')}"
onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
</div>
<button id="delete-file" class="btn" title="${state.translate(
'deleteFileButton'
)}" onclick=${deleteFile}>${state.translate('deleteFileButton')}</button>
<a class="send-new" data-state="completed" href="/" onclick=${sendNew}>${state.translate(
'sendAnotherFileLink'
)}</a>
${passwordSection}
<button id="delete-file"
class="btn"
title="${state.translate('deleteFileButton')}"
onclick=${deleteFile}>${state.translate('deleteFileButton')}</button>
<a class="send-new"
data-state="completed"
href="/"
onclick=${sendNew}>${state.translate('sendAnotherFileLink')}</a>
</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="description">
<div>${state.translate('uploadPageExplainer')}</div>
<a href="https://testpilot.firefox.com/experiments/send" class="link">${state.translate(
'uploadPageLearnMore'
)}</a>
<a href="https://testpilot.firefox.com/experiments/send"
class="link">${state.translate('uploadPageLearnMore')}</a>
</div>
<div class="upload-window"
ondragover=${dragover}
ondragleave=${dragleave}>
<div id="upload-img">
<img src="${assets.get('upload.svg')}"
title="${state.translate('uploadSvgAlt')}"/>
</div>
<div class="upload-window" ondragover=${dragover} ondragleave=${dragleave}>
<div id="upload-img"><img src="${assets.get(
'upload.svg'
)}" title="${state.translate('uploadSvgAlt')}"/></div>
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
<span id="file-size-msg"><em>${state.translate(
'uploadPageSizeMessage'
)}</em></span>
<form method="post" action="upload" enctype="multipart/form-data">
<input id="file-upload" type="file" name="fileUploaded" onchange=${upload} onfocus=${onfocus} onblur=${onblur} />
<label for="file-upload" id="browse" class="btn browse" title="${state.translate(
'uploadPageBrowseButton1'
)}">${state.translate('uploadPageBrowseButton1')}</label>
</form>
<span id="file-size-msg">
<em>${state.translate('uploadPageSizeMessage')}</em>
</span>
<label for="file-upload"
id="browse"
class="btn browse"
title="${state.translate('uploadPageBrowseButton1')}">
${state.translate('uploadPageBrowseButton1')}</label>
<input id="file-upload"
type="file"
name="fileUploaded"
onfocus=${onfocus}
onblur=${onblur}
onchange=${upload} />
</div>
${fileList(state, emit)}
</div>

View File

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

View File

@ -638,6 +638,23 @@ tbody {
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 {
display: flex;
@ -766,6 +783,55 @@ tbody {
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 {
right: 0;

View File

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

View File

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

View File

@ -34,6 +34,10 @@ sendAnotherFileLink = Send another file
downloadAltText = Download
downloadFileName = Download { $filename }
downloadFileSize = ({ $size })
unlockInputLabel = Enter Password
unlockInputPlaceholder = Password
unlockButtonLabel = Unlock
downloadFileTitle = Download Encrypted File
// 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.
// Text and title used on the download link/button (indicates an action).
@ -80,3 +84,6 @@ footerLinkAbout = About Test Pilot
footerLinkPrivacy = Privacy
footerLinkTerms = Terms
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 mozlog = require('../log');
const log = mozlog('send.download');
const crypto = require('crypto');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
@ -13,13 +14,24 @@ module.exports = async function(req, res) {
}
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);
}
const contentLength = await storage.length(id);
res.writeHead(200, {
'Content-Disposition': `attachment; filename=${meta.filename}`,
'Content-Disposition': 'attachment',
'Content-Type': 'application/octet-stream',
'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);

View File

@ -59,10 +59,12 @@ module.exports = function(app) {
app.get('/download/:id', pages.download);
app.get('/completed', pages.blank);
app.get('/unsupported/:reason', pages.unsupported);
app.post('/api/upload', require('./upload'));
app.get('/api/download/:id', require('./download'));
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/password/:id', require('./password'));
app.get('/__version__', function(req, res) {
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 {
const efilename = await storage.filename(id);
const name = decodeURIComponent(efilename);
const size = await storage.length(id);
const ttl = await storage.ttl(id);
const { nonce, pwd } = await storage.metadata(id);
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
res.send(
stripEvents(
routes.toString(
`/download/${req.params.id}`,
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,41 +5,29 @@ const mozlog = require('../log');
const log = mozlog('send.upload');
const validateIV = route_id => {
return route_id.match(/^[0-9a-fA-F]{24}$/) !== null;
};
module.exports = function(req, res) {
const newId = crypto.randomBytes(5).toString('hex');
let meta;
try {
meta = JSON.parse(req.header('X-File-Metadata'));
} catch (e) {
res.sendStatus(400);
return;
const metadata = req.header('X-File-Metadata');
const auth = req.header('Authorization');
if (!metadata || !auth) {
return res.sendStatus(400);
}
if (
!meta.hasOwnProperty('id') ||
!meta.hasOwnProperty('filename') ||
!validateIV(meta.id)
) {
res.sendStatus(404);
return;
}
meta.delete = crypto.randomBytes(10).toString('hex');
const meta = {
delete: crypto.randomBytes(10).toString('hex'),
metadata,
pwd: 0,
auth: auth.split(' ')[1],
nonce: crypto.randomBytes(16).toString('base64')
};
req.pipe(req.busboy);
req.busboy.on(
'file',
async (fieldname, file, filename, encoding, mimeType) => {
req.busboy.on('file', async (fieldname, file) => {
try {
meta.mimeType = mimeType || 'application/octet-stream';
await storage.set(newId, file, filename, meta);
await storage.set(newId, file, meta);
const protocol = config.env === 'production' ? 'https' : req.protocol;
const url = `${protocol}://${req.get('host')}/download/${newId}/`;
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
res.json({
url,
delete: meta.delete,
@ -52,8 +40,7 @@ module.exports = function(req, res) {
}
res.sendStatus(500);
}
}
);
});
req.on('close', async err => {
try {

View File

@ -29,7 +29,6 @@ const fileDir = config.file_dir;
if (config.s3_bucket) {
module.exports = {
filename: filename,
exists: exists,
ttl: ttl,
length: awsLength,
@ -47,7 +46,6 @@ if (config.s3_bucket) {
mkdirp.sync(config.file_dir);
log.info('fileDir', fileDir);
module.exports = {
filename: filename,
exists: exists,
ttl: ttl,
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) {
return new Promise((resolve, reject) => {
redis_client.exists(id, (rediserr, reply) => {
@ -134,7 +121,7 @@ function localGet(id) {
return fs.createReadStream(path.join(fileDir, id));
}
function localSet(newId, file, filename, meta) {
function localSet(newId, file, meta) {
return new Promise((resolve, reject) => {
const filepath = path.join(fileDir, newId);
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 = {
Bucket: config.s3_bucket,
Key: newId,

View File

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

View File

@ -3,7 +3,7 @@ const FileReceiver = window.FileReceiver;
const FakeFile = window.FakeFile;
const assert = window.assert;
const server = window.server;
const hexToArray = window.hexToArray;
const b64ToArray = window.b64ToArray;
const sinon = window.sinon;
let file;
@ -112,7 +112,7 @@ describe('File Sender', function() {
.encrypt(
{
name: 'AES-GCM',
iv: hexToArray(IV),
iv: b64ToArray(IV),
tagLength: 128
},
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() {
it('Filesize returns properly if id exists', function() {
fsStub.statSync.returns({ size: 10 });

View File

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