289 lines
7.7 KiB
TypeScript
289 lines
7.7 KiB
TypeScript
import { useCallback, useContext, useEffect, useState } from "react";
|
|
import * as sodium from "libsodium-wrappers-sumo";
|
|
import useWebSocket from "react-use-websocket";
|
|
import CBOR from "cbor";
|
|
import { wsUrl } from "./constants";
|
|
import { RoomContext } from "./lib/roomContext";
|
|
import { useDebouncedCallback } from "use-debounce";
|
|
import {
|
|
Row,
|
|
Col,
|
|
Button,
|
|
Form,
|
|
Badge,
|
|
OverlayTrigger,
|
|
Tooltip,
|
|
} from "react-bootstrap";
|
|
|
|
export default function Upload() {
|
|
const { roomId } = useContext(RoomContext);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [encryptionKey, setEncryptionKey] = useState(null);
|
|
const [passphrase, setPassphrase] = useState("");
|
|
const [uploadProgress, setUploadProgress] = useState(null);
|
|
|
|
const setHashedPassphrase = useDebouncedCallback(
|
|
(passphrase) => {
|
|
if (passphrase) {
|
|
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
|
console.log("salt", salt);
|
|
setEncryptionKey(
|
|
sodium.crypto_pwhash(
|
|
sodium.crypto_box_SEEDBYTES,
|
|
passphrase,
|
|
salt,
|
|
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
|
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
|
sodium.crypto_pwhash_ALG_ARGON2ID13
|
|
)
|
|
);
|
|
}
|
|
},
|
|
// delay in ms
|
|
1000
|
|
);
|
|
|
|
const [decryptionKey, setDecryptionKey] = useState("");
|
|
const [selectedSaveFile, setSelectedSaveFile] = useState(null);
|
|
const [decryptState, setDecryptState] = useState(null);
|
|
const [pendingDecryption, setPendingDecryption] = useState([]);
|
|
|
|
let decryptionKeyInternal: Uint8Array;
|
|
if (decryptionKey) {
|
|
decryptionKeyInternal = hex2Bytes(decryptionKey);
|
|
}
|
|
const [writeStream, setWriteStream] = useState(null);
|
|
const readyToDownload =
|
|
!!decryptionKeyInternal && !!selectedSaveFile && !!writeStream;
|
|
|
|
const { sendMessage } = useWebSocket(wsUrl, {
|
|
share: true,
|
|
onMessage: async (event) => {
|
|
const message = CBOR.decode(await event.data.arrayBuffer());
|
|
|
|
if (message.type === "FileUploadBegin") {
|
|
const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
|
message.header,
|
|
decryptionKeyInternal
|
|
);
|
|
setDecryptState(state);
|
|
}
|
|
|
|
if (message.type === "FileUploadChunk") {
|
|
if (readyToDownload && decryptState) {
|
|
for (const { data } of [...pendingDecryption, message]) {
|
|
const { message } =
|
|
sodium.crypto_secretstream_xchacha20poly1305_pull(
|
|
decryptState,
|
|
data
|
|
);
|
|
const result = await writeStream.write(message);
|
|
}
|
|
setPendingDecryption([]);
|
|
} else {
|
|
setPendingDecryption([...pendingDecryption, message]);
|
|
}
|
|
}
|
|
|
|
if (message.type === "FileUploadComplete") {
|
|
await writeStream.close();
|
|
}
|
|
},
|
|
});
|
|
|
|
function sendCborMessage(data) {
|
|
let cbor = CBOR.encode(data);
|
|
sendMessage(cbor);
|
|
}
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
await sodium.ready;
|
|
if (passphrase) {
|
|
setHashedPassphrase(passphrase);
|
|
} else {
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
setEncryptionKey(key);
|
|
}
|
|
})();
|
|
}, [passphrase]);
|
|
|
|
const uploadFile = useCallback(async () => {
|
|
if (!encryptionKey) return;
|
|
if (!selectedFile) return;
|
|
|
|
const { state, header } =
|
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(encryptionKey);
|
|
sendCborMessage({
|
|
type: "FileUploadBegin",
|
|
room_id: roomId,
|
|
header,
|
|
});
|
|
|
|
const stream = selectedFile.stream();
|
|
const reader = stream.getReader();
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
// Encrypt this chunk
|
|
const encryptedValue =
|
|
sodium.crypto_secretstream_xchacha20poly1305_push(
|
|
state,
|
|
value,
|
|
null,
|
|
0
|
|
);
|
|
|
|
sendCborMessage({
|
|
type: "FileUploadChunk",
|
|
room_id: roomId,
|
|
data: encryptedValue,
|
|
});
|
|
}
|
|
|
|
sendCborMessage({
|
|
type: "FileUploadComplete",
|
|
room_id: roomId,
|
|
});
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}, [encryptionKey, selectedFile]);
|
|
|
|
async function selectSaveFile() {
|
|
const newHandle = await window.showSaveFilePicker();
|
|
setSelectedSaveFile(newHandle);
|
|
|
|
const writableStream = await newHandle.createWritable();
|
|
setWriteStream(writableStream);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h3>How does this work?</h3>
|
|
<p style={{ color: "darkred" }}>
|
|
<b>
|
|
RECEIVER MUST BE USING CHROME{" "}
|
|
<a
|
|
href="https://caniuse.com/mdn-api_window_showsavefilepicker"
|
|
target="_blank"
|
|
>
|
|
[?]
|
|
</a>
|
|
</b>
|
|
</p>
|
|
<ol>
|
|
<li>
|
|
<b>Uploader:</b> Copy the key and send it to whoever you're sending
|
|
files to
|
|
</li>
|
|
<li>
|
|
<b>Receiver:</b> Paste the key into the "Paste key here" box
|
|
</li>
|
|
<li>
|
|
<b>Receiver:</b> Press the "select save file" button to choose where
|
|
to save the file to
|
|
</li>
|
|
<li>
|
|
<b>Receiver:</b> Tell the uploader to press "upload"
|
|
</li>
|
|
<li>
|
|
<b>Uploader:</b> Press "upload"
|
|
</li>
|
|
</ol>
|
|
<p>Only one person can upload at a time.</p>
|
|
|
|
<Row>
|
|
<Col md={6}>
|
|
<h3>Send file</h3>
|
|
|
|
{/* <p>
|
|
Upload passphrase:
|
|
<Form.Control
|
|
value={passphrase}
|
|
onChange={(e) => {
|
|
setEncryptionKey(null);
|
|
setPassphrase(e.target.value);
|
|
}}
|
|
/>
|
|
</p> */}
|
|
|
|
<p>
|
|
Upload Key:{" "}
|
|
<Form.Control
|
|
type="text"
|
|
disabled
|
|
value={encryptionKey ? bytes2Hex(encryptionKey) : "generating..."}
|
|
/>
|
|
</p>
|
|
|
|
<input
|
|
type="file"
|
|
onChange={(e) => setSelectedFile(e.target.files[0])}
|
|
/>
|
|
<Button onClick={uploadFile} disabled={!encryptionKey}>
|
|
upload
|
|
</Button>
|
|
</Col>
|
|
|
|
<Col md={6}>
|
|
<h3>
|
|
Receive file
|
|
{readyToDownload ? (
|
|
<Badge bg="success">ready</Badge>
|
|
) : (
|
|
<OverlayTrigger
|
|
placement="top"
|
|
overlay={
|
|
<Tooltip>
|
|
you are not ready to download, make sure you enter the key
|
|
and select a place to save
|
|
</Tooltip>
|
|
}
|
|
>
|
|
<Badge bg="warning">not ready</Badge>
|
|
</OverlayTrigger>
|
|
)}
|
|
</h3>
|
|
|
|
<ol>
|
|
<li>
|
|
<Form.Control
|
|
type="text"
|
|
value={decryptionKey}
|
|
onChange={(e) => setDecryptionKey(e.target.value)}
|
|
placeholder="Paste key here..."
|
|
/>
|
|
</li>
|
|
<li>
|
|
<Button onClick={selectSaveFile} disabled={!!selectedSaveFile}>
|
|
{selectedSaveFile
|
|
? `Saving to "${selectedSaveFile.name}"`
|
|
: "select save file"}
|
|
</Button>
|
|
</li>
|
|
</ol>
|
|
</Col>
|
|
</Row>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function bytes2Hex(byteArray: Uint8Array): string {
|
|
return Array.prototype.map
|
|
.call(byteArray, function (byte: number) {
|
|
return ("0" + (byte & 0xff).toString(16)).slice(-2);
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function hex2Bytes(hexString: string): Uint8Array {
|
|
let result = [];
|
|
for (var i = 0; i < hexString.length; i += 2) {
|
|
result.push(parseInt(hexString.substr(i, 2), 16));
|
|
}
|
|
return new Uint8Array(result);
|
|
}
|