implement file decryption

This commit is contained in:
Peter Cai 2020-02-19 11:03:57 +08:00
parent ec540f9718
commit b35c57f591
No known key found for this signature in database
GPG Key ID: 71F5FB4E4F3FD54F
9 changed files with 201 additions and 10 deletions

View File

@ -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
}

View File

@ -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 '/'

View File

@ -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
}

View File

@ -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) ->

View File

@ -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

View 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

View 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

View File

@ -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>

View File

@ -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%);
}