From 565e47aef8313e1bbbda1afeb3d1f04e7adc6baf Mon Sep 17 00:00:00 2001 From: Danny Coates Date: Wed, 24 Jan 2018 10:23:13 -0800 Subject: [PATCH] big refactor --- app/api.js | 206 +++++++++++ app/fileManager.js | 138 +++---- app/fileReceiver.js | 274 +++----------- app/fileSender.js | 339 +++--------------- app/keychain.js | 212 +++++++++++ app/main.js | 24 +- app/ownedFile.js | 81 +++++ app/routes/download.js | 50 ++- app/routes/home.js | 3 +- app/routes/index.js | 19 +- app/storage.js | 9 +- app/templates/blank.js | 2 +- app/templates/completed.js | 27 +- app/templates/download.js | 11 +- app/templates/downloadButton.js | 16 + app/templates/downloadPassword.js | 7 +- app/templates/file.js | 58 +-- app/templates/fileList.js | 24 +- app/templates/footer.js | 51 +-- app/templates/header.js | 7 +- app/templates/notFound.js | 12 +- app/templates/preview.js | 53 +-- app/templates/progress.js | 36 +- app/templates/selectbox.js | 4 +- app/templates/share.js | 98 +---- app/templates/unsupported.js | 50 +-- app/templates/upload.js | 25 +- app/templates/uploadPasswordSet.js | 79 ++++ ...loadPassword.js => uploadPasswordUnset.js} | 12 +- app/templates/welcome.js | 13 +- app/utils.js | 51 ++- public/locales/en-US/send.ftl | 7 + server/config.js | 6 +- server/prod.js | 2 +- server/routes/pages.js | 2 +- server/routes/password.js | 26 +- test/server/server.test.js | 4 +- 37 files changed, 1095 insertions(+), 943 deletions(-) create mode 100644 app/api.js create mode 100644 app/keychain.js create mode 100644 app/ownedFile.js create mode 100644 app/templates/downloadButton.js create mode 100644 app/templates/uploadPasswordSet.js rename app/templates/{uploadPassword.js => uploadPasswordUnset.js} (87%) diff --git a/app/api.js b/app/api.js new file mode 100644 index 00000000..69c7216c --- /dev/null +++ b/app/api.js @@ -0,0 +1,206 @@ +import { arrayToB64, b64ToArray } from './utils'; + +function post(obj) { + return { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify(obj) + }; +} + +function parseNonce(header) { + header = header || ''; + return header.split(' ')[1]; +} + +async function fetchWithAuth(url, params, keychain) { + const result = {}; + params = params || {}; + const h = await keychain.authHeader(); + params.headers = new Headers({ Authorization: h }); + const response = await fetch(url, params); + result.response = response; + result.ok = response.ok; + const nonce = parseNonce(response.headers.get('WWW-Authenticate')); + result.shouldRetry = response.status === 401 && nonce !== keychain.nonce; + keychain.nonce = nonce; + return result; +} + +async function fetchWithAuthAndRetry(url, params, keychain) { + const result = await fetchWithAuth(url, params, keychain); + if (result.shouldRetry) { + return fetchWithAuth(url, params, keychain); + } + return result; +} + +export async function del(id, owner_token) { + const response = await fetch(`/api/delete/${id}`, post({ owner_token })); + return response.ok; +} + +export async function setParams(id, owner_token, params) { + const response = await fetch( + `/api/params/${id}`, + post({ + owner_token, + dlimit: params.dlimit + }) + ); + return response.ok; +} + +export async function metadata(id, keychain) { + const result = await fetchWithAuthAndRetry( + `/api/metadata/${id}`, + { method: 'GET' }, + keychain + ); + if (result.ok) { + const data = await result.response.json(); + const meta = await keychain.decryptMetadata(b64ToArray(data.metadata)); + return { + dtotal: data.dtotal, + dlimit: data.dlimit, + size: data.size, + ttl: data.ttl, + iv: meta.iv, + name: meta.name, + type: meta.type + }; + } + throw new Error(result.response.status); +} + +export async function setPassword(id, owner_token, keychain) { + const auth = await keychain.authKeyB64(); + const response = await fetch( + `/api/password/${id}`, + post({ owner_token, auth }) + ); + return response.ok; +} + +export function uploadFile(encrypted, metadata, verifierB64, keychain) { + const xhr = new XMLHttpRequest(); + const upload = { + onprogress: function() {}, + cancel: function() { + xhr.abort(); + }, + result: new Promise(function(resolve, reject) { + xhr.addEventListener('loadend', function() { + const authHeader = xhr.getResponseHeader('WWW-Authenticate'); + if (authHeader) { + keychain.nonce = parseNonce(authHeader); + } + if (xhr.status === 200) { + const responseObj = JSON.parse(xhr.responseText); + return resolve({ + url: responseObj.url, + id: responseObj.id, + ownerToken: responseObj.owner + }); + } + reject(new Error(xhr.status)); + }); + }) + }; + const dataView = new DataView(encrypted); + const blob = new Blob([dataView], { type: 'application/octet-stream' }); + const fd = new FormData(); + fd.append('data', blob); + xhr.upload.addEventListener('progress', function(event) { + if (event.lengthComputable) { + upload.onprogress([event.loaded, event.total]); + } + }); + xhr.open('post', '/api/upload', true); + xhr.setRequestHeader('X-File-Metadata', arrayToB64(new Uint8Array(metadata))); + xhr.setRequestHeader('Authorization', `send-v1 ${verifierB64}`); + xhr.send(fd); + return upload; +} + +function download(id, keychain) { + const xhr = new XMLHttpRequest(); + const download = { + onprogress: function() {}, + cancel: function() { + xhr.abort(); + }, + result: new Promise(async function(resolve, reject) { + xhr.addEventListener('loadend', function() { + const authHeader = xhr.getResponseHeader('WWW-Authenticate'); + if (authHeader) { + keychain.nonce = parseNonce(authHeader); + } + if (xhr.status === 404) { + return reject(new Error('notfound')); + } + if (xhr.status !== 200) { + return reject(new Error(xhr.status)); + } + + const blob = new Blob([xhr.response]); + const fileReader = new FileReader(); + fileReader.readAsArrayBuffer(blob); + fileReader.onload = function() { + resolve(this.result); + }; + }); + xhr.addEventListener('progress', function(event) { + if (event.lengthComputable && event.target.status === 200) { + download.onprogress([event.loaded, event.total]); + } + }); + const auth = await keychain.authHeader(); + xhr.open('get', `/api/download/${id}`); + xhr.setRequestHeader('Authorization', auth); + xhr.responseType = 'blob'; + xhr.send(); + }) + }; + + return download; +} + +async function tryDownload(id, keychain, onprogress, tries = 1) { + const dl = download(id, keychain); + dl.onprogress = onprogress; + try { + const result = await dl.result; + return result; + } catch (e) { + if (e.message === '401' && --tries > 0) { + return tryDownload(id, keychain, onprogress, tries); + } + throw e; + } +} + +export function downloadFile(id, keychain) { + let cancelled = false; + function updateProgress(p) { + if (cancelled) { + // This is a bit of a hack + // We piggyback off of the progress event as a chance to cancel. + // Otherwise wiring the xhr abort up while allowing retries + // gets pretty nasty. + // 'this' here is the object returned by download(id, keychain) + return this.cancel(); + } + dl.onprogress(p); + } + const dl = { + onprogress: function() {}, + cancel: function() { + cancelled = true; + }, + result: tryDownload(id, keychain, updateProgress, 2) + }; + return dl; +} diff --git a/app/fileManager.js b/app/fileManager.js index acbe9161..9be93215 100644 --- a/app/fileManager.js +++ b/app/fileManager.js @@ -1,51 +1,15 @@ -/* global EXPIRE_SECONDS */ import FileSender from './fileSender'; import FileReceiver from './fileReceiver'; -import { copyToClipboard, delay, fadeOut, percent } from './utils'; +import { + copyToClipboard, + delay, + fadeOut, + openLinksInNewTab, + percent, + saveFile +} from './utils'; import * as metrics from './metrics'; -function saveFile(file) { - const dataView = new DataView(file.plaintext); - const blob = new Blob([dataView], { type: file.type }); - const downloadUrl = URL.createObjectURL(blob); - - if (window.navigator.msSaveBlob) { - return window.navigator.msSaveBlob(blob, file.name); - } - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = file.name; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(downloadUrl); -} - -function openLinksInNewTab(links, should = true) { - links = links || Array.from(document.querySelectorAll('a:not([target])')); - if (should) { - links.forEach(l => { - l.setAttribute('target', '_blank'); - l.setAttribute('rel', 'noopener noreferrer'); - }); - } else { - links.forEach(l => { - l.removeAttribute('target'); - l.removeAttribute('rel'); - }); - } - return links; -} - -async function getDLCounts(file) { - const url = `/api/metadata/${file.id}`; - const receiver = new FileReceiver(url, file); - try { - await receiver.getMetadata(file.nonce); - return receiver.file; - } catch (e) { - if (e.message === '404') return false; - } -} export default function(state, emitter) { let lastRender = 0; let updateTitle = false; @@ -60,14 +24,11 @@ export default function(state, emitter) { for (const file of files) { const oldLimit = file.dlimit; const oldTotal = file.dtotal; - const receivedFile = await getDLCounts(file); - if (!receivedFile) { + await file.updateDownloadCount(); + if (file.dtotal === file.dlimit) { state.storage.remove(file.id); rerender = true; - } else if ( - oldLimit !== receivedFile.dlimit || - oldTotal !== receivedFile.dtotal - ) { + } else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) { rerender = true; } } @@ -92,16 +53,15 @@ export default function(state, emitter) { checkFiles(); }); - emitter.on('navigate', checkFiles); + // emitter.on('navigate', checkFiles); emitter.on('render', () => { lastRender = Date.now(); }); emitter.on('changeLimit', async ({ file, value }) => { - await FileSender.changeLimit(file.id, file.ownerToken, value); - file.dlimit = value; - state.storage.writeFiles(); + await file.changeLimit(value); + state.storage.writeFile(file); metrics.changedDownloadLimit(file); }); @@ -116,11 +76,10 @@ export default function(state, emitter) { location }); state.storage.remove(file.id); - await FileSender.delete(file.id, file.ownerToken); + await file.del(); } catch (e) { state.raven.captureException(e); } - state.fileInfo = null; }); emitter.on('cancel', () => { @@ -134,32 +93,24 @@ export default function(state, emitter) { sender.on('encrypting', render); state.transfer = sender; render(); + const links = openLinksInNewTab(); await delay(200); try { - const start = Date.now(); metrics.startedUpload({ size, type }); - const info = await sender.upload(); - const time = Date.now() - start; - const speed = size / (time / 1000); - metrics.completedUpload({ size, time, speed, type }); + const ownedFile = await sender.upload(state.storage); + state.storage.totalUploads += 1; + metrics.completedUpload(ownedFile); + + state.storage.addFile(ownedFile); + document.getElementById('cancel-upload').hidden = 'hidden'; await delay(1000); await fadeOut('upload-progress'); - info.name = file.name; - info.size = size; - info.type = type; - info.time = time; - info.speed = speed; - info.createdAt = Date.now(); - info.url = `${info.url}#${info.secretKey}`; - info.expiresAt = Date.now() + EXPIRE_SECONDS * 1000; - state.fileInfo = info; - state.storage.addFile(state.fileInfo); openLinksInNewTab(links, false); state.transfer = null; - state.storage.totalUploads += 1; - emitter.emit('pushState', `/share/${info.id}`); + + emitter.emit('pushState', `/share/${ownedFile.id}`); } catch (err) { console.error(err); state.transfer = null; @@ -174,31 +125,29 @@ export default function(state, emitter) { } }); - emitter.on('password', async ({ existingPassword, password, file }) => { + emitter.on('password', async ({ password, file }) => { try { - await FileSender.setPassword(existingPassword, password, file); + await file.setPassword(password); + state.storage.writeFile(file); metrics.addedPassword({ size: file.size }); - file.password = password; - state.storage.writeFiles(); - } catch (e) { - console.error(e); + } catch (err) { + console.error(err); } render(); }); - emitter.on('preview', async () => { + emitter.on('getMetadata', async () => { const file = state.fileInfo; - const url = `/api/download/${file.id}`; - const receiver = new FileReceiver(url, file); - receiver.on('progress', updateProgress); - receiver.on('decrypting', render); - state.transfer = receiver; + const receiver = new FileReceiver(file); try { - await receiver.getMetadata(file.nonce); + await receiver.getMetadata(); + receiver.on('progress', updateProgress); + receiver.on('decrypting', render); + state.transfer = receiver; } catch (e) { if (e.message === '401') { file.password = null; - if (!file.pwd) { + if (!file.requiresPassword) { return emitter.emit('pushState', '/404'); } } @@ -214,7 +163,7 @@ export default function(state, emitter) { try { const start = Date.now(); metrics.startedDownload({ size: file.size, ttl: file.ttl }); - const f = await state.transfer.download(file.nonce); + const f = await state.transfer.download(); const time = Date.now() - start; const speed = size / (time / 1000); await delay(1000); @@ -225,8 +174,11 @@ export default function(state, emitter) { metrics.completedDownload({ size, time, speed }); emitter.emit('pushState', '/completed'); } catch (err) { + if (err.message === '0') { + // download cancelled + return render(); + } console.error(err); - // TODO cancelled download const location = err.message === 'notfound' ? '/404' : '/error'; if (location === '/error') { state.raven.captureException(err); @@ -244,6 +196,14 @@ export default function(state, emitter) { metrics.copiedLink({ location }); }); + setInterval(() => { + // poll for updates of the download counts + // TODO something for the share page: || state.route === '/share/:id' + if (state.route === '/') { + checkFiles(); + } + }, 2 * 60 * 1000); + setInterval(() => { // poll for rerendering the file list countdown timers if ( diff --git a/app/fileReceiver.js b/app/fileReceiver.js index 0766f71e..03347dcd 100644 --- a/app/fileReceiver.js +++ b/app/fileReceiver.js @@ -1,104 +1,21 @@ import Nanobus from 'nanobus'; -import { arrayToB64, b64ToArray, bytes } from './utils'; +import Keychain from './keychain'; +import { bytes } from './utils'; +import { metadata, downloadFile } from './api'; export default class FileReceiver extends Nanobus { - constructor(url, file) { + constructor(fileInfo) { super('FileReceiver'); - this.secretKeyPromise = window.crypto.subtle.importKey( - 'raw', - b64ToArray(file.secretKey), - 'HKDF', - false, - ['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(); - 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.keychain = new Keychain(fileInfo.secretKey, fileInfo.nonce); + if (fileInfo.requiresPassword) { + this.keychain.setPassword(fileInfo.password, fileInfo.url); } - 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.fileInfo = fileInfo; + this.fileDownload = null; this.msg = 'fileSizeProgress'; this.state = 'initialized'; this.progress = [0, 1]; + this.cancelled = false; } get progressRatio() { @@ -113,160 +30,51 @@ export default class FileReceiver extends Nanobus { } cancel() { - // TODO - } - - async fetchMetadata(nonce) { - const authHeader = await this.getAuthHeader(nonce); - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 404) { - return reject(new Error(xhr.status)); - } - const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1]; - this.file.nonce = nonce; - if (xhr.status === 200) { - return resolve(xhr.response); - } - const err = new Error(xhr.status); - err.nonce = nonce; - reject(err); - } - }; - xhr.onerror = () => reject(new Error(0)); - xhr.ontimeout = () => reject(new Error(0)); - xhr.open('get', `/api/metadata/${this.file.id}`); - xhr.setRequestHeader('Authorization', authHeader); - xhr.responseType = 'json'; - xhr.timeout = 2000; - xhr.send(); - }); - } - - async getMetadata(nonce) { - let data = null; - try { - try { - data = await this.fetchMetadata(nonce); - } catch (e) { - if (e.message === '401' && nonce !== e.nonce) { - // allow one retry for changed nonce - data = await this.fetchMetadata(e.nonce); - } else { - throw e; - } - } - 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.file.dlimit = data.dlimit; - this.file.dtotal = data.dtotal; - this.state = 'ready'; - } catch (e) { - this.state = 'invalid'; - throw e; + this.cancelled = true; + if (this.fileDownload) { + this.fileDownload.cancel(); } } - async downloadFile(nonce) { - const authHeader = await this.getAuthHeader(nonce); - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.onprogress = event => { - if (event.lengthComputable && event.target.status !== 404) { - this.progress = [event.loaded, event.total]; - this.emit('progress', this.progress); - } - }; - - xhr.onload = event => { - if (xhr.status === 404) { - reject(new Error('notfound')); - return; - } - if (xhr.status !== 200) { - const err = new Error(xhr.status); - err.nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1]; - return reject(err); - } - - const blob = new Blob([xhr.response]); - const fileReader = new FileReader(); - fileReader.onload = function() { - resolve(this.result); - }; - - fileReader.readAsArrayBuffer(blob); - }; - - xhr.open('get', this.url); - xhr.setRequestHeader('Authorization', authHeader); - xhr.responseType = 'blob'; - xhr.send(); - }); + async getMetadata() { + const meta = await metadata(this.fileInfo.id, this.keychain); + if (meta) { + this.keychain.setIV(meta.iv); + this.fileInfo.name = meta.name; + this.fileInfo.type = meta.type; + this.fileInfo.iv = meta.iv; + this.fileInfo.size = meta.size; + this.state = 'ready'; + return; + } + this.state = 'invalid'; + return; } - async getAuthHeader(nonce) { - const authKey = await this.authKeyPromise; - const sig = await window.crypto.subtle.sign( - { - name: 'HMAC' - }, - authKey, - b64ToArray(nonce) - ); - return `send-v1 ${arrayToB64(new Uint8Array(sig))}`; - } - - async download(nonce) { + async download() { this.state = 'downloading'; this.emit('progress', this.progress); try { - const encryptKey = await this.encryptKeyPromise; - let ciphertext = null; - try { - ciphertext = await this.downloadFile(nonce); - } catch (e) { - if (e.message === '401' && nonce !== e.nonce) { - ciphertext = await this.downloadFile(e.nonce); - } else { - throw e; - } - } + const download = await downloadFile(this.fileInfo.id, this.keychain); + download.onprogress = p => { + this.progress = p; + this.emit('progress', p); + }; + this.fileDownload = download; + const ciphertext = await download.result; + this.fileDownload = null; 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 - ); + const plaintext = await this.keychain.decryptFile(ciphertext); + if (this.cancelled) { + throw new Error(0); + } this.msg = 'downloadFinish'; this.state = 'complete'; return { plaintext, - name: decodeURIComponent(this.file.name), - type: this.file.type + name: decodeURIComponent(this.fileInfo.name), + type: this.fileInfo.type }; } catch (e) { this.state = 'invalid'; diff --git a/app/fileSender.js b/app/fileSender.js index 3965edf5..fd6465c1 100644 --- a/app/fileSender.js +++ b/app/fileSender.js @@ -1,97 +1,19 @@ +/* global EXPIRE_SECONDS */ import Nanobus from 'nanobus'; -import { arrayToB64, b64ToArray, bytes } from './utils'; - -async function getAuthHeader(authKey, nonce) { - const sig = await window.crypto.subtle.sign( - { - name: 'HMAC' - }, - authKey, - b64ToArray(nonce) - ); - return `send-v1 ${arrayToB64(new Uint8Array(sig))}`; -} - -async function sendPassword(file, authKey, rawAuth) { - const authHeader = await getAuthHeader(authKey, file.nonce); - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1]; - file.nonce = nonce; - return resolve(xhr.response); - } - 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', authHeader); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.responseType = 'json'; - xhr.timeout = 2000; - xhr.send(JSON.stringify({ auth: arrayToB64(new Uint8Array(rawAuth)) })); - }); -} +import OwnedFile from './ownedFile'; +import Keychain from './keychain'; +import { arrayToB64, bytes } from './utils'; +import { uploadFile } from './api'; export default class FileSender extends Nanobus { constructor(file) { super('FileSender'); this.file = file; + this.uploadRequest = null; this.msg = 'importingFile'; this.progress = [0, 1]; this.cancelled = false; - this.iv = window.crypto.getRandomValues(new Uint8Array(12)); - this.uploadXHR = new XMLHttpRequest(); - this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16)); - this.secretKey = window.crypto.subtle.importKey( - 'raw', - this.rawSecret, - 'HKDF', - false, - ['deriveKey'] - ); - } - - static delete(id, token) { - return new Promise((resolve, reject) => { - if (!id || !token) { - return reject(); - } - const xhr = new XMLHttpRequest(); - xhr.open('POST', `/api/delete/${id}`); - xhr.setRequestHeader('Content-Type', 'application/json'); - - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.DONE) { - resolve(); - } - }; - - xhr.send(JSON.stringify({ owner_token: token })); - }); - } - - static changeLimit(id, owner_token, dlimit) { - return new Promise((resolve, reject) => { - if (!id || !owner_token) { - return reject(); - } - const xhr = new XMLHttpRequest(); - xhr.open('POST', `/api/params/${id}`); - xhr.setRequestHeader('Content-Type', 'application/json'); - - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.DONE) { - resolve(); - } - }; - - xhr.send(JSON.stringify({ owner_token, dlimit })); - }); + this.keychain = new Keychain(); } get progressRatio() { @@ -107,8 +29,8 @@ export default class FileSender extends Nanobus { cancel() { this.cancelled = true; - if (this.msg === 'fileSizeProgress') { - this.uploadXHR.abort(); + if (this.uploadRequest) { + this.uploadRequest.cancel(); } } @@ -116,6 +38,7 @@ export default class FileSender extends Nanobus { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsArrayBuffer(this.file); + // TODO: progress? reader.onload = function(event) { const plaintext = new Uint8Array(this.result); resolve(plaintext); @@ -126,218 +49,60 @@ export default class FileSender extends Nanobus { }); } - uploadFile(encrypted, metadata, rawAuth) { - return new Promise((resolve, reject) => { - const dataView = new DataView(encrypted); - const blob = new Blob([dataView], { type: 'application/octet-stream' }); - const fd = new FormData(); - fd.append('data', blob); - - const xhr = this.uploadXHR; - - xhr.upload.addEventListener('progress', e => { - if (e.lengthComputable) { - this.progress = [e.loaded, e.total]; - this.emit('progress', this.progress); - } - }); - - 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: arrayToB64(this.rawSecret), - ownerToken: responseObj.owner, - nonce - }); - } - this.msg = 'errorPageHeader'; - reject(new Error(xhr.status)); - } - }; - - xhr.open('post', '/api/upload', true); - xhr.setRequestHeader( - 'X-File-Metadata', - arrayToB64(new Uint8Array(metadata)) - ); - xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`); - xhr.send(fd); - this.msg = 'fileSizeProgress'; - }); - } - - async upload() { - 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'] - ); + async upload(storage) { + const start = Date.now(); const plaintext = await this.readFile(); if (this.cancelled) { throw new Error(0); } this.msg = 'encryptingFile'; this.emit('encrypting'); - const encrypted = await window.crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv: this.iv, - tagLength: 128 - }, - 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 || 'application/octet-stream' - }) - ) - ); - const rawAuth = await window.crypto.subtle.exportKey('raw', authKey); + const encrypted = await this.keychain.encryptFile(plaintext); + const metadata = await this.keychain.encryptMetadata(this.file); + const authKeyB64 = await this.keychain.authKeyB64(); if (this.cancelled) { throw new Error(0); } - return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth)); - } - - static async setPassword(existingPassword, password, file) { - const encoder = new TextEncoder(); - const secretKey = await window.crypto.subtle.importKey( - 'raw', - b64ToArray(file.secretKey), - 'HKDF', - false, - ['deriveKey'] + this.uploadRequest = uploadFile( + encrypted, + metadata, + authKeyB64, + this.keychain ); - 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 pwdKey = await window.crypto.subtle.importKey( - 'raw', - encoder.encode(password), - { name: 'PBKDF2' }, - false, - ['deriveKey'] - ); - const oldPwdkey = await window.crypto.subtle.importKey( - 'raw', - encoder.encode(existingPassword), - { name: 'PBKDF2' }, - false, - ['deriveKey'] - ); - const oldAuthKey = await window.crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: encoder.encode(file.url), - iterations: 100, - hash: 'SHA-256' - }, - oldPwdkey, - { - name: 'HMAC', - hash: 'SHA-256' - }, - true, - ['sign'] - ); - 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); - const aKey = existingPassword ? oldAuthKey : authKey; + this.msg = 'fileSizeProgress'; + this.uploadRequest.onprogress = p => { + this.progress = p; + this.emit('progress', p); + }; try { - await sendPassword(file, aKey, rawAuth); + const result = await this.uploadRequest.result; + const time = Date.now() - start; + this.msg = 'notifyUploadDone'; + this.uploadRequest = null; + this.progress = [1, 1]; + const secretKey = arrayToB64(this.keychain.rawSecret); + const ownedFile = new OwnedFile( + { + id: result.id, + url: `${result.url}#${secretKey}`, + name: this.file.name, + size: this.file.size, + type: this.file.type, //TODO 'click' ? + time: time, + speed: this.file.size / (time / 1000), + createdAt: Date.now(), + expiresAt: Date.now() + EXPIRE_SECONDS * 1000, + secretKey: secretKey, + nonce: this.keychain.nonce, + ownerToken: result.ownerToken + }, + storage + ); + return ownedFile; } catch (e) { - if (e.message === '401' && file.nonce !== e.nonce) { - await sendPassword(file, aKey, rawAuth); - } else { - throw e; - } + this.msg = 'errorPageHeader'; + this.uploadRequest = null; + throw e; } } } diff --git a/app/keychain.js b/app/keychain.js new file mode 100644 index 00000000..bb19864b --- /dev/null +++ b/app/keychain.js @@ -0,0 +1,212 @@ +import Nanobus from 'nanobus'; +import { arrayToB64, b64ToArray } from './utils'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export default class Keychain extends Nanobus { + constructor(secretKeyB64, nonce, ivB64) { + super('Keychain'); + this._nonce = nonce || 'yRCdyQ1EMSA3mo4rqSkuNQ=='; + if (ivB64) { + this.iv = b64ToArray(ivB64); + } else { + this.iv = window.crypto.getRandomValues(new Uint8Array(12)); + } + if (secretKeyB64) { + this.rawSecret = b64ToArray(secretKeyB64); + } else { + this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16)); + } + this.secretKeyPromise = window.crypto.subtle.importKey( + 'raw', + this.rawSecret, + 'HKDF', + false, + ['deriveKey'] + ); + this.encryptKeyPromise = this.secretKeyPromise.then(function(secretKey) { + return window.crypto.subtle.deriveKey( + { + name: 'HKDF', + salt: new Uint8Array(), + info: encoder.encode('encryption'), + hash: 'SHA-256' + }, + secretKey, + { + name: 'AES-GCM', + length: 128 + }, + false, + ['encrypt', 'decrypt'] + ); + }); + this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) { + return window.crypto.subtle.deriveKey( + { + name: 'HKDF', + salt: new Uint8Array(), + info: encoder.encode('metadata'), + hash: 'SHA-256' + }, + secretKey, + { + name: 'AES-GCM', + length: 128 + }, + false, + ['encrypt', 'decrypt'] + ); + }); + this.authKeyPromise = this.secretKeyPromise.then(function(secretKey) { + return window.crypto.subtle.deriveKey( + { + name: 'HKDF', + salt: new Uint8Array(), + info: encoder.encode('authentication'), + hash: 'SHA-256' + }, + secretKey, + { + name: 'HMAC', + hash: { name: 'SHA-256' } + }, + true, + ['sign'] + ); + }); + } + + get nonce() { + return this._nonce; + } + + set nonce(n) { + if (n !== this.nonce) { + this.emit('nonceChanged', n); + } + this._nonce = n; + } + + setIV(ivB64) { + this.iv = b64ToArray(ivB64); + } + + setPassword(password, shareUrl) { + this.authKeyPromise = window.crypto.subtle + .importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, [ + 'deriveKey' + ]) + .then(passwordKey => + window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: encoder.encode(shareUrl), + iterations: 100, + hash: 'SHA-256' + }, + passwordKey, + { + name: 'HMAC', + hash: 'SHA-256' + }, + true, + ['sign'] + ) + ); + } + + setAuthKey(authKeyB64) { + this.authKeyPromise = window.crypto.subtle.importKey( + 'raw', + b64ToArray(authKeyB64), + { + name: 'HMAC', + hash: 'SHA-256' + }, + true, + ['sign'] + ); + } + + async authKeyB64() { + const authKey = await this.authKeyPromise; + const rawAuth = await window.crypto.subtle.exportKey('raw', authKey); + return arrayToB64(new Uint8Array(rawAuth)); + } + + async authHeader() { + const authKey = await this.authKeyPromise; + const sig = await window.crypto.subtle.sign( + { + name: 'HMAC' + }, + authKey, + b64ToArray(this.nonce) + ); + return `send-v1 ${arrayToB64(new Uint8Array(sig))}`; + } + + async encryptFile(plaintext) { + const encryptKey = await this.encryptKeyPromise; + const ciphertext = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: this.iv, + tagLength: 128 + }, + encryptKey, + plaintext + ); + return ciphertext; + } + + async encryptMetadata(metadata) { + const metaKey = await this.metaKeyPromise; + const ciphertext = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: new Uint8Array(12), + tagLength: 128 + }, + metaKey, + encoder.encode( + JSON.stringify({ + iv: arrayToB64(this.iv), + name: metadata.name, + type: metadata.type || 'application/octet-stream' + }) + ) + ); + return ciphertext; + } + + async decryptFile(ciphertext) { + const encryptKey = await this.encryptKeyPromise; + const plaintext = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: this.iv, + tagLength: 128 + }, + encryptKey, + ciphertext + ); + return plaintext; + } + + async decryptMetadata(ciphertext) { + const metaKey = await this.metaKeyPromise; + const plaintext = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: new Uint8Array(12), + tagLength: 128 + }, + metaKey, + ciphertext + ); + return JSON.parse(decoder.decode(plaintext)); + } +} diff --git a/app/main.js b/app/main.js index 7f7e0402..0b41b75d 100644 --- a/app/main.js +++ b/app/main.js @@ -15,30 +15,34 @@ if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) { } app.use((state, emitter) => { - // init state state.transfer = null; state.fileInfo = null; state.translate = locale.getTranslator(); state.storage = storage; state.raven = Raven; - emitter.on('DOMContentLoaded', async () => { - let reason = null; + window.appState = state; + emitter.on('DOMContentLoaded', async function checkSupport() { + let unsupportedReason = null; if ( + // Firefox < 50 /firefox/i.test(navigator.userAgent) && - parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) <= - 49 + parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) < 50 ) { - reason = 'outdated'; + unsupportedReason = 'outdated'; } if (/edge\/\d+/i.test(navigator.userAgent)) { - reason = 'edge'; + unsupportedReason = 'edge'; } const ok = await canHasSend(assets.get('cryptofill.js')); if (!ok) { - reason = /firefox/i.test(navigator.userAgent) ? 'outdated' : 'gcm'; + unsupportedReason = /firefox/i.test(navigator.userAgent) + ? 'outdated' + : 'gcm'; } - if (reason) { - setTimeout(() => emitter.emit('replaceState', `/unsupported/${reason}`)); + if (unsupportedReason) { + setTimeout(() => + emitter.emit('replaceState', `/unsupported/${unsupportedReason}`) + ); } }); }); diff --git a/app/ownedFile.js b/app/ownedFile.js new file mode 100644 index 00000000..b482caf8 --- /dev/null +++ b/app/ownedFile.js @@ -0,0 +1,81 @@ +import Keychain from './keychain'; +import { arrayToB64 } from './utils'; +import { del, metadata, setParams, setPassword } from './api'; + +export default class OwnedFile { + constructor(obj, storage) { + this.id = obj.id; + this.url = obj.url; + this.name = obj.name; + this.size = obj.size; + this.type = obj.type; + this.time = obj.time; + this.speed = obj.speed; + this.createdAt = obj.createdAt; + this.expiresAt = obj.expiresAt; + this.ownerToken = obj.ownerToken; + this.dlimit = obj.dlimit || 1; + this.dtotal = obj.dtotal || 0; + this.keychain = new Keychain(obj.secretKey, obj.nonce); + this.keychain.on('nonceChanged', () => storage.writeFile(this)); + if (obj.authKeyB64) { + this.authKeyB64 = obj.authKeyB64; + this.keychain.setAuthKey(obj.authKeyB64); + } + } + + async setPassword(password) { + this.password = password; + this.keychain.setPassword(password, this.url); + const result = await setPassword(this.id, this.ownerToken, this.keychain); + this.authKeyB64 = await this.keychain.authKeyB64(); + return result; + } + + del() { + return del(this.id, this.ownerToken); + } + + changeLimit(dlimit) { + if (this.dlimit !== dlimit) { + this.dlimit = dlimit; + return setParams(this.id, this.ownerToken, { dlimit }); + } + return Promise.resolve(true); + } + + hasPassword() { + return !!this.authKeyB64; + } + + async updateDownloadCount() { + try { + const result = await metadata(this.id, this.keychain); + this.dtotal = result.dtotal; + } catch (e) { + if (e.message === '404') { + this.dtotal = this.dlimit; + } + } + } + + toJSON() { + return { + id: this.id, + url: this.url, + name: this.name, + size: this.size, + type: this.type, + time: this.time, + speed: this.speed, + createdAt: this.createdAt, + expiresAt: this.expiresAt, + secretKey: arrayToB64(this.keychain.rawSecret), + nonce: this.keychain.nonce, + ownerToken: this.ownerToken, + dlimit: this.dlimit, + dtotal: this.dtotal, + authKeyB64: this.authKeyB64 + }; + } +} diff --git a/app/routes/download.js b/app/routes/download.js index c0a47a4c..ca0f2b71 100644 --- a/app/routes/download.js +++ b/app/routes/download.js @@ -1,12 +1,60 @@ const preview = require('../templates/preview'); const download = require('../templates/download'); +const notFound = require('../templates/notFound'); +const downloadPassword = require('../templates/downloadPassword'); +const downloadButton = require('../templates/downloadButton'); + +function hasFileInfo() { + return !!document.getElementById('dl-file'); +} + +function getFileInfoFromDOM() { + const el = document.getElementById('dl-file'); + if (!el) { + return null; + } + return { + nonce: el.getAttribute('data-nonce'), + requiresPassword: !!+el.getAttribute('data-requires-password') + }; +} + +function createFileInfo(state) { + const metadata = getFileInfoFromDOM(); + return { + id: state.params.id, + secretKey: state.params.key, + nonce: metadata.nonce, + requiresPassword: metadata.requiresPassword + }; +} module.exports = function(state, emit) { + if (!state.fileInfo) { + // This is a fresh page load + // We need to parse the file info from the server's html + if (!hasFileInfo()) { + return notFound(state, emit); + } + state.fileInfo = createFileInfo(state); + + if (!state.fileInfo.requiresPassword) { + emit('getMetadata'); + } + } + + let pageAction = ''; //default state: we don't have file metadata if (state.transfer) { const s = state.transfer.state; if (s === 'downloading' || s === 'complete') { + // Downloading is in progress return download(state, emit); } + // we have file metadata + pageAction = downloadButton(state, emit); + } else if (state.fileInfo.requiresPassword && !state.fileInfo.password) { + // we're waiting on the user for a valid password + pageAction = downloadPassword(state, emit); } - return preview(state, emit); + return preview(state, pageAction); }; diff --git a/app/routes/home.js b/app/routes/home.js index 0059ceb0..2be53047 100644 --- a/app/routes/home.js +++ b/app/routes/home.js @@ -2,8 +2,7 @@ const welcome = require('../templates/welcome'); const upload = require('../templates/upload'); module.exports = function(state, emit) { - if (state.transfer && state.transfer.iv) { - //TODO relying on 'iv' is gross + if (state.transfer) { return upload(state, emit); } return welcome(state, emit); diff --git a/app/routes/index.js b/app/routes/index.js index 4bf6a55a..dfe6c34c 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -7,26 +7,33 @@ const fxPromo = require('../templates/fxPromo'); const app = choo(); -function showBanner(state) { - return state.promo && !state.route.startsWith('/unsupported/'); +function banner(state, emit) { + if (state.promo && !state.route.startsWith('/unsupported/')) { + return fxPromo(state, emit); + } } function body(template) { return function(state, emit) { const b = html` - ${showBanner(state) ? fxPromo(state, emit) : ''} + ${banner(state, emit)} ${header(state)}
${template(state, emit)}
${footer(state)} `; if (state.layout) { + // server side only return state.layout(state, b); } return b; diff --git a/app/storage.js b/app/storage.js index 27cba1cb..64d591fc 100644 --- a/app/storage.js +++ b/app/storage.js @@ -1,4 +1,5 @@ import { isFile } from './utils'; +import OwnedFile from './ownedFile'; class Mem { constructor() { @@ -42,7 +43,7 @@ class Storage { const k = this.engine.key(i); if (isFile(k)) { try { - const f = JSON.parse(this.engine.getItem(k)); + const f = new OwnedFile(JSON.parse(this.engine.getItem(k)), this); if (!f.id) { f.id = f.fileId; } @@ -108,11 +109,15 @@ class Storage { addFile(file) { this._files.push(file); + this.writeFile(file); + } + + writeFile(file) { this.engine.setItem(file.id, JSON.stringify(file)); } writeFiles() { - this._files.forEach(f => this.engine.setItem(f.id, JSON.stringify(f))); + this._files.forEach(f => this.writeFile(f)); } } diff --git a/app/templates/blank.js b/app/templates/blank.js index 080a3232..ec104ac3 100644 --- a/app/templates/blank.js +++ b/app/templates/blank.js @@ -1,6 +1,6 @@ const html = require('choo/html'); module.exports = function() { - const div = html`
`; + const div = html`
`; return div; }; diff --git a/app/templates/completed.js b/app/templates/completed.js index 6415a0c0..88bb9958 100644 --- a/app/templates/completed.js +++ b/app/templates/completed.js @@ -5,21 +5,22 @@ const { fadeOut } = require('../utils'); module.exports = function(state, emit) { const div = html`
-
-
-
${state.translate( - 'downloadFinish' - )}
-
- ${progress(1)} -
-
+
+
+
+ ${state.translate('downloadFinish')} +
+
+ ${progress(1)} +
+
+
+ ${state.translate('sendYourFilesLink')}
- ${state.translate('sendYourFilesLink')} -
`; diff --git a/app/templates/download.js b/app/templates/download.js index eca55893..8e0c81c8 100644 --- a/app/templates/download.js +++ b/app/templates/download.js @@ -2,7 +2,7 @@ const html = require('choo/html'); const progress = require('./progress'); const { bytes } = require('../utils'); -module.exports = function(state) { +module.exports = function(state, emit) { const transfer = state.transfer; const div = html`
@@ -24,11 +24,20 @@ module.exports = function(state) { transfer.msg, transfer.sizes )}
+
`; + function cancel() { + const btn = document.getElementById('cancel-upload'); + btn.remove(); + emit('cancel'); + } return div; }; diff --git a/app/templates/downloadButton.js b/app/templates/downloadButton.js new file mode 100644 index 00000000..be01333f --- /dev/null +++ b/app/templates/downloadButton.js @@ -0,0 +1,16 @@ +const html = require('choo/html'); + +module.exports = function(state, emit) { + function download(event) { + event.preventDefault(); + emit('download', state.fileInfo); + } + + return html` +
+ +
`; +}; diff --git a/app/templates/downloadPassword.js b/app/templates/downloadPassword.js index 4ce13608..dea30a03 100644 --- a/app/templates/downloadPassword.js +++ b/app/templates/downloadPassword.js @@ -5,8 +5,9 @@ module.exports = function(state, emit) { const label = fileInfo.password === null ? html` - ` + ` : html`