diff --git a/package-lock.json b/package-lock.json index f72d052..43d614a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 26f5b6d..dda612d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Message/InlineMedia.tsx b/src/components/Message/InlineMedia.tsx index d1a92f5..11cd4a5 100644 --- a/src/components/Message/InlineMedia.tsx +++ b/src/components/Message/InlineMedia.tsx @@ -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> @@ -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 (
@@ -44,16 +72,20 @@ export const InlineFile = ({ file }: InlineFileProps) => { export const InlineMedia = ({ magnetURI }: InlineMediaProps) => { const [hasDownloadInitiated, setHasDownloadInitiated] = useState(false) const [downloadedFiles, setDownloadedFiles] = useState([]) + 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 ( <> diff --git a/src/components/Room/RoomFileUploadControls.tsx b/src/components/Room/RoomFileUploadControls.tsx index f05314a..161b1ea 100644 --- a/src/components/Room/RoomFileUploadControls.tsx +++ b/src/components/Room/RoomFileUploadControls.tsx @@ -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 ? : + return ( - {isSharingFile ? : } + {isFileSharingEnabled ? ( + buttonIcon + ) : ( + + )} diff --git a/src/components/Room/useRoom.ts b/src/components/Room/useRoom.ts index 0816854..8435cb5 100644 --- a/src/components/Room/useRoom.ts +++ b/src/components/Room/useRoom.ts @@ -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, diff --git a/src/components/Room/useRoomFileShare.ts b/src/components/Room/useRoomFileShare.ts index 5483c97..83270be 100644 --- a/src/components/Room/useRoomFileShare.ts +++ b/src/components/Room/useRoomFileShare.ts @@ -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) diff --git a/src/components/Shell/PeerDownloadFileButton.tsx b/src/components/Shell/PeerDownloadFileButton.tsx index 48b14fc..235ba1f 100644 --- a/src/components/Shell/PeerDownloadFileButton.tsx +++ b/src/components/Shell/PeerDownloadFileButton.tsx @@ -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, { diff --git a/src/services/FileTransfer/FileTransfer.ts b/src/services/FileTransfer/FileTransfer.ts index fc9d779..b31c803 100644 --- a/src/services/FileTransfer/FileTransfer.ts +++ b/src/services/FileTransfer/FileTransfer.ts @@ -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 = {} - private async saveTorrentFiles(torrent: Torrent) { + private async saveTorrentFiles(torrent: Torrent, password: string) { for (const file of torrent.files) { try { - await new Promise((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[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(res => { this.webTorrentClient.seed( - files, + encryptedFiles, { announce: trackerUrls, // If the user is using their browser's private mode, IndexedDB will diff --git a/src/setupTests.ts b/src/setupTests.ts index 150416a..bee9a8e 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -12,3 +12,8 @@ jest.mock('webtorrent', () => ({ __esModule: true, default: class WebTorrent {}, })) + +jest.mock('wormhole-crypto', () => ({ + __esModule: true, + Keychain: class Keychain {}, +}))