implement file decryption
This commit is contained in:
parent
ec540f9718
commit
b35c57f591
|
@ -2,9 +2,16 @@ utf8Bytes = (str) ->
|
||||||
new TextEncoder 'utf-8'
|
new TextEncoder 'utf-8'
|
||||||
.encode str
|
.encode str
|
||||||
|
|
||||||
|
fromUtf8Bytes = (bytes) ->
|
||||||
|
new TextDecoder 'utf-8'
|
||||||
|
.decode bytes
|
||||||
|
|
||||||
hex = (buf) -> (Array.prototype.map.call new Uint8Array(buf),
|
hex = (buf) -> (Array.prototype.map.call new Uint8Array(buf),
|
||||||
(x) => ('00' + x.toString 16).slice(-2)).join ''
|
(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) ->
|
HMAC_SHA256_KEY = (buf) ->
|
||||||
crypto.subtle.importKey 'raw', buf,
|
crypto.subtle.importKey 'raw', buf,
|
||||||
{ name: 'HMAC', hash: 'SHA-256' }, true, [ 'sign' ]
|
{ name: 'HMAC', hash: 'SHA-256' }, true, [ 'sign' ]
|
||||||
|
@ -41,10 +48,36 @@ encryptFile = (file) ->
|
||||||
exportedKey = hex await crypto.subtle.exportKey 'raw', key
|
exportedKey = hex await crypto.subtle.exportKey 'raw', key
|
||||||
[exportedKey, hex(iv), name, mime, encrypted]
|
[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 {
|
export {
|
||||||
utf8Bytes,
|
utf8Bytes,
|
||||||
hex,
|
hex,
|
||||||
HMAC_SHA256,
|
HMAC_SHA256,
|
||||||
SHA256,
|
SHA256,
|
||||||
encryptFile
|
encryptFile,
|
||||||
|
decryptMetadata,
|
||||||
|
decryptFile
|
||||||
}
|
}
|
|
@ -86,6 +86,10 @@ handleGET = (req, file) ->
|
||||||
if not files.Contents or files.Contents.length == 0
|
if not files.Contents or files.Contents.length == 0
|
||||||
return new Response "Not Found",
|
return new Response "Not Found",
|
||||||
status: 404
|
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
|
# The full path to the original file
|
||||||
fullPath = files.Contents[0].Key
|
fullPath = files.Contents[0].Key
|
||||||
fileName = fullPath.split '/'
|
fileName = fullPath.split '/'
|
||||||
|
|
|
@ -51,6 +51,47 @@ isBrowser = (req) ->
|
||||||
b = detectBrowser req.headers.get 'user-agent'
|
b = detectBrowser req.headers.get 'user-agent'
|
||||||
b and (b.name != 'searchbot')
|
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 {
|
export {
|
||||||
getFileName,
|
getFileName,
|
||||||
validateLength,
|
validateLength,
|
||||||
|
@ -59,5 +100,8 @@ export {
|
||||||
idToPath,
|
idToPath,
|
||||||
shouldShowInline,
|
shouldShowInline,
|
||||||
isBrowser,
|
isBrowser,
|
||||||
isText
|
isText,
|
||||||
|
progressText,
|
||||||
|
browserSaveFile,
|
||||||
|
humanFileSize
|
||||||
}
|
}
|
|
@ -2,6 +2,7 @@ import React from "react"
|
||||||
import { Redirect } from "react-router-dom"
|
import { Redirect } from "react-router-dom"
|
||||||
import Dropzone from "react-dropzone"
|
import Dropzone from "react-dropzone"
|
||||||
import * as crypto from "../crypto"
|
import * as crypto from "../crypto"
|
||||||
|
import * as util from "../util"
|
||||||
|
|
||||||
class BinaryUpload extends React.Component
|
class BinaryUpload extends React.Component
|
||||||
constructor: (props) ->
|
constructor: (props) ->
|
||||||
|
@ -62,11 +63,7 @@ class BinaryUpload extends React.Component
|
||||||
encrypting: false
|
encrypting: false
|
||||||
|
|
||||||
progressText: ->
|
progressText: ->
|
||||||
txt = (@state.progress * 100).toFixed(2) + "%"
|
util.progressText @state.progress
|
||||||
if @state.progress < 0.1
|
|
||||||
"0" + txt
|
|
||||||
else
|
|
||||||
txt
|
|
||||||
|
|
||||||
toggleEncrypt: =>
|
toggleEncrypt: =>
|
||||||
@setState (state, props) ->
|
@setState (state, props) ->
|
||||||
|
|
|
@ -13,7 +13,7 @@ class CodeViewer extends React.Component
|
||||||
highlight: true
|
highlight: true
|
||||||
|
|
||||||
componentDidMount: ->
|
componentDidMount: ->
|
||||||
resp = await fetch "/paste/#{@props.match.params.id}?original"
|
resp = await fetch "/paste/#{@props.id}?original"
|
||||||
resp = await resp.text()
|
resp = await resp.text()
|
||||||
if resp.length < MAX_HIGHLIGHT_LENGTH
|
if resp.length < MAX_HIGHLIGHT_LENGTH
|
||||||
resp = hljs.highlightAuto(resp).value
|
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 ReactModal from "react-modal"
|
||||||
import Pastebin from "./pastebin"
|
import Pastebin from "./pastebin"
|
||||||
import BinaryUpload from "./binaryUpload"
|
import BinaryUpload from "./binaryUpload"
|
||||||
import CodeViewer from "./codeViewer"
|
import FileViewerDispatcher from "./fileViewerDispatcher"
|
||||||
|
|
||||||
class Home extends React.Component
|
class Home extends React.Component
|
||||||
constructor: (props) ->
|
constructor: (props) ->
|
||||||
|
@ -39,7 +39,7 @@ class Home extends React.Component
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/paste/:id"
|
path="/paste/:id"
|
||||||
component={CodeViewer}
|
component={FileViewerDispatcher}
|
||||||
/>
|
/>
|
||||||
</AnimatedSwitch>
|
</AnimatedSwitch>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
@ -14,4 +14,11 @@
|
||||||
text-align: left;
|
text-align: left;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-file-info {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
Loading…
Reference in a new issue