index.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import {
  2. createIV,
  3. generateEncryptionKey,
  4. getImportedKey,
  5. IV_LENGTH_BYTES,
  6. } from "../../data/encryption";
  7. import { serializeAsJSON } from "../../data/json";
  8. import { restore } from "../../data/restore";
  9. import { ImportedDataState } from "../../data/types";
  10. import { isInitializedImageElement } from "../../element/typeChecks";
  11. import { ExcalidrawElement, FileId } from "../../element/types";
  12. import { t } from "../../i18n";
  13. import {
  14. AppState,
  15. BinaryFileData,
  16. BinaryFiles,
  17. UserIdleState,
  18. } from "../../types";
  19. import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
  20. import { encodeFilesForUpload } from "./FileManager";
  21. import { saveFilesToFirebase } from "./firebase";
  22. const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
  23. const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
  24. const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
  25. const generateRandomID = async () => {
  26. const arr = new Uint8Array(10);
  27. window.crypto.getRandomValues(arr);
  28. return Array.from(arr, byteToHex).join("");
  29. };
  30. export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
  31. export type EncryptedData = {
  32. data: ArrayBuffer;
  33. iv: Uint8Array;
  34. };
  35. export type SocketUpdateDataSource = {
  36. SCENE_INIT: {
  37. type: "SCENE_INIT";
  38. payload: {
  39. elements: readonly ExcalidrawElement[];
  40. };
  41. };
  42. SCENE_UPDATE: {
  43. type: "SCENE_UPDATE";
  44. payload: {
  45. elements: readonly ExcalidrawElement[];
  46. };
  47. };
  48. MOUSE_LOCATION: {
  49. type: "MOUSE_LOCATION";
  50. payload: {
  51. socketId: string;
  52. pointer: { x: number; y: number };
  53. button: "down" | "up";
  54. selectedElementIds: AppState["selectedElementIds"];
  55. username: string;
  56. };
  57. };
  58. IDLE_STATUS: {
  59. type: "IDLE_STATUS";
  60. payload: {
  61. socketId: string;
  62. userState: UserIdleState;
  63. username: string;
  64. };
  65. };
  66. };
  67. export type SocketUpdateDataIncoming =
  68. | SocketUpdateDataSource[keyof SocketUpdateDataSource]
  69. | {
  70. type: "INVALID_RESPONSE";
  71. };
  72. export type SocketUpdateData =
  73. SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
  74. _brand: "socketUpdateData";
  75. };
  76. export const encryptAESGEM = async (
  77. data: Uint8Array,
  78. key: string,
  79. ): Promise<EncryptedData> => {
  80. const importedKey = await getImportedKey(key, "encrypt");
  81. const iv = createIV();
  82. return {
  83. data: await window.crypto.subtle.encrypt(
  84. {
  85. name: "AES-GCM",
  86. iv,
  87. },
  88. importedKey,
  89. data,
  90. ),
  91. iv,
  92. };
  93. };
  94. export const decryptAESGEM = async (
  95. data: ArrayBuffer,
  96. key: string,
  97. iv: Uint8Array,
  98. ): Promise<SocketUpdateDataIncoming> => {
  99. try {
  100. const importedKey = await getImportedKey(key, "decrypt");
  101. const decrypted = await window.crypto.subtle.decrypt(
  102. {
  103. name: "AES-GCM",
  104. iv,
  105. },
  106. importedKey,
  107. data,
  108. );
  109. const decodedData = new TextDecoder("utf-8").decode(
  110. new Uint8Array(decrypted),
  111. );
  112. return JSON.parse(decodedData);
  113. } catch (error: any) {
  114. window.alert(t("alerts.decryptFailed"));
  115. console.error(error);
  116. }
  117. return {
  118. type: "INVALID_RESPONSE",
  119. };
  120. };
  121. export const getCollaborationLinkData = (link: string) => {
  122. const hash = new URL(link).hash;
  123. const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
  124. if (match && match[2].length !== 22) {
  125. window.alert(t("alerts.invalidEncryptionKey"));
  126. return null;
  127. }
  128. return match ? { roomId: match[1], roomKey: match[2] } : null;
  129. };
  130. export const generateCollaborationLinkData = async () => {
  131. const roomId = await generateRandomID();
  132. const roomKey = await generateEncryptionKey();
  133. if (!roomKey) {
  134. throw new Error("Couldn't generate room key");
  135. }
  136. return { roomId, roomKey };
  137. };
  138. export const getCollaborationLink = (data: {
  139. roomId: string;
  140. roomKey: string;
  141. }) => {
  142. return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
  143. };
  144. export const decryptImported = async (
  145. iv: ArrayBuffer | Uint8Array,
  146. encrypted: ArrayBuffer,
  147. privateKey: string,
  148. ): Promise<ArrayBuffer> => {
  149. const key = await getImportedKey(privateKey, "decrypt");
  150. return window.crypto.subtle.decrypt(
  151. {
  152. name: "AES-GCM",
  153. iv,
  154. },
  155. key,
  156. encrypted,
  157. );
  158. };
  159. const importFromBackend = async (
  160. id: string,
  161. privateKey: string,
  162. ): Promise<ImportedDataState> => {
  163. try {
  164. const response = await fetch(`${BACKEND_V2_GET}${id}`);
  165. if (!response.ok) {
  166. window.alert(t("alerts.importBackendFailed"));
  167. return {};
  168. }
  169. const buffer = await response.arrayBuffer();
  170. let decrypted: ArrayBuffer;
  171. try {
  172. // Buffer should contain both the IV (fixed length) and encrypted data
  173. const iv = buffer.slice(0, IV_LENGTH_BYTES);
  174. const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
  175. decrypted = await decryptImported(iv, encrypted, privateKey);
  176. } catch (error: any) {
  177. // Fixed IV (old format, backward compatibility)
  178. const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
  179. decrypted = await decryptImported(fixedIv, buffer, privateKey);
  180. }
  181. // We need to convert the decrypted array buffer to a string
  182. const string = new window.TextDecoder("utf-8").decode(
  183. new Uint8Array(decrypted),
  184. );
  185. const data: ImportedDataState = JSON.parse(string);
  186. return {
  187. elements: data.elements || null,
  188. appState: data.appState || null,
  189. };
  190. } catch (error: any) {
  191. window.alert(t("alerts.importBackendFailed"));
  192. console.error(error);
  193. return {};
  194. }
  195. };
  196. export const loadScene = async (
  197. id: string | null,
  198. privateKey: string | null,
  199. // Supply local state even if importing from backend to ensure we restore
  200. // localStorage user settings which we do not persist on server.
  201. // Non-optional so we don't forget to pass it even if `undefined`.
  202. localDataState: ImportedDataState | undefined | null,
  203. ) => {
  204. let data;
  205. if (id != null && privateKey != null) {
  206. // the private key is used to decrypt the content from the server, take
  207. // extra care not to leak it
  208. data = restore(
  209. await importFromBackend(id, privateKey),
  210. localDataState?.appState,
  211. localDataState?.elements,
  212. );
  213. } else {
  214. data = restore(localDataState || null, null, null);
  215. }
  216. return {
  217. elements: data.elements,
  218. appState: data.appState,
  219. // note: this will always be empty because we're not storing files
  220. // in the scene database/localStorage, and instead fetch them async
  221. // from a different database
  222. files: data.files,
  223. commitToHistory: false,
  224. };
  225. };
  226. export const exportToBackend = async (
  227. elements: readonly ExcalidrawElement[],
  228. appState: AppState,
  229. files: BinaryFiles,
  230. ) => {
  231. const json = serializeAsJSON(elements, appState, files, "database");
  232. const encoded = new TextEncoder().encode(json);
  233. const cryptoKey = await window.crypto.subtle.generateKey(
  234. {
  235. name: "AES-GCM",
  236. length: 128,
  237. },
  238. true, // extractable
  239. ["encrypt", "decrypt"],
  240. );
  241. const iv = createIV();
  242. // We use symmetric encryption. AES-GCM is the recommended algorithm and
  243. // includes checks that the ciphertext has not been modified by an attacker.
  244. const encrypted = await window.crypto.subtle.encrypt(
  245. {
  246. name: "AES-GCM",
  247. iv,
  248. },
  249. cryptoKey,
  250. encoded,
  251. );
  252. // Concatenate IV with encrypted data (IV does not have to be secret).
  253. const payloadBlob = new Blob([iv.buffer, encrypted]);
  254. const payload = await new Response(payloadBlob).arrayBuffer();
  255. // We use jwk encoding to be able to extract just the base64 encoded key.
  256. // We will hardcode the rest of the attributes when importing back the key.
  257. const exportedKey = await window.crypto.subtle.exportKey("jwk", cryptoKey);
  258. try {
  259. const filesMap = new Map<FileId, BinaryFileData>();
  260. for (const element of elements) {
  261. if (isInitializedImageElement(element) && files[element.fileId]) {
  262. filesMap.set(element.fileId, files[element.fileId]);
  263. }
  264. }
  265. const encryptionKey = exportedKey.k!;
  266. const filesToUpload = await encodeFilesForUpload({
  267. files: filesMap,
  268. encryptionKey,
  269. maxBytes: FILE_UPLOAD_MAX_BYTES,
  270. });
  271. const response = await fetch(BACKEND_V2_POST, {
  272. method: "POST",
  273. body: payload,
  274. });
  275. const json = await response.json();
  276. if (json.id) {
  277. const url = new URL(window.location.href);
  278. // We need to store the key (and less importantly the id) as hash instead
  279. // of queryParam in order to never send it to the server
  280. url.hash = `json=${json.id},${encryptionKey}`;
  281. const urlString = url.toString();
  282. await saveFilesToFirebase({
  283. prefix: `/files/shareLinks/${json.id}`,
  284. files: filesToUpload,
  285. });
  286. window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
  287. } else if (json.error_class === "RequestTooLargeError") {
  288. window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
  289. } else {
  290. window.alert(t("alerts.couldNotCreateShareableLink"));
  291. }
  292. } catch (error: any) {
  293. console.error(error);
  294. window.alert(t("alerts.couldNotCreateShareableLink"));
  295. }
  296. };