diff --git a/app/fileManager.js b/app/fileManager.js
index 969c5d1e..63539c98 100644
--- a/app/fileManager.js
+++ b/app/fileManager.js
@@ -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);
diff --git a/app/fileReceiver.js b/app/fileReceiver.js
index 5af6c34c..281215ab 100644
--- a/app/fileReceiver.js
+++ b/app/fileReceiver.js
@@ -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',
- {
- k,
- kty: 'oct',
- alg: 'A128GCM',
- ext: true
- },
- {
- name: 'AES-GCM'
- },
+ this.secretKeyPromise = window.crypto.subtle.importKey(
+ 'raw',
+ b64ToArray(file.key),
+ 'HKDF',
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.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();
- this.msg = 'decryptingFile';
- this.emit('decrypting');
- const plaintext = await window.crypto.subtle.decrypt(
- {
- name: 'AES-GCM',
- iv: hexToArray(file.iv),
- tagLength: 128
- },
- key,
- file.data
- );
- this.msg = 'downloadFinish';
- return {
- plaintext,
- name: decodeURIComponent(file.name),
- type: file.type
- };
+ 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: 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;
+ }
}
}
diff --git a/app/fileSender.js b/app/fileSender.js
index 37a5add1..51985022 100644
--- a/app/fileSender.js
+++ b/app/fileSender.js
@@ -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)) }));
+ });
}
}
diff --git a/app/metrics.js b/app/metrics.js
index ecb2c54d..186b7c93 100644
--- a/app/metrics.js
+++ b/app/metrics.js
@@ -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
};
diff --git a/app/routes/download.js b/app/routes/download.js
index 0b935028..c0a47a4c 100644
--- a/app/routes/download.js
+++ b/app/routes/download.js
@@ -3,7 +3,10 @@ const download = require('../templates/download');
module.exports = function(state, emit) {
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);
};
diff --git a/app/routes/home.js b/app/routes/home.js
index 2be53047..0059ceb0 100644
--- a/app/routes/home.js
+++ b/app/routes/home.js
@@ -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);
diff --git a/app/storage.js b/app/storage.js
index 209d6237..27cba1cb 100644
--- a/app/storage.js
+++ b/app/storage.js
@@ -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();
diff --git a/app/templates/downloadPassword.js b/app/templates/downloadPassword.js
new file mode 100644
index 00000000..92ded723
--- /dev/null
+++ b/app/templates/downloadPassword.js
@@ -0,0 +1,41 @@
+const html = require('choo/html');
+
+module.exports = function(state, emit) {
+ const fileInfo = state.fileInfo;
+ const label =
+ fileInfo.password === null
+ ? html`
+ `
+ : html`
+ `;
+ const div = html`
+
+ ${label}
+
+
`;
+
+ 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;
+};
diff --git a/app/templates/preview.js b/app/templates/preview.js
index b623a3b4..93129d1b 100644
--- a/app/templates/preview.js
+++ b/app/templates/preview.js
@@ -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`
+
+
+
+
+
+
`;
+ 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`
${state.translate('downloadFileName', {
- filename: fileInfo.name
- })}
- ${' ' +
- state.translate('downloadFileSize', { size })}
+ data-nonce="${fileInfo.nonce}"
+ data-requires-password="${fileInfo.pwd}">${title}
+ ${' ' + size}
${state.translate('downloadMessage')}
-
-
-
-
+ ${action}
${state.translate('sendYourFilesLink')}
`;
+
function download(event) {
event.preventDefault();
emit('download', fileInfo);
diff --git a/app/templates/share.js b/app/templates/share.js
index 8c9b3c3c..2fe1a741 100644
--- a/app/templates/share.js
+++ b/app/templates/share.js
@@ -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`
+
+ Password: ${file.password}
+
`;
+ const passwordSection = file.password
+ ? passwordComplete
+ : uploadPassword(state, emit);
const div = html`
${state.translate('uploadSuccessTimingHeader')}
`;
diff --git a/app/templates/uploadPassword.js b/app/templates/uploadPassword.js
new file mode 100644
index 00000000..fe589aff
--- /dev/null
+++ b/app/templates/uploadPassword.js
@@ -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`
+
+
+
+
+
+
+
`;
+
+ 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;
+};
diff --git a/app/templates/welcome.js b/app/templates/welcome.js
index cea2e7c9..03275af0 100644
--- a/app/templates/welcome.js
+++ b/app/templates/welcome.js
@@ -9,24 +9,31 @@ module.exports = function(state, emit) {
${state.translate('uploadPageHeader')}
-
-
+
+
+
+
${state.translate('uploadPageDropMessage')}
-
${state.translate(
- 'uploadPageSizeMessage'
- )}
-
+
+ ${state.translate('uploadPageSizeMessage')}
+
+
+
${fileList(state, emit)}
diff --git a/app/utils.js b/app/utils.js
index 61bf742a..9103cfc8 100644
--- a/app/utils.js
+++ b/app/utils.js
@@ -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,
diff --git a/assets/main.css b/assets/main.css
index d9d94190..69c5f9e6 100644
--- a/assets/main.css
+++ b/assets/main.css
@@ -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;
diff --git a/docs/metrics.md b/docs/metrics.md
index 98d2abf6..9006b347 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -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:
diff --git a/package.json b/package.json
index 724704e9..12a1626a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/locales/en-US/send.ftl b/public/locales/en-US/send.ftl
index 477ceac5..74548bc1 100644
--- a/public/locales/en-US/send.ftl
+++ b/public/locales/en-US/send.ftl
@@ -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?
diff --git a/server/routes/download.js b/server/routes/download.js
index f574b850..4a77febf 100644
--- a/server/routes/download.js
+++ b/server/routes/download.js
@@ -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);
diff --git a/server/routes/index.js b/server/routes/index.js
index d08194c4..69479dd7 100644
--- a/server/routes/index.js
+++ b/server/routes/index.js
@@ -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'));
diff --git a/server/routes/metadata.js b/server/routes/metadata.js
new file mode 100644
index 00000000..0c774821
--- /dev/null
+++ b/server/routes/metadata.js
@@ -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);
+ }
+};
diff --git a/server/routes/pages.js b/server/routes/pages.js
index 5266d19a..d0385fbf 100644
--- a/server/routes/pages.js
+++ b/server/routes/pages.js
@@ -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 }
})
)
)
diff --git a/server/routes/password.js b/server/routes/password.js
new file mode 100644
index 00000000..02be3d25
--- /dev/null
+++ b/server/routes/password.js
@@ -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);
+};
diff --git a/server/routes/upload.js b/server/routes/upload.js
index 28d6112e..d23e8086 100644
--- a/server/routes/upload.js
+++ b/server/routes/upload.js
@@ -5,55 +5,42 @@ 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) => {
- try {
- meta.mimeType = mimeType || 'application/octet-stream';
- await storage.set(newId, file, filename, meta);
- const protocol = config.env === 'production' ? 'https' : req.protocol;
- const url = `${protocol}://${req.get('host')}/download/${newId}/`;
- res.json({
- url,
- delete: meta.delete,
- id: newId
- });
- } catch (e) {
- log.error('upload', e);
- if (e.message === 'limit') {
- return res.sendStatus(413);
- }
- res.sendStatus(500);
+ req.busboy.on('file', async (fieldname, file) => {
+ try {
+ 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,
+ id: newId
+ });
+ } catch (e) {
+ log.error('upload', e);
+ if (e.message === 'limit') {
+ return res.sendStatus(413);
}
+ res.sendStatus(500);
}
- );
+ });
req.on('close', async err => {
try {
diff --git a/server/storage.js b/server/storage.js
index e13a6f92..13bfeb47 100644
--- a/server/storage.js
+++ b/server/storage.js
@@ -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,
diff --git a/test/frontend/frontend.bundle.js b/test/frontend/frontend.bundle.js
index eea32c79..2f043245 100644
--- a/test/frontend/frontend.bundle.js
+++ b/test/frontend/frontend.bundle.js
@@ -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;
diff --git a/test/frontend/frontend.test.js b/test/frontend/frontend.test.js
index 5eabf5ca..b2241330 100644
--- a/test/frontend/frontend.test.js
+++ b/test/frontend/frontend.test.js
@@ -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,
diff --git a/test/unit/local.storage.test.js b/test/unit/local.storage.test.js
index ae94ba95..4f1c54bd 100644
--- a/test/unit/local.storage.test.js
+++ b/test/unit/local.storage.test.js
@@ -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 });
diff --git a/webpack.config.js b/webpack.config.js
index feb4052a..a89509d9 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -143,6 +143,6 @@ module.exports = {
],
devServer: {
compress: true,
- setup: IS_DEV ? require('./server/dev') : undefined
+ before: IS_DEV ? require('./server/dev') : undefined
}
};