diff --git a/.gitignore b/.gitignore index f06235c4..82ee25a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules dist +.idea diff --git a/README.md b/README.md index c9ac6f68..0e6e2d88 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,13 @@ The server is configured with environment variables. See [server/config.js](serv ## Localization -Firefox Send localization is managed via [Pontoon](https://pontoon.mozilla.org/projects/test-pilot-firefox-send/), not direct pull requests to the repository. If you want to fix a typo, add a new language, or simply know more about localization, please get in touch with the [existing localization team](https://pontoon.mozilla.org/teams/) for your language, or Mozilla’s [l10n-drivers](https://wiki.mozilla.org/L10n:Mozilla_Team#Mozilla_Corporation) for guidance. +Firefox Send localization is managed via [Pontoon](https://pontoon.mozilla.org/projects/test-pilot-firefox-send/), not direct pull requests to the repository. If you want to fix a typo, add a new language, or simply know more about localization, please get in touch with the [existing localization team](https://pontoon.mozilla.org/teams/) for your language or Mozilla’s [l10n-drivers](https://wiki.mozilla.org/L10n:Mozilla_Team#Mozilla_Corporation) for guidance. --- ## Contributing -Pull requests are always welcome! Feel free to check out the list of ["good first bugs"](https://github.com/mozilla/send/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+bug%22). +Pull requests are always welcome! Feel free to check out the list of ["good first issues"](https://github.com/mozilla/send/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22). --- diff --git a/app/api.js b/app/api.js new file mode 100644 index 00000000..5986ff9f --- /dev/null +++ b/app/api.js @@ -0,0 +1,213 @@ +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 fileInfo(id, owner_token) { + const response = await fetch(`/api/info/${id}`, post({ owner_token })); + if (response.ok) { + const obj = await response.json(); + return obj; + } + throw new Error(response.status); +} + +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 { + 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/experiments.js b/app/experiments.js index 37bce4e5..0b27b358 100644 --- a/app/experiments.js +++ b/app/experiments.js @@ -1,10 +1,19 @@ import hash from 'string-hash'; const experiments = { - 'SyI-hI7gT9agiH-f3f0BYg': { - id: 'SyI-hI7gT9agiH-f3f0BYg', + S9wqVl2SQ4ab2yZtqDI3Dw: { + id: 'S9wqVl2SQ4ab2yZtqDI3Dw', run: function(variant, state, emitter) { - state.promo = variant === 1 ? 'body' : 'header'; + switch (variant) { + case 1: + state.promo = 'blue'; + break; + case 2: + state.promo = 'pink'; + break; + default: + state.promo = 'grey'; + } emitter.emit('render'); }, eligible: function() { @@ -14,7 +23,11 @@ const experiments = { ); }, variant: function(state) { - return this.luckyNumber(state) > 0.5 ? 1 : 0; + const n = this.luckyNumber(state); + if (n < 0.33) { + return 0; + } + return n < 0.66 ? 1 : 2; }, luckyNumber: function(state) { return luckyNumber( diff --git a/app/fileManager.js b/app/fileManager.js index 588fd93a..b9161502 100644 --- a/app/fileManager.js +++ b/app/fileManager.js @@ -1,57 +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; -} - -function exists(id) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { - resolve(xhr.status === 200); - } - }; - xhr.onerror = () => resolve(false); - xhr.ontimeout = () => resolve(false); - xhr.open('get', '/api/exists/' + id); - xhr.timeout = 2000; - xhr.send(); - }); -} - export default function(state, emitter) { let lastRender = 0; let updateTitle = false; @@ -64,10 +22,14 @@ export default function(state, emitter) { const files = state.storage.files; let rerender = false; for (const file of files) { - const ok = await exists(file.id); - if (!ok) { + const oldLimit = file.dlimit; + const oldTotal = file.dtotal; + await file.updateDownloadCount(); + if (file.dtotal === file.dlimit) { state.storage.remove(file.id); rerender = true; + } else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) { + rerender = true; } } if (rerender) { @@ -91,12 +53,18 @@ 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 file.changeLimit(value); + state.storage.writeFile(file); + metrics.changedDownloadLimit(file); + }); + emitter.on('delete', async ({ file, location }) => { try { metrics.deletedUpload({ @@ -108,11 +76,10 @@ export default function(state, emitter) { location }); state.storage.remove(file.id); - await FileSender.delete(file.id, file.deleteToken); + await file.del(); } catch (e) { state.raven.captureException(e); } - state.fileInfo = null; }); emitter.on('cancel', () => { @@ -126,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.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; @@ -168,29 +127,27 @@ export default function(state, emitter) { emitter.on('password', async ({ password, file }) => { try { - await FileSender.setPassword(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'); } } @@ -206,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); @@ -217,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); @@ -236,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 281215ab..03347dcd 100644 --- a/app/fileReceiver.js +++ b/app/fileReceiver.js @@ -1,105 +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.key), - '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(); - 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.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() { @@ -114,135 +30,51 @@ export default class FileReceiver extends Nanobus { } cancel() { - // TODO - } - - 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; + this.cancelled = true; + if (this.fileDownload) { + this.fileDownload.cancel(); } } - downloadFile(sig) { - 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) { - return reject(new Error(xhr.status)); - } - - 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', `send-v1 ${arrayToB64(sig)}`); - 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 download(nonce) { + async download() { 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)); + 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 d5a00eec..5b1f356c 100644 --- a/app/fileSender.js +++ b/app/fileSender.js @@ -1,42 +1,19 @@ +/* global EXPIRE_SECONDS */ import Nanobus from 'nanobus'; -import { arrayToB64, b64ToArray, bytes } from './utils'; +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({ delete_token: token })); - }); + this.keychain = new Keychain(); } get progressRatio() { @@ -52,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(); } } @@ -61,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); @@ -71,221 +49,57 @@ 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), - deleteToken: responseObj.delete, - 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'] - ); + 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(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 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)) })); - }); + this.msg = 'fileSizeProgress'; + this.uploadRequest.onprogress = p => { + this.progress = p; + this.emit('progress', p); + }; + try { + 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 + }); + return ownedFile; + } catch (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..eacffec3 --- /dev/null +++ b/app/keychain.js @@ -0,0 +1,209 @@ +import { arrayToB64, b64ToArray } from './utils'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export default class Keychain { + constructor(secretKeyB64, nonce, ivB64) { + 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 && n !== this._nonce) { + 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 2c55e0ed..0b41b75d 100644 --- a/app/main.js +++ b/app/main.js @@ -1,3 +1,4 @@ +import 'fluent-intl-polyfill'; import app from './routes'; import locale from '../common/locales'; import fileManager from './fileManager'; @@ -14,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/metrics.js b/app/metrics.js index 3f3adf4e..bc6783f2 100644 --- a/app/metrics.js +++ b/app/metrics.js @@ -20,7 +20,7 @@ let experiment = null; export default function initialize(state, emitter) { appState = state; emitter.on('DOMContentLoaded', () => { - // addExitHandlers(); + addExitHandlers(); experiment = storage.enrolled[0]; sendEvent(category(), 'visit', { cm5: storage.totalUploads, @@ -29,9 +29,8 @@ export default function initialize(state, emitter) { }); //TODO restart handlers... somewhere }); - emitter.on('exit', evt => { - exitEvent(evt); - }); + emitter.on('exit', exitEvent); + emitter.on('experiment', experimentEvent); } function category() { @@ -205,6 +204,16 @@ function stoppedUpload(params) { }); } +function changedDownloadLimit(params) { + return sendEvent('sender', 'download-limit-changed', { + cm1: params.size, + cm5: storage.totalUploads, + cm6: storage.files.length, + cm7: storage.totalDownloads, + cm8: params.dlimit + }); +} + function completedDownload(params) { return sendEvent('recipient', 'download-stopped', { cm1: params.size, @@ -249,6 +258,10 @@ function exitEvent(target) { }); } +function experimentEvent(params) { + return sendEvent(category(), 'experiment', params); +} + // eslint-disable-next-line no-unused-vars function addExitHandlers() { const links = Array.from(document.querySelectorAll('a')); @@ -272,6 +285,7 @@ export { cancelledUpload, stoppedUpload, completedUpload, + changedDownloadLimit, deletedUpload, startedDownload, cancelledDownload, diff --git a/app/ownedFile.js b/app/ownedFile.js new file mode 100644 index 00000000..307621f1 --- /dev/null +++ b/app/ownedFile.js @@ -0,0 +1,77 @@ +import Keychain from './keychain'; +import { arrayToB64 } from './utils'; +import { del, fileInfo, setParams, setPassword } from './api'; + +export default class OwnedFile { + constructor(obj) { + 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); + this._hasPassword = !!obj.hasPassword; + } + + async setPassword(password) { + this.password = password; + this._hasPassword = true; + this.keychain.setPassword(password, this.url); + const result = await setPassword(this.id, this.ownerToken, this.keychain); + 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); + } + + get hasPassword() { + return !!this._hasPassword; + } + + async updateDownloadCount() { + try { + const result = await fileInfo(this.id, this.ownerToken); + this.dtotal = result.dtotal; + this.dlimit = result.dlimit; + } 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), + ownerToken: this.ownerToken, + dlimit: this.dlimit, + dtotal: this.dtotal, + hasPassword: this.hasPassword + }; + } +} diff --git a/app/templates/blank.js b/app/pages/blank.js similarity index 63% rename from app/templates/blank.js rename to app/pages/blank.js index 080a3232..ec104ac3 100644 --- a/app/templates/blank.js +++ b/app/pages/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/pages/completed.js b/app/pages/completed.js new file mode 100644 index 00000000..fa1fb896 --- /dev/null +++ b/app/pages/completed.js @@ -0,0 +1,34 @@ +const html = require('choo/html'); +const progress = require('../templates/progress'); +const { fadeOut } = require('../utils'); + +module.exports = function(state, emit) { + const div = html` +
+
+
+
+ ${state.translate('downloadFinish')} +
+
+ ${progress(1)} +
+
+
+
+ ${state.translate('sendYourFilesLink')} +
+
+ `; + + async function sendNew(e) { + e.preventDefault(); + await fadeOut('download'); + emit('pushState', '/'); + } + + return div; +}; diff --git a/app/pages/download.js b/app/pages/download.js new file mode 100644 index 00000000..eb6b8b37 --- /dev/null +++ b/app/pages/download.js @@ -0,0 +1,43 @@ +const html = require('choo/html'); +const progress = require('../templates/progress'); +const { bytes } = require('../utils'); + +module.exports = function(state, emit) { + const transfer = state.transfer; + const div = html` +
+
+
+
${state.translate( + 'downloadingPageProgress', + { + filename: state.fileInfo.name, + size: bytes(state.fileInfo.size) + } + )}
+
${state.translate( + 'downloadingPageMessage' + )}
+ ${progress(transfer.progressRatio)} +
+
${state.translate( + transfer.msg, + transfer.sizes + )}
+ +
+
+
+
+ `; + + function cancel() { + const btn = document.getElementById('cancel-upload'); + btn.remove(); + emit('cancel'); + } + return div; +}; diff --git a/app/templates/error.js b/app/pages/error.js similarity index 100% rename from app/templates/error.js rename to app/pages/error.js diff --git a/app/templates/legal.js b/app/pages/legal.js similarity index 100% rename from app/templates/legal.js rename to app/pages/legal.js diff --git a/app/templates/notFound.js b/app/pages/notFound.js similarity index 65% rename from app/templates/notFound.js rename to app/pages/notFound.js index c29bf194..495b8b08 100644 --- a/app/templates/notFound.js +++ b/app/pages/notFound.js @@ -9,12 +9,12 @@ module.exports = function(state) {
-
${state.translate( - 'uploadPageExplainer' - )}
- ${state.translate( - 'sendYourFilesLink' - )} +
+ ${state.translate('uploadPageExplainer')} +
+ + ${state.translate('sendYourFilesLink')} + `; return div; diff --git a/app/pages/preview.js b/app/pages/preview.js new file mode 100644 index 00000000..5916780d --- /dev/null +++ b/app/pages/preview.js @@ -0,0 +1,37 @@ +const html = require('choo/html'); +const assets = require('../../common/assets'); +const { bytes } = require('../utils'); + +module.exports = function(state, pageAction) { + const fileInfo = state.fileInfo; + const size = fileInfo.size + ? state.translate('downloadFileSize', { size: bytes(fileInfo.size) }) + : ''; + + const title = fileInfo.name + ? state.translate('downloadFileName', { filename: fileInfo.name }) + : state.translate('downloadFileTitle'); + const div = html` +
+
+
+
+ ${title} + ${' ' + size} +
+
${state.translate('downloadMessage')}
+ ${state.translate('downloadAltText')} + ${pageAction} +
+ ${state.translate('sendYourFilesLink')} +
+
+ `; + return div; +}; diff --git a/app/templates/share.js b/app/pages/share.js similarity index 51% rename from app/templates/share.js rename to app/pages/share.js index 3efeb6bd..5fa8b127 100644 --- a/app/templates/share.js +++ b/app/pages/share.js @@ -1,16 +1,28 @@ +/* global EXPIRE_SECONDS */ const html = require('choo/html'); const assets = require('../../common/assets'); const notFound = require('./notFound'); -const uploadPassword = require('./uploadPassword'); +const uploadPasswordSet = require('../templates/uploadPasswordSet'); +const uploadPasswordUnset = require('../templates/uploadPasswordUnset'); +const selectbox = require('../templates/selectbox'); const { allowedCopy, delay, fadeOut } = require('../utils'); -function passwordComplete(state, password) { +function expireInfo(file, translate, emit) { + const hours = Math.floor(EXPIRE_SECONDS / 60 / 60); const el = html([ - `
${state.translate('passwordResult', { - password: '
'
+    `
${translate('expireInfo', { + downloadCount: '', + timespan: translate('timespanHours', { num: hours }) })}
` ]); - el.lastElementChild.textContent = password; + const select = el.querySelector('select'); + const options = [1, 2, 3, 4, 5, 20].filter(i => i > (file.dtotal || 0)); + const t = num => translate('downloadCount', { num }); + const changed = value => emit('changeLimit', { file, value }); + select.parentNode.replaceChild( + selectbox(file.dlimit || 1, options, t, changed), + select + ); return el; } @@ -20,19 +32,16 @@ module.exports = function(state, emit) { return notFound(state, emit); } - file.password = file.password || ''; - - const passwordSection = file.password - ? passwordComplete(state, file.password) - : uploadPassword(state, emit); + const passwordSection = file.hasPassword + ? uploadPasswordSet(state, emit) + : uploadPasswordUnset(state, emit); const div = html`