index.ts 8.6 KB

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