123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- import {
- createIV,
- generateEncryptionKey,
- getImportedKey,
- IV_LENGTH_BYTES,
- } from "../../data/encryption";
- import { serializeAsJSON } from "../../data/json";
- import { restore } from "../../data/restore";
- import { ImportedDataState } from "../../data/types";
- import { isInitializedImageElement } from "../../element/typeChecks";
- import { ExcalidrawElement, FileId } from "../../element/types";
- import { t } from "../../i18n";
- import {
- AppState,
- BinaryFileData,
- BinaryFiles,
- UserIdleState,
- } from "../../types";
- import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
- import { encodeFilesForUpload } from "./FileManager";
- import { saveFilesToFirebase } from "./firebase";
- const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
- const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
- const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
- const generateRandomID = async () => {
- const arr = new Uint8Array(10);
- window.crypto.getRandomValues(arr);
- return Array.from(arr, byteToHex).join("");
- };
- export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
- export type EncryptedData = {
- data: ArrayBuffer;
- iv: Uint8Array;
- };
- export type SocketUpdateDataSource = {
- SCENE_INIT: {
- type: "SCENE_INIT";
- payload: {
- elements: readonly ExcalidrawElement[];
- };
- };
- SCENE_UPDATE: {
- type: "SCENE_UPDATE";
- payload: {
- elements: readonly ExcalidrawElement[];
- };
- };
- MOUSE_LOCATION: {
- type: "MOUSE_LOCATION";
- payload: {
- socketId: string;
- pointer: { x: number; y: number };
- button: "down" | "up";
- selectedElementIds: AppState["selectedElementIds"];
- username: string;
- };
- };
- IDLE_STATUS: {
- type: "IDLE_STATUS";
- payload: {
- socketId: string;
- userState: UserIdleState;
- username: string;
- };
- };
- };
- export type SocketUpdateDataIncoming =
- | SocketUpdateDataSource[keyof SocketUpdateDataSource]
- | {
- type: "INVALID_RESPONSE";
- };
- export type SocketUpdateData =
- SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
- _brand: "socketUpdateData";
- };
- export const encryptAESGEM = async (
- data: Uint8Array,
- key: string,
- ): Promise<EncryptedData> => {
- const importedKey = await getImportedKey(key, "encrypt");
- const iv = createIV();
- return {
- data: await window.crypto.subtle.encrypt(
- {
- name: "AES-GCM",
- iv,
- },
- importedKey,
- data,
- ),
- iv,
- };
- };
- export const decryptAESGEM = async (
- data: ArrayBuffer,
- key: string,
- iv: Uint8Array,
- ): Promise<SocketUpdateDataIncoming> => {
- try {
- const importedKey = await getImportedKey(key, "decrypt");
- const decrypted = await window.crypto.subtle.decrypt(
- {
- name: "AES-GCM",
- iv,
- },
- importedKey,
- data,
- );
- const decodedData = new TextDecoder("utf-8").decode(
- new Uint8Array(decrypted),
- );
- return JSON.parse(decodedData);
- } catch (error: any) {
- window.alert(t("alerts.decryptFailed"));
- console.error(error);
- }
- return {
- type: "INVALID_RESPONSE",
- };
- };
- export const getCollaborationLinkData = (link: string) => {
- const hash = new URL(link).hash;
- const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
- if (match && match[2].length !== 22) {
- window.alert(t("alerts.invalidEncryptionKey"));
- return null;
- }
- return match ? { roomId: match[1], roomKey: match[2] } : null;
- };
- export const generateCollaborationLinkData = async () => {
- const roomId = await generateRandomID();
- const roomKey = await generateEncryptionKey();
- if (!roomKey) {
- throw new Error("Couldn't generate room key");
- }
- return { roomId, roomKey };
- };
- export const getCollaborationLink = (data: {
- roomId: string;
- roomKey: string;
- }) => {
- return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
- };
- export const decryptImported = async (
- iv: ArrayBuffer | Uint8Array,
- encrypted: ArrayBuffer,
- privateKey: string,
- ): Promise<ArrayBuffer> => {
- const key = await getImportedKey(privateKey, "decrypt");
- return window.crypto.subtle.decrypt(
- {
- name: "AES-GCM",
- iv,
- },
- key,
- encrypted,
- );
- };
- const importFromBackend = async (
- id: string,
- privateKey: string,
- ): Promise<ImportedDataState> => {
- try {
- const response = await fetch(`${BACKEND_V2_GET}${id}`);
- if (!response.ok) {
- window.alert(t("alerts.importBackendFailed"));
- return {};
- }
- const buffer = await response.arrayBuffer();
- let decrypted: ArrayBuffer;
- try {
- // Buffer should contain both the IV (fixed length) and encrypted data
- const iv = buffer.slice(0, IV_LENGTH_BYTES);
- const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
- decrypted = await decryptImported(iv, encrypted, privateKey);
- } catch (error: any) {
- // Fixed IV (old format, backward compatibility)
- const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
- decrypted = await decryptImported(fixedIv, buffer, privateKey);
- }
- // We need to convert the decrypted array buffer to a string
- const string = new window.TextDecoder("utf-8").decode(
- new Uint8Array(decrypted),
- );
- const data: ImportedDataState = JSON.parse(string);
- return {
- elements: data.elements || null,
- appState: data.appState || null,
- };
- } catch (error: any) {
- window.alert(t("alerts.importBackendFailed"));
- console.error(error);
- return {};
- }
- };
- export const loadScene = async (
- id: string | null,
- privateKey: string | null,
- // Supply local state even if importing from backend to ensure we restore
- // localStorage user settings which we do not persist on server.
- // Non-optional so we don't forget to pass it even if `undefined`.
- localDataState: ImportedDataState | undefined | null,
- ) => {
- let data;
- if (id != null && privateKey != null) {
- // the private key is used to decrypt the content from the server, take
- // extra care not to leak it
- data = restore(
- await importFromBackend(id, privateKey),
- localDataState?.appState,
- localDataState?.elements,
- );
- } else {
- data = restore(localDataState || null, null, null);
- }
- return {
- elements: data.elements,
- appState: data.appState,
- // note: this will always be empty because we're not storing files
- // in the scene database/localStorage, and instead fetch them async
- // from a different database
- files: data.files,
- commitToHistory: false,
- };
- };
- export const exportToBackend = async (
- elements: readonly ExcalidrawElement[],
- appState: AppState,
- files: BinaryFiles,
- ) => {
- const json = serializeAsJSON(elements, appState, files, "database");
- const encoded = new TextEncoder().encode(json);
- const cryptoKey = await window.crypto.subtle.generateKey(
- {
- name: "AES-GCM",
- length: 128,
- },
- true, // extractable
- ["encrypt", "decrypt"],
- );
- const iv = createIV();
- // We use symmetric encryption. AES-GCM is the recommended algorithm and
- // includes checks that the ciphertext has not been modified by an attacker.
- const encrypted = await window.crypto.subtle.encrypt(
- {
- name: "AES-GCM",
- iv,
- },
- cryptoKey,
- encoded,
- );
- // Concatenate IV with encrypted data (IV does not have to be secret).
- const payloadBlob = new Blob([iv.buffer, encrypted]);
- const payload = await new Response(payloadBlob).arrayBuffer();
- // We use jwk encoding to be able to extract just the base64 encoded key.
- // We will hardcode the rest of the attributes when importing back the key.
- const exportedKey = await window.crypto.subtle.exportKey("jwk", cryptoKey);
- try {
- const filesMap = new Map<FileId, BinaryFileData>();
- for (const element of elements) {
- if (isInitializedImageElement(element) && files[element.fileId]) {
- filesMap.set(element.fileId, files[element.fileId]);
- }
- }
- const encryptionKey = exportedKey.k!;
- const filesToUpload = await encodeFilesForUpload({
- files: filesMap,
- encryptionKey,
- maxBytes: FILE_UPLOAD_MAX_BYTES,
- });
- const response = await fetch(BACKEND_V2_POST, {
- method: "POST",
- body: payload,
- });
- const json = await response.json();
- if (json.id) {
- const url = new URL(window.location.href);
- // We need to store the key (and less importantly the id) as hash instead
- // of queryParam in order to never send it to the server
- url.hash = `json=${json.id},${encryptionKey}`;
- const urlString = url.toString();
- await saveFilesToFirebase({
- prefix: `/files/shareLinks/${json.id}`,
- files: filesToUpload,
- });
- window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
- } else if (json.error_class === "RequestTooLargeError") {
- window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
- } else {
- window.alert(t("alerts.couldNotCreateShareableLink"));
- }
- } catch (error: any) {
- console.error(error);
- window.alert(t("alerts.couldNotCreateShareableLink"));
- }
- };
|