feat: [closes #86] Encrypted file transfers (#87)

* 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:
Jeremy Kahn 2023-02-11 17:29:57 -06:00 committed by GitHub
parent 8493ddade5
commit d7287b5f6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 207 additions and 64 deletions

54
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,3 +12,8 @@ jest.mock('webtorrent', () => ({
__esModule: true,
default: class WebTorrent {},
}))
jest.mock('wormhole-crypto', () => ({
__esModule: true,
Keychain: class Keychain {},
}))