* feat: [#86] encrypt torrent data before upload * feat: [#86] decrypt torrent data after download * feat: [#86] use room ID as encryption key * feat: [#86] show alerts for encryption activity * feat: [#86] show progress indicator while encrypting files
This commit is contained in:
parent
8493ddade5
commit
d7287b5f6d
54
package-lock.json
generated
54
package-lock.json
generated
@ -36,6 +36,8 @@
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"readable-stream-node-to-web": "^1.0.1",
|
||||
"readable-web-to-node-stream": "^3.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.54.3",
|
||||
"streamsaver": "^2.0.6",
|
||||
@ -45,7 +47,8 @@
|
||||
"typescript": "^4.7.4",
|
||||
"uuid": "^8.3.2",
|
||||
"web-vitals": "^2.1.4",
|
||||
"webtorrent": "^1.9.4"
|
||||
"webtorrent": "^1.9.4",
|
||||
"wormhole-crypto": "^0.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.5",
|
||||
@ -22489,6 +22492,26 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream-node-to-web": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream-node-to-web/-/readable-stream-node-to-web-1.0.1.tgz",
|
||||
"integrity": "sha512-OGzi2VKLa8H259kAx7BIwuRrXHGcxeHj4RdASSgEGBP9Q2wowdPvBc65upF4Q9O05qWgKqBw1+9PiLTtObl7uQ=="
|
||||
},
|
||||
"node_modules/readable-web-to-node-stream": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
|
||||
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
|
||||
"dependencies": {
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@ -26674,6 +26697,14 @@
|
||||
"workbox-core": "6.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/wormhole-crypto": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wormhole-crypto/-/wormhole-crypto-0.3.1.tgz",
|
||||
"integrity": "sha512-P6dsua8VTTbaLozTSbeDWBuXcoAJABH8a+XutfMg9Vb2a/roUPLFvkgtuJcXorp8hwLM5RguQEWtMZqgpJYohA==",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
@ -43199,6 +43230,19 @@
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"readable-stream-node-to-web": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream-node-to-web/-/readable-stream-node-to-web-1.0.1.tgz",
|
||||
"integrity": "sha512-OGzi2VKLa8H259kAx7BIwuRrXHGcxeHj4RdASSgEGBP9Q2wowdPvBc65upF4Q9O05qWgKqBw1+9PiLTtObl7uQ=="
|
||||
},
|
||||
"readable-web-to-node-stream": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
|
||||
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
|
||||
"requires": {
|
||||
"readable-stream": "^3.6.0"
|
||||
}
|
||||
},
|
||||
"readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@ -46280,6 +46324,14 @@
|
||||
"workbox-core": "6.5.4"
|
||||
}
|
||||
},
|
||||
"wormhole-crypto": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wormhole-crypto/-/wormhole-crypto-0.3.1.tgz",
|
||||
"integrity": "sha512-P6dsua8VTTbaLozTSbeDWBuXcoAJABH8a+XutfMg9Vb2a/roUPLFvkgtuJcXorp8hwLM5RguQEWtMZqgpJYohA==",
|
||||
"requires": {
|
||||
"base64-js": "^1.5.1"
|
||||
}
|
||||
},
|
||||
"wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
@ -32,6 +32,8 @@
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"readable-stream-node-to-web": "^1.0.1",
|
||||
"readable-web-to-node-stream": "^3.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.54.3",
|
||||
"streamsaver": "^2.0.6",
|
||||
@ -41,7 +43,8 @@
|
||||
"typescript": "^4.7.4",
|
||||
"uuid": "^8.3.2",
|
||||
"web-vitals": "^2.1.4",
|
||||
"webtorrent": "^1.9.4"
|
||||
"webtorrent": "^1.9.4",
|
||||
"wormhole-crypto": "^0.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { TorrentFile } from 'webtorrent'
|
||||
import { ReadableWebToNodeStream } from 'readable-web-to-node-stream'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { Typography } from '@mui/material'
|
||||
|
||||
import { fileTransfer } from 'services/FileTransfer'
|
||||
import { Typography } from '@mui/material'
|
||||
import { ShellContext } from 'contexts/ShellContext'
|
||||
|
||||
type TorrentFiles = Awaited<ReturnType<typeof fileTransfer.download>>
|
||||
|
||||
@ -17,18 +20,43 @@ interface InlineFileProps {
|
||||
export const InlineFile = ({ file }: InlineFileProps) => {
|
||||
const containerRef = useRef(null)
|
||||
const [didRenderingMediaFail, setDidRenderingMediaFail] = useState(false)
|
||||
const shellContext = useContext(ShellContext)
|
||||
|
||||
useEffect(() => {
|
||||
const { current: container } = containerRef
|
||||
;(async () => {
|
||||
const { current: container } = containerRef
|
||||
|
||||
if (!container) return
|
||||
if (!container) return
|
||||
|
||||
try {
|
||||
file.appendTo(container)
|
||||
} catch (e) {
|
||||
setDidRenderingMediaFail(true)
|
||||
}
|
||||
}, [file, containerRef])
|
||||
try {
|
||||
if (typeof shellContext.roomId !== 'string') {
|
||||
throw new Error('shellContext.roomId is not a string')
|
||||
}
|
||||
|
||||
const readStream: NodeJS.ReadableStream = new ReadableWebToNodeStream(
|
||||
await fileTransfer.getDecryptedFileReadStream(
|
||||
file,
|
||||
shellContext.roomId
|
||||
)
|
||||
// ReadableWebToNodeStream is the same as NodeJS.ReadableStream. The
|
||||
// library's typing is wrong.
|
||||
) as any
|
||||
|
||||
const decryptedFile: TorrentFile = {
|
||||
...file,
|
||||
createReadStream: () => {
|
||||
return readStream
|
||||
},
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(decryptedFile, Object.getPrototypeOf(file))
|
||||
decryptedFile.appendTo(container)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setDidRenderingMediaFail(true)
|
||||
}
|
||||
})()
|
||||
}, [file, containerRef, shellContext.roomId])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
@ -44,16 +72,20 @@ export const InlineFile = ({ file }: InlineFileProps) => {
|
||||
export const InlineMedia = ({ magnetURI }: InlineMediaProps) => {
|
||||
const [hasDownloadInitiated, setHasDownloadInitiated] = useState(false)
|
||||
const [downloadedFiles, setDownloadedFiles] = useState<TorrentFiles>([])
|
||||
const shellContext = useContext(ShellContext)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
if (hasDownloadInitiated) return
|
||||
if (typeof shellContext.roomId !== 'string') {
|
||||
throw new Error('shellContext.roomId is not a string')
|
||||
}
|
||||
|
||||
setHasDownloadInitiated(true)
|
||||
const files = await fileTransfer.download(magnetURI)
|
||||
const files = await fileTransfer.download(magnetURI, shellContext.roomId)
|
||||
setDownloadedFiles(files)
|
||||
})()
|
||||
}, [hasDownloadInitiated, magnetURI])
|
||||
}, [hasDownloadInitiated, magnetURI, shellContext.roomId])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -4,6 +4,7 @@ import UploadFile from '@mui/icons-material/UploadFile'
|
||||
import Cancel from '@mui/icons-material/Cancel'
|
||||
import Fab from '@mui/material/Fab'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
import { RoomContext } from 'contexts/RoomContext'
|
||||
import { PeerRoom } from 'services/PeerRoom/PeerRoom'
|
||||
@ -60,6 +61,8 @@ export function RoomFileUploadControls({
|
||||
|
||||
const disableFileUpload = !isFileSharingEnabled || isMessageSending
|
||||
|
||||
const buttonIcon = isSharingFile ? <Cancel /> : <UploadFile />
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@ -91,7 +94,11 @@ export function RoomFileUploadControls({
|
||||
onClick={handleToggleScreenShareButtonClick}
|
||||
disabled={disableFileUpload}
|
||||
>
|
||||
{isSharingFile ? <Cancel /> : <UploadFile />}
|
||||
{isFileSharingEnabled ? (
|
||||
buttonIcon
|
||||
) : (
|
||||
<CircularProgress variant="indeterminate" color="inherit" />
|
||||
)}
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
@ -322,7 +322,7 @@ export function useRoom(
|
||||
if (!showVideoDisplay && !isShowingMessages) setIsShowingMessages(true)
|
||||
|
||||
const handleInlineMediaUpload = async (files: File[]) => {
|
||||
const fileOfferId = await fileTransfer.offer(files)
|
||||
const fileOfferId = await fileTransfer.offer(files, roomId)
|
||||
|
||||
const unsentInlineMedia: UnsentInlineMedia = {
|
||||
authorId: userId,
|
||||
|
@ -32,7 +32,7 @@ export function useRoomFileShare({
|
||||
>(null)
|
||||
const [isFileSharingEnabled, setIsFileSharingEnabled] = useState(true)
|
||||
|
||||
const { peerList, setPeerList } = shellContext
|
||||
const { peerList, setPeerList, showAlert } = shellContext
|
||||
const { peerOfferedFileMetadata, setPeerOfferedFileMetadata } = roomContext
|
||||
|
||||
const [sendFileOfferMetadata, receiveFileOfferMetadata] =
|
||||
@ -121,7 +121,19 @@ export function useRoomFileShare({
|
||||
setSharedFiles(files)
|
||||
setIsFileSharingEnabled(false)
|
||||
|
||||
const magnetURI = await fileTransfer.offer(files)
|
||||
if (typeof shellContext.roomId !== 'string') {
|
||||
throw new Error('shellContext.roomId is not a string')
|
||||
}
|
||||
|
||||
const alertText =
|
||||
files.length > 1
|
||||
? 'Encrypting a copy of the files...'
|
||||
: 'Encrypting a copy of the file...'
|
||||
showAlert(alertText, { severity: 'info' })
|
||||
|
||||
const magnetURI = await fileTransfer.offer(files, shellContext.roomId)
|
||||
|
||||
showAlert('Encryption complete', { severity: 'success' })
|
||||
|
||||
if (inlineMediaFiles.length > 0) {
|
||||
onInlineMediaUpload(inlineMediaFiles)
|
||||
|
@ -38,7 +38,14 @@ export const PeerDownloadFileButton = ({
|
||||
setDownloadProgress(null)
|
||||
|
||||
try {
|
||||
await fileTransfer.download(offeredFileId, { doSave: true, onProgress })
|
||||
if (typeof shellContext.roomId !== 'string') {
|
||||
throw new Error('shellContext.roomId is not a string')
|
||||
}
|
||||
|
||||
await fileTransfer.download(offeredFileId, shellContext.roomId, {
|
||||
doSave: true,
|
||||
onProgress,
|
||||
})
|
||||
} catch (e) {
|
||||
if (isError(e)) {
|
||||
shellContext.showAlert(e.message, {
|
||||
|
@ -1,14 +1,36 @@
|
||||
import WebTorrent, { Torrent } from 'webtorrent'
|
||||
import WebTorrent, { Torrent, TorrentFile } from 'webtorrent'
|
||||
import streamSaver from 'streamsaver'
|
||||
// @ts-ignore
|
||||
import { Keychain, plaintextSize } from 'wormhole-crypto'
|
||||
// @ts-ignore
|
||||
import idbChunkStore from 'idb-chunk-store'
|
||||
import { detectIncognito } from 'detectincognitojs'
|
||||
|
||||
import { trackerUrls } from 'config/trackerUrls'
|
||||
import { streamSaverUrl } from 'config/streamSaverUrl'
|
||||
|
||||
import { ReadableWebToNodeStream } from 'readable-web-to-node-stream'
|
||||
// @ts-ignore
|
||||
import nodeToWebStream from 'readable-stream-node-to-web'
|
||||
|
||||
streamSaver.mitm = streamSaverUrl
|
||||
|
||||
interface NamedReadableWebToNodeStream extends NodeJS.ReadableStream {
|
||||
name?: string
|
||||
}
|
||||
|
||||
const getKeychain = (password: string) => {
|
||||
const encoder = new TextEncoder()
|
||||
const keyLength = 16
|
||||
const padding = new Array(keyLength).join('0')
|
||||
const key = password.concat(padding).slice(0, keyLength)
|
||||
const salt = window.location.origin.concat(padding).slice(0, keyLength)
|
||||
|
||||
const keychain = new Keychain(encoder.encode(key), encoder.encode(salt))
|
||||
|
||||
return keychain
|
||||
}
|
||||
|
||||
interface DownloadOpts {
|
||||
doSave?: boolean
|
||||
onProgress?: (progress: number) => void
|
||||
@ -19,51 +41,18 @@ export class FileTransfer {
|
||||
|
||||
private torrents: Record<Torrent['magnetURI'], Torrent> = {}
|
||||
|
||||
private async saveTorrentFiles(torrent: Torrent) {
|
||||
private async saveTorrentFiles(torrent: Torrent, password: string) {
|
||||
for (const file of torrent.files) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const fileStream = streamSaver.createWriteStream(file.name, {
|
||||
size: file.length,
|
||||
})
|
||||
const readStream = await this.getDecryptedFileReadStream(file, password)
|
||||
|
||||
const writeStream = fileStream.getWriter()
|
||||
const readStream = file.createReadStream()
|
||||
let aborted = false
|
||||
|
||||
const handleData = async (data: ArrayBuffer) => {
|
||||
try {
|
||||
await writeStream.write(data)
|
||||
} catch (e) {
|
||||
writeStream.abort()
|
||||
readStream.off('data', handleData)
|
||||
aborted = true
|
||||
reject()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforePageUnloadForFile = () => {
|
||||
// Clean up any broken downloads
|
||||
writeStream.abort()
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforePageUnloadForFile)
|
||||
|
||||
const handleEnd = async () => {
|
||||
window.removeEventListener(
|
||||
'beforeunload',
|
||||
handleBeforePageUnloadForFile
|
||||
)
|
||||
|
||||
if (aborted) return
|
||||
|
||||
await writeStream.close()
|
||||
resolve()
|
||||
}
|
||||
|
||||
readStream.on('data', handleData).on('end', handleEnd)
|
||||
const writeStream = streamSaver.createWriteStream(file.name, {
|
||||
size: plaintextSize(file.length),
|
||||
})
|
||||
|
||||
await readStream.pipeTo(writeStream)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw new Error('Download aborted')
|
||||
}
|
||||
}
|
||||
@ -73,7 +62,20 @@ export class FileTransfer {
|
||||
window.addEventListener('beforeunload', this.handleBeforePageUnload)
|
||||
}
|
||||
|
||||
async download(magnetURI: string, { onProgress, doSave }: DownloadOpts = {}) {
|
||||
async getDecryptedFileReadStream(file: TorrentFile, password: string) {
|
||||
const keychain = getKeychain(password)
|
||||
const readStream: ReadableStream = await keychain.decryptStream(
|
||||
nodeToWebStream(file.createReadStream())
|
||||
)
|
||||
|
||||
return readStream
|
||||
}
|
||||
|
||||
async download(
|
||||
magnetURI: string,
|
||||
password: string,
|
||||
{ onProgress, doSave }: DownloadOpts = {}
|
||||
) {
|
||||
let torrent = this.torrents[magnetURI]
|
||||
|
||||
if (!torrent) {
|
||||
@ -107,7 +109,7 @@ export class FileTransfer {
|
||||
|
||||
if (doSave) {
|
||||
try {
|
||||
await this.saveTorrentFiles(torrent)
|
||||
await this.saveTorrentFiles(torrent, password)
|
||||
} catch (e) {
|
||||
torrent.off('download', handleDownload)
|
||||
|
||||
@ -119,12 +121,35 @@ export class FileTransfer {
|
||||
return torrent.files
|
||||
}
|
||||
|
||||
async offer(files: Parameters<typeof this.webTorrentClient.seed>[0]) {
|
||||
async offer(files: File[] | FileList, password: string) {
|
||||
const { isPrivate } = await detectIncognito()
|
||||
|
||||
const filesToSeed: File[] =
|
||||
files instanceof FileList ? Array.from(files) : files
|
||||
|
||||
const encryptedFiles = await Promise.all(
|
||||
filesToSeed.map(async file => {
|
||||
const encryptedStream = await getKeychain(password).encryptStream(
|
||||
file.stream()
|
||||
)
|
||||
|
||||
// WebTorrent only accepts Node-style ReadableStreams
|
||||
const nodeStream: NamedReadableWebToNodeStream =
|
||||
new ReadableWebToNodeStream(
|
||||
encryptedStream
|
||||
// ReadableWebToNodeStream is the same as NodeJS.ReadableStream.
|
||||
// The library's typing is wrong.
|
||||
) as any as NodeJS.ReadableStream
|
||||
|
||||
nodeStream.name = file.name
|
||||
|
||||
return nodeStream
|
||||
})
|
||||
)
|
||||
|
||||
const torrent = await new Promise<Torrent>(res => {
|
||||
this.webTorrentClient.seed(
|
||||
files,
|
||||
encryptedFiles,
|
||||
{
|
||||
announce: trackerUrls,
|
||||
// If the user is using their browser's private mode, IndexedDB will
|
||||
|
@ -12,3 +12,8 @@ jest.mock('webtorrent', () => ({
|
||||
__esModule: true,
|
||||
default: class WebTorrent {},
|
||||
}))
|
||||
|
||||
jest.mock('wormhole-crypto', () => ({
|
||||
__esModule: true,
|
||||
Keychain: class Keychain {},
|
||||
}))
|
||||
|
Loading…
Reference in New Issue
Block a user