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` +
+ ${state.translate('downloadAltText')} +
+ +
+
`; + 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')}
- ${state.translate('downloadAltText')} -
- -
+ ${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` `; 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('uploadPageExplainer')}
- ${state.translate( - 'uploadPageLearnMore' - )} + ${state.translate('uploadPageLearnMore')}
-
-
+
+
+ +
${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 } };