فهرست منبع

refactor: deduplicate encryption helpers (#4146)

David Luzar 3 سال پیش
والد
کامیت
6143d5195a

+ 2 - 0
src/constants.ts

@@ -174,3 +174,5 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
 export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
 
 export const SVG_NS = "http://www.w3.org/2000/svg";
+
+export const ENCRYPTION_KEY_BITS = 128;

+ 4 - 11
src/data/blob.ts

@@ -11,6 +11,7 @@ import { CanvasError } from "../errors";
 import { t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
 import { AppState, DataURL } from "../types";
+import { bytesToHexString } from "../utils";
 import { FileSystemHandle } from "./filesystem";
 import { isValidExcalidrawData } from "./json";
 import { restore } from "./restore";
@@ -195,26 +196,18 @@ export const canvasToBlob = async (
 
 /** generates SHA-1 digest from supplied file (if not supported, falls back
     to a 40-char base64 random id) */
-export const generateIdFromFile = async (file: File) => {
-  let id: FileId;
+export const generateIdFromFile = async (file: File): Promise<FileId> => {
   try {
     const hashBuffer = await window.crypto.subtle.digest(
       "SHA-1",
       await file.arrayBuffer(),
     );
-    id =
-      // convert buffer to byte array
-      Array.from(new Uint8Array(hashBuffer))
-        // convert to hex string
-        .map((byte) => byte.toString(16).padStart(2, "0"))
-        .join("") as FileId;
+    return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
   } catch (error: any) {
     console.error(error);
     // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
-    id = nanoid(40) as FileId;
+    return nanoid(40) as FileId;
   }
-
-  return id;
 };
 
 export const getDataURL = async (file: Blob | File): Promise<DataURL> => {

+ 21 - 8
src/data/encryption.ts

@@ -1,3 +1,5 @@
+import { ENCRYPTION_KEY_BITS } from "../constants";
+
 export const IV_LENGTH_BYTES = 12;
 
 export const createIV = () => {
@@ -5,19 +7,27 @@ export const createIV = () => {
   return window.crypto.getRandomValues(arr);
 };
 
-export const generateEncryptionKey = async () => {
+export const generateEncryptionKey = async <
+  T extends "string" | "cryptoKey" = "string",
+>(
+  returnAs?: T,
+): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
   const key = await window.crypto.subtle.generateKey(
     {
       name: "AES-GCM",
-      length: 128,
+      length: ENCRYPTION_KEY_BITS,
     },
     true, // extractable
     ["encrypt", "decrypt"],
   );
-  return (await window.crypto.subtle.exportKey("jwk", key)).k;
+  return (
+    returnAs === "cryptoKey"
+      ? key
+      : (await window.crypto.subtle.exportKey("jwk", key)).k
+  ) as T extends "cryptoKey" ? CryptoKey : string;
 };
 
-export const getImportedKey = (key: string, usage: KeyUsage) =>
+export const getCryptoKey = (key: string, usage: KeyUsage) =>
   window.crypto.subtle.importKey(
     "jwk",
     {
@@ -29,17 +39,18 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
     },
     {
       name: "AES-GCM",
-      length: 128,
+      length: ENCRYPTION_KEY_BITS,
     },
     false, // extractable
     [usage],
   );
 
 export const encryptData = async (
-  key: string,
+  key: string | CryptoKey,
   data: Uint8Array | ArrayBuffer | Blob | File | string,
 ): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
-  const importedKey = await getImportedKey(key, "encrypt");
+  const importedKey =
+    typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
   const iv = createIV();
   const buffer: ArrayBuffer | Uint8Array =
     typeof data === "string"
@@ -50,6 +61,8 @@ export const encryptData = async (
       ? await data.arrayBuffer()
       : data;
 
+  // We use symmetric encryption. AES-GCM is the recommended algorithm and
+  // includes checks that the ciphertext has not been modified by an attacker.
   const encryptedBuffer = await window.crypto.subtle.encrypt(
     {
       name: "AES-GCM",
@@ -67,7 +80,7 @@ export const decryptData = async (
   encrypted: Uint8Array | ArrayBuffer,
   privateKey: string,
 ): Promise<ArrayBuffer> => {
-  const key = await getImportedKey(privateKey, "decrypt");
+  const key = await getCryptoKey(privateKey, "decrypt");
   return window.crypto.subtle.decrypt(
     {
       name: "AES-GCM",

+ 2 - 0
src/excalidraw-app/app_constants.ts

@@ -23,3 +23,5 @@ export const FIREBASE_STORAGE_PREFIXES = {
   shareLinkFiles: `/files/shareLinks`,
   collabFiles: `/files/rooms`,
 };
+
+export const ROOM_ID_BYTES = 10;

+ 25 - 3
src/excalidraw-app/collab/CollabWrapper.tsx

@@ -24,7 +24,6 @@ import {
   SYNC_FULL_SCENE_INTERVAL_MS,
 } from "../app_constants";
 import {
-  decryptAESGEM,
   generateCollaborationLinkData,
   getCollaborationLink,
   SocketUpdateDataSource,
@@ -65,6 +64,7 @@ import {
   ReconciledElements,
   reconcileElements as _reconcileElements,
 } from "./reconciliation";
+import { decryptData } from "../../data/encryption";
 
 interface CollabState {
   modalIsShown: boolean;
@@ -301,6 +301,27 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
     return await this.fileManager.getFiles(unfetchedImages);
   };
 
+  private decryptPayload = async (
+    iv: Uint8Array,
+    encryptedData: ArrayBuffer,
+    decryptionKey: string,
+  ) => {
+    try {
+      const decrypted = await decryptData(iv, encryptedData, decryptionKey);
+
+      const decodedData = new TextDecoder("utf-8").decode(
+        new Uint8Array(decrypted),
+      );
+      return JSON.parse(decodedData);
+    } catch (error) {
+      window.alert(t("alerts.decryptFailed"));
+      console.error(error);
+      return {
+        type: "INVALID_RESPONSE",
+      };
+    }
+  };
+
   private initializeSocketClient = async (
     existingRoomLinkData: null | { roomId: string; roomKey: string },
   ): Promise<ImportedDataState | null> => {
@@ -388,10 +409,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
         if (!this.portal.roomKey) {
           return;
         }
-        const decryptedData = await decryptAESGEM(
+
+        const decryptedData = await this.decryptPayload(
+          iv,
           encryptedData,
           this.portal.roomKey,
-          iv,
         );
 
         switch (decryptedData.type) {

+ 6 - 8
src/excalidraw-app/collab/Portal.tsx

@@ -1,8 +1,4 @@
-import {
-  encryptAESGEM,
-  SocketUpdateData,
-  SocketUpdateDataSource,
-} from "../data";
+import { SocketUpdateData, SocketUpdateDataSource } from "../data";
 
 import CollabWrapper from "./CollabWrapper";
 
@@ -13,6 +9,7 @@ import { trackEvent } from "../../analytics";
 import { throttle } from "lodash";
 import { newElementWith } from "../../element/mutateElement";
 import { BroadcastedExcalidrawElement } from "./reconciliation";
+import { encryptData } from "../../data/encryption";
 
 class Portal {
   collab: CollabWrapper;
@@ -79,12 +76,13 @@ class Portal {
     if (this.isOpen()) {
       const json = JSON.stringify(data);
       const encoded = new TextEncoder().encode(json);
-      const encrypted = await encryptAESGEM(encoded, this.roomKey!);
+      const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
+
       this.socket?.emit(
         volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
         this.roomId,
-        encrypted.data,
-        encrypted.iv,
+        encryptedBuffer,
+        iv,
       );
     }
   }

+ 4 - 22
src/excalidraw-app/data/firebase.ts

@@ -5,7 +5,7 @@ import { restoreElements } from "../../data/restore";
 import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
 import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
 import { decompressData } from "../../data/encode";
-import { getImportedKey, createIV } from "../../data/encryption";
+import { encryptData, decryptData } from "../../data/encryption";
 import { MIME_TYPES } from "../../constants";
 
 // private
@@ -92,20 +92,11 @@ const encryptElements = async (
   key: string,
   elements: readonly ExcalidrawElement[],
 ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
-  const importedKey = await getImportedKey(key, "encrypt");
-  const iv = createIV();
   const json = JSON.stringify(elements);
   const encoded = new TextEncoder().encode(json);
-  const ciphertext = await window.crypto.subtle.encrypt(
-    {
-      name: "AES-GCM",
-      iv,
-    },
-    importedKey,
-    encoded,
-  );
+  const { encryptedBuffer, iv } = await encryptData(key, encoded);
 
-  return { ciphertext, iv };
+  return { ciphertext: encryptedBuffer, iv };
 };
 
 const decryptElements = async (
@@ -113,16 +104,7 @@ const decryptElements = async (
   iv: Uint8Array,
   ciphertext: ArrayBuffer | Uint8Array,
 ): Promise<readonly ExcalidrawElement[]> => {
-  const importedKey = await getImportedKey(key, "decrypt");
-  const decrypted = await window.crypto.subtle.decrypt(
-    {
-      name: "AES-GCM",
-      iv,
-    },
-    importedKey,
-    ciphertext,
-  );
-
+  const decrypted = await decryptData(iv, ciphertext, key);
   const decodedData = new TextDecoder("utf-8").decode(
     new Uint8Array(decrypted),
   );

+ 14 - 96
src/excalidraw-app/data/index.ts

@@ -1,7 +1,7 @@
 import {
-  createIV,
+  decryptData,
+  encryptData,
   generateEncryptionKey,
-  getImportedKey,
   IV_LENGTH_BYTES,
 } from "../../data/encryption";
 import { serializeAsJSON } from "../../data/json";
@@ -16,19 +16,18 @@ import {
   BinaryFiles,
   UserIdleState,
 } from "../../types";
-import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
+import { bytesToHexString } from "../../utils";
+import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_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("");
+const generateRoomId = async () => {
+  const buffer = new Uint8Array(ROOM_ID_BYTES);
+  window.crypto.getRandomValues(buffer);
+  return bytesToHexString(buffer);
 };
 
 export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
@@ -82,54 +81,6 @@ export type SocketUpdateData =
     _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_-]+)$/);
@@ -141,7 +92,7 @@ export const getCollaborationLinkData = (link: string) => {
 };
 
 export const generateCollaborationLinkData = async () => {
-  const roomId = await generateRandomID();
+  const roomId = await generateRoomId();
   const roomKey = await generateEncryptionKey();
 
   if (!roomKey) {
@@ -158,22 +109,6 @@ export const getCollaborationLink = (data: {
   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,
@@ -192,11 +127,11 @@ const importFromBackend = async (
       // 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);
+      decrypted = await decryptData(new Uint8Array(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);
+      decrypted = await decryptData(fixedIv, buffer, privateKey);
     }
 
     // We need to convert the decrypted array buffer to a string
@@ -256,29 +191,12 @@ export const exportToBackend = async (
   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 cryptoKey = await generateEncryptionKey("cryptoKey");
 
-  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,
-  );
+  const { encryptedBuffer, iv } = await encryptData(cryptoKey, encoded);
 
   // Concatenate IV with encrypted data (IV does not have to be secret).
-  const payloadBlob = new Blob([iv.buffer, encrypted]);
+  const payloadBlob = new Blob([iv.buffer, encryptedBuffer]);
   const payload = await new Response(payloadBlob).arrayBuffer();
 
   // We use jwk encoding to be able to extract just the base64 encoded key.

+ 6 - 0
src/utils.ts

@@ -449,3 +449,9 @@ export const preventUnload = (event: BeforeUnloadEvent) => {
   // NOTE: modern browsers no longer allow showing a custom message here
   event.returnValue = "";
 };
+
+export const bytesToHexString = (bytes: Uint8Array) => {
+  return Array.from(bytes)
+    .map((byte) => `0${byte.toString(16)}`.slice(-2))
+    .join("");
+};