diff --git a/src/crypto.coffee b/src/crypto.coffee index c8b7922..3f18f03 100644 --- a/src/crypto.coffee +++ b/src/crypto.coffee @@ -2,9 +2,16 @@ utf8Bytes = (str) -> new TextEncoder 'utf-8' .encode str +fromUtf8Bytes = (bytes) -> + new TextDecoder 'utf-8' + .decode bytes + hex = (buf) -> (Array.prototype.map.call new Uint8Array(buf), (x) => ('00' + x.toString 16).slice(-2)).join '' +fromHex = (str) -> + new Uint8Array str.match(/.{1,2}/g).map (byte) => parseInt byte, 16 + HMAC_SHA256_KEY = (buf) -> crypto.subtle.importKey 'raw', buf, { name: 'HMAC', hash: 'SHA-256' }, true, [ 'sign' ] @@ -41,10 +48,36 @@ encryptFile = (file) -> exportedKey = hex await crypto.subtle.exportKey 'raw', key [exportedKey, hex(iv), name, mime, encrypted] +importKeyAndIv = (key, iv) -> + key = fromHex key + iv = fromHex iv + key = await crypto.subtle.importKey 'raw', key, + { name: "AES-GCM" }, false, ['encrypt', 'decrypt'] + algoParams = + name: 'AES-GCM' + iv: iv + tagLength: 128 + [key, algoParams] + +# Decrypt the name and mime-type of an encrypted file +decryptMetadata = (key, iv, name, mime) -> + [key, algoParams] = await importKeyAndIv key, iv + name = fromUtf8Bytes await crypto.subtle.decrypt algoParams, key, fromHex name + mime = fromHex mime.replace /^binary\//, "" + mime = fromUtf8Bytes await crypto.subtle.decrypt algoParams, key, mime + [name, mime] + +# Decrypt an encrypted ArrayBuffer +decryptFile = (key, iv, file) -> + [key, algoParams] = await importKeyAndIv key, iv + await crypto.subtle.decrypt algoParams, key, file + export { utf8Bytes, hex, HMAC_SHA256, SHA256, - encryptFile + encryptFile, + decryptMetadata, + decryptFile } \ No newline at end of file diff --git a/src/index.coffee b/src/index.coffee index cf20d21..ab195d9 100644 --- a/src/index.coffee +++ b/src/index.coffee @@ -86,6 +86,10 @@ handleGET = (req, file) -> if not files.Contents or files.Contents.length == 0 return new Response "Not Found", status: 404 + else if req.url.endsWith "crypt" + # We need frontend to handle encrypted files + # The key is passed after the hash ('#'), unavailable to server + return buildFrontendResponse _ # The full path to the original file fullPath = files.Contents[0].Key fileName = fullPath.split '/' diff --git a/src/util.coffee b/src/util.coffee index a7a029c..8301295 100644 --- a/src/util.coffee +++ b/src/util.coffee @@ -51,6 +51,47 @@ isBrowser = (req) -> b = detectBrowser req.headers.get 'user-agent' b and (b.name != 'searchbot') +# Process progress text +progressText = (progress) -> + txt = (progress * 100).toFixed(2) + "%" + if progress < 0.1 + "0" + txt + else + txt + +# Browser: save a file from ArrayBuffer +browserSaveFile = (mime, name, file) -> + link = document.createElement 'a' + link.style.display = 'none'; + document.body.appendChild link + + blob = new Blob [file], + type: mime + objUrl = URL.createObjectURL blob + + link.href = objUrl + link.download = name + link.click() + +# Convert a file size to human-readable form +# +humanFileSize = (bytes, si) -> + thresh = if si then 1000 else 1024 + if Math.abs bytes < thresh + bytes + " B" + else + units = do -> + if si + ['kB','MB','GB','TB','PB','EB','ZB','YB'] + else + ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB'] + u = -1 + loop + bytes /= thresh + ++u + break if not (Math.abs(bytes) >= thresh and u < units.length - 1) + bytes.toFixed(1) + ' ' + units[u] + export { getFileName, validateLength, @@ -59,5 +100,8 @@ export { idToPath, shouldShowInline, isBrowser, - isText + isText, + progressText, + browserSaveFile, + humanFileSize } \ No newline at end of file diff --git a/src/web/binaryUpload.coffee b/src/web/binaryUpload.coffee index 063ee7c..b6b49dc 100644 --- a/src/web/binaryUpload.coffee +++ b/src/web/binaryUpload.coffee @@ -2,6 +2,7 @@ import React from "react" import { Redirect } from "react-router-dom" import Dropzone from "react-dropzone" import * as crypto from "../crypto" +import * as util from "../util" class BinaryUpload extends React.Component constructor: (props) -> @@ -62,11 +63,7 @@ class BinaryUpload extends React.Component encrypting: false progressText: -> - txt = (@state.progress * 100).toFixed(2) + "%" - if @state.progress < 0.1 - "0" + txt - else - txt + util.progressText @state.progress toggleEncrypt: => @setState (state, props) -> diff --git a/src/web/codeViewer.coffee b/src/web/codeViewer.coffee index 7a30275..748d21d 100644 --- a/src/web/codeViewer.coffee +++ b/src/web/codeViewer.coffee @@ -13,7 +13,7 @@ class CodeViewer extends React.Component highlight: true componentDidMount: -> - resp = await fetch "/paste/#{@props.match.params.id}?original" + resp = await fetch "/paste/#{@props.id}?original" resp = await resp.text() if resp.length < MAX_HIGHLIGHT_LENGTH resp = hljs.highlightAuto(resp).value diff --git a/src/web/fileDecrypter.coffee b/src/web/fileDecrypter.coffee new file mode 100644 index 0000000..ecc7d90 --- /dev/null +++ b/src/web/fileDecrypter.coffee @@ -0,0 +1,91 @@ +import React from "react" +import * as crypto from "../crypto" +import * as util from "../util" + +class FileDecrypter extends React.Component + constructor: (props) -> + super props + @originalUrl = "/paste/#{props.id}?original" + # We simply let it fail if there's no key / iv provided in window.location.hash + # also we don't care about decryption failure. Just let it look like a broken page + # if someone tries to brute-force + [key, iv] = window.location.hash.replace("#", "").split '+' + @state = + name: null + mime: null + length: null + downloading: false + decrypting: false + progress: 0 + key: key + iv: iv + + componentDidMount: -> + # Fetch metadata to show to user + # We can use fetch API here + resp = await fetch @originalUrl + # Fail silently as explained above + return if not resp.ok + mime = resp.headers.get 'content-type' + [_, name] = resp.headers.get 'content-disposition' + .split 'filename*=' + [name, mime] = await crypto.decryptMetadata @state.key, @state.iv, name, mime + @setState + name: name + mime: mime + length: parseInt resp.headers.get 'content-length' + + downloadFile: => + @setState + downloading: true + decrypting: false + progress: 0 + # For progress, we have to use XHR + xhr = new XMLHttpRequest() + xhr.responseType = "arraybuffer" + xhr.addEventListener 'progress', (e) => + if e.lengthComputable + @setState + progress: e.loaded / e.total + xhr.addEventListener 'readystatechange', => + if xhr.readyState == XMLHttpRequest.DONE + @setState + downloading: false + return if xhr.status != 200 # We always fail silently here + @decryptFile xhr.response + xhr.open 'GET', @originalUrl + xhr.send() + + decryptFile: (file) => + @setState + decrypting: true + decrypted = await crypto.decryptFile @state.key, @state.iv, file + util.browserSaveFile @state.mime, @state.name, decrypted + @setState + decrypting: false + + render: -> +
{ + if not @state.name +

Loading...

+ else +
+

{@state.name}

+

{@state.mime}

+

{util.humanFileSize @state.length}

+ +
+ }
+ +export default FileDecrypter \ No newline at end of file diff --git a/src/web/fileViewerDispatcher.coffee b/src/web/fileViewerDispatcher.coffee new file mode 100644 index 0000000..3c81a6d --- /dev/null +++ b/src/web/fileViewerDispatcher.coffee @@ -0,0 +1,15 @@ +import React from "react" +import CodeViewer from "./codeViewer" +import FileDecrypter from "./fileDecrypter" + +class FileViewerDispatcher extends React.Component + constructor: (props) -> + super props + + render: -> + if @props.location.search == "?crypt" + + else + + +export default FileViewerDispatcher \ No newline at end of file diff --git a/src/web/home.coffee b/src/web/home.coffee index 4407968..ee8b678 100644 --- a/src/web/home.coffee +++ b/src/web/home.coffee @@ -4,7 +4,7 @@ import { AnimatedSwitch } from 'react-router-transition' import ReactModal from "react-modal" import Pastebin from "./pastebin" import BinaryUpload from "./binaryUpload" -import CodeViewer from "./codeViewer" +import FileViewerDispatcher from "./fileViewerDispatcher" class Home extends React.Component constructor: (props) -> @@ -39,7 +39,7 @@ class Home extends React.Component /> diff --git a/src/web/styles/pastebin.scss b/src/web/styles/pastebin.scss index 5a741c9..57e272b 100644 --- a/src/web/styles/pastebin.scss +++ b/src/web/styles/pastebin.scss @@ -14,4 +14,11 @@ text-align: left; box-sizing: border-box; font-family: monospace; +} + +.content-file-info { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } \ No newline at end of file