implement file decryption
This commit is contained in:
parent
ec540f9718
commit
b35c57f591
|
@ -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
|
||||
}
|
|
@ -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 '/'
|
||||
|
|
|
@ -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
|
||||
# <https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string>
|
||||
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
|
||||
}
|
|
@ -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) ->
|
||||
|
|
|
@ -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
|
||||
|
|
91
src/web/fileDecrypter.coffee
Normal file
91
src/web/fileDecrypter.coffee
Normal file
|
@ -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: ->
|
||||
<div className="content-pastebin">{
|
||||
if not @state.name
|
||||
<p>Loading...</p>
|
||||
else
|
||||
<div className="content-file-info">
|
||||
<p>{@state.name}</p>
|
||||
<p>{@state.mime}</p>
|
||||
<p>{util.humanFileSize @state.length}</p>
|
||||
<button
|
||||
className="button-blue"
|
||||
disabled={@state.downloading}
|
||||
onClick={@downloadFile}
|
||||
>{
|
||||
if not @state.downloading
|
||||
"Download"
|
||||
else if @state.decrypting
|
||||
"Decrypting"
|
||||
else
|
||||
util.progressText @state.progress
|
||||
}</button>
|
||||
</div>
|
||||
}</div>
|
||||
|
||||
export default FileDecrypter
|
15
src/web/fileViewerDispatcher.coffee
Normal file
15
src/web/fileViewerDispatcher.coffee
Normal file
|
@ -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"
|
||||
<FileDecrypter id={@props.match.params.id} />
|
||||
else
|
||||
<CodeViewer id={@props.match.params.id} />
|
||||
|
||||
export default FileViewerDispatcher
|
|
@ -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
|
|||
/>
|
||||
<Route
|
||||
path="/paste/:id"
|
||||
component={CodeViewer}
|
||||
component={FileViewerDispatcher}
|
||||
/>
|
||||
</AnimatedSwitch>
|
||||
</Router>
|
||||
|
|
|
@ -15,3 +15,10 @@
|
|||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.content-file-info {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
Loading…
Reference in a new issue