Bläddra i källkod

refactor: deduplicate encryption helpers (#4146)

vanilla_orig
David Luzar 3 år sedan
förälder
incheckning
6143d5195a
Inget konto är kopplat till bidragsgivarens mejladress

+ 2
- 0
src/constants.ts Visa fil

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

+ 4
- 11
src/data/blob.ts Visa fil

@@ -11,6 +11,7 @@ import { CanvasError } from "../errors";
11 11
 import { t } from "../i18n";
12 12
 import { calculateScrollCenter } from "../scene";
13 13
 import { AppState, DataURL } from "../types";
14
+import { bytesToHexString } from "../utils";
14 15
 import { FileSystemHandle } from "./filesystem";
15 16
 import { isValidExcalidrawData } from "./json";
16 17
 import { restore } from "./restore";
@@ -195,26 +196,18 @@ export const canvasToBlob = async (
195 196
 
196 197
 /** generates SHA-1 digest from supplied file (if not supported, falls back
197 198
     to a 40-char base64 random id) */
198
-export const generateIdFromFile = async (file: File) => {
199
-  let id: FileId;
199
+export const generateIdFromFile = async (file: File): Promise<FileId> => {
200 200
   try {
201 201
     const hashBuffer = await window.crypto.subtle.digest(
202 202
       "SHA-1",
203 203
       await file.arrayBuffer(),
204 204
     );
205
-    id =
206
-      // convert buffer to byte array
207
-      Array.from(new Uint8Array(hashBuffer))
208
-        // convert to hex string
209
-        .map((byte) => byte.toString(16).padStart(2, "0"))
210
-        .join("") as FileId;
205
+    return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
211 206
   } catch (error: any) {
212 207
     console.error(error);
213 208
     // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
214
-    id = nanoid(40) as FileId;
209
+    return nanoid(40) as FileId;
215 210
   }
216
-
217
-  return id;
218 211
 };
219 212
 
220 213
 export const getDataURL = async (file: Blob | File): Promise<DataURL> => {

+ 21
- 8
src/data/encryption.ts Visa fil

@@ -1,3 +1,5 @@
1
+import { ENCRYPTION_KEY_BITS } from "../constants";
2
+
1 3
 export const IV_LENGTH_BYTES = 12;
2 4
 
3 5
 export const createIV = () => {
@@ -5,19 +7,27 @@ export const createIV = () => {
5 7
   return window.crypto.getRandomValues(arr);
6 8
 };
7 9
 
8
-export const generateEncryptionKey = async () => {
10
+export const generateEncryptionKey = async <
11
+  T extends "string" | "cryptoKey" = "string",
12
+>(
13
+  returnAs?: T,
14
+): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
9 15
   const key = await window.crypto.subtle.generateKey(
10 16
     {
11 17
       name: "AES-GCM",
12
-      length: 128,
18
+      length: ENCRYPTION_KEY_BITS,
13 19
     },
14 20
     true, // extractable
15 21
     ["encrypt", "decrypt"],
16 22
   );
17
-  return (await window.crypto.subtle.exportKey("jwk", key)).k;
23
+  return (
24
+    returnAs === "cryptoKey"
25
+      ? key
26
+      : (await window.crypto.subtle.exportKey("jwk", key)).k
27
+  ) as T extends "cryptoKey" ? CryptoKey : string;
18 28
 };
19 29
 
20
-export const getImportedKey = (key: string, usage: KeyUsage) =>
30
+export const getCryptoKey = (key: string, usage: KeyUsage) =>
21 31
   window.crypto.subtle.importKey(
22 32
     "jwk",
23 33
     {
@@ -29,17 +39,18 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
29 39
     },
30 40
     {
31 41
       name: "AES-GCM",
32
-      length: 128,
42
+      length: ENCRYPTION_KEY_BITS,
33 43
     },
34 44
     false, // extractable
35 45
     [usage],
36 46
   );
37 47
 
38 48
 export const encryptData = async (
39
-  key: string,
49
+  key: string | CryptoKey,
40 50
   data: Uint8Array | ArrayBuffer | Blob | File | string,
41 51
 ): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
42
-  const importedKey = await getImportedKey(key, "encrypt");
52
+  const importedKey =
53
+    typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
43 54
   const iv = createIV();
44 55
   const buffer: ArrayBuffer | Uint8Array =
45 56
     typeof data === "string"
@@ -50,6 +61,8 @@ export const encryptData = async (
50 61
       ? await data.arrayBuffer()
51 62
       : data;
52 63
 
64
+  // We use symmetric encryption. AES-GCM is the recommended algorithm and
65
+  // includes checks that the ciphertext has not been modified by an attacker.
53 66
   const encryptedBuffer = await window.crypto.subtle.encrypt(
54 67
     {
55 68
       name: "AES-GCM",
@@ -67,7 +80,7 @@ export const decryptData = async (
67 80
   encrypted: Uint8Array | ArrayBuffer,
68 81
   privateKey: string,
69 82
 ): Promise<ArrayBuffer> => {
70
-  const key = await getImportedKey(privateKey, "decrypt");
83
+  const key = await getCryptoKey(privateKey, "decrypt");
71 84
   return window.crypto.subtle.decrypt(
72 85
     {
73 86
       name: "AES-GCM",

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

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

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

@@ -24,7 +24,6 @@ import {
24 24
   SYNC_FULL_SCENE_INTERVAL_MS,
25 25
 } from "../app_constants";
26 26
 import {
27
-  decryptAESGEM,
28 27
   generateCollaborationLinkData,
29 28
   getCollaborationLink,
30 29
   SocketUpdateDataSource,
@@ -65,6 +64,7 @@ import {
65 64
   ReconciledElements,
66 65
   reconcileElements as _reconcileElements,
67 66
 } from "./reconciliation";
67
+import { decryptData } from "../../data/encryption";
68 68
 
69 69
 interface CollabState {
70 70
   modalIsShown: boolean;
@@ -301,6 +301,27 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
301 301
     return await this.fileManager.getFiles(unfetchedImages);
302 302
   };
303 303
 
304
+  private decryptPayload = async (
305
+    iv: Uint8Array,
306
+    encryptedData: ArrayBuffer,
307
+    decryptionKey: string,
308
+  ) => {
309
+    try {
310
+      const decrypted = await decryptData(iv, encryptedData, decryptionKey);
311
+
312
+      const decodedData = new TextDecoder("utf-8").decode(
313
+        new Uint8Array(decrypted),
314
+      );
315
+      return JSON.parse(decodedData);
316
+    } catch (error) {
317
+      window.alert(t("alerts.decryptFailed"));
318
+      console.error(error);
319
+      return {
320
+        type: "INVALID_RESPONSE",
321
+      };
322
+    }
323
+  };
324
+
304 325
   private initializeSocketClient = async (
305 326
     existingRoomLinkData: null | { roomId: string; roomKey: string },
306 327
   ): Promise<ImportedDataState | null> => {
@@ -388,10 +409,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
388 409
         if (!this.portal.roomKey) {
389 410
           return;
390 411
         }
391
-        const decryptedData = await decryptAESGEM(
412
+
413
+        const decryptedData = await this.decryptPayload(
414
+          iv,
392 415
           encryptedData,
393 416
           this.portal.roomKey,
394
-          iv,
395 417
         );
396 418
 
397 419
         switch (decryptedData.type) {

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

@@ -1,8 +1,4 @@
1
-import {
2
-  encryptAESGEM,
3
-  SocketUpdateData,
4
-  SocketUpdateDataSource,
5
-} from "../data";
1
+import { SocketUpdateData, SocketUpdateDataSource } from "../data";
6 2
 
7 3
 import CollabWrapper from "./CollabWrapper";
8 4
 
@@ -13,6 +9,7 @@ import { trackEvent } from "../../analytics";
13 9
 import { throttle } from "lodash";
14 10
 import { newElementWith } from "../../element/mutateElement";
15 11
 import { BroadcastedExcalidrawElement } from "./reconciliation";
12
+import { encryptData } from "../../data/encryption";
16 13
 
17 14
 class Portal {
18 15
   collab: CollabWrapper;
@@ -79,12 +76,13 @@ class Portal {
79 76
     if (this.isOpen()) {
80 77
       const json = JSON.stringify(data);
81 78
       const encoded = new TextEncoder().encode(json);
82
-      const encrypted = await encryptAESGEM(encoded, this.roomKey!);
79
+      const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
80
+
83 81
       this.socket?.emit(
84 82
         volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
85 83
         this.roomId,
86
-        encrypted.data,
87
-        encrypted.iv,
84
+        encryptedBuffer,
85
+        iv,
88 86
       );
89 87
     }
90 88
   }

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

@@ -5,7 +5,7 @@ import { restoreElements } from "../../data/restore";
5 5
 import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
6 6
 import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
7 7
 import { decompressData } from "../../data/encode";
8
-import { getImportedKey, createIV } from "../../data/encryption";
8
+import { encryptData, decryptData } from "../../data/encryption";
9 9
 import { MIME_TYPES } from "../../constants";
10 10
 
11 11
 // private
@@ -92,20 +92,11 @@ const encryptElements = async (
92 92
   key: string,
93 93
   elements: readonly ExcalidrawElement[],
94 94
 ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
95
-  const importedKey = await getImportedKey(key, "encrypt");
96
-  const iv = createIV();
97 95
   const json = JSON.stringify(elements);
98 96
   const encoded = new TextEncoder().encode(json);
99
-  const ciphertext = await window.crypto.subtle.encrypt(
100
-    {
101
-      name: "AES-GCM",
102
-      iv,
103
-    },
104
-    importedKey,
105
-    encoded,
106
-  );
97
+  const { encryptedBuffer, iv } = await encryptData(key, encoded);
107 98
 
108
-  return { ciphertext, iv };
99
+  return { ciphertext: encryptedBuffer, iv };
109 100
 };
110 101
 
111 102
 const decryptElements = async (
@@ -113,16 +104,7 @@ const decryptElements = async (
113 104
   iv: Uint8Array,
114 105
   ciphertext: ArrayBuffer | Uint8Array,
115 106
 ): Promise<readonly ExcalidrawElement[]> => {
116
-  const importedKey = await getImportedKey(key, "decrypt");
117
-  const decrypted = await window.crypto.subtle.decrypt(
118
-    {
119
-      name: "AES-GCM",
120
-      iv,
121
-    },
122
-    importedKey,
123
-    ciphertext,
124
-  );
125
-
107
+  const decrypted = await decryptData(iv, ciphertext, key);
126 108
   const decodedData = new TextDecoder("utf-8").decode(
127 109
     new Uint8Array(decrypted),
128 110
   );

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

@@ -1,7 +1,7 @@
1 1
 import {
2
-  createIV,
2
+  decryptData,
3
+  encryptData,
3 4
   generateEncryptionKey,
4
-  getImportedKey,
5 5
   IV_LENGTH_BYTES,
6 6
 } from "../../data/encryption";
7 7
 import { serializeAsJSON } from "../../data/json";
@@ -16,19 +16,18 @@ import {
16 16
   BinaryFiles,
17 17
   UserIdleState,
18 18
 } from "../../types";
19
-import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
19
+import { bytesToHexString } from "../../utils";
20
+import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES } from "../app_constants";
20 21
 import { encodeFilesForUpload } from "./FileManager";
21 22
 import { saveFilesToFirebase } from "./firebase";
22 23
 
23
-const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
24
-
25 24
 const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
26 25
 const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
27 26
 
28
-const generateRandomID = async () => {
29
-  const arr = new Uint8Array(10);
30
-  window.crypto.getRandomValues(arr);
31
-  return Array.from(arr, byteToHex).join("");
27
+const generateRoomId = async () => {
28
+  const buffer = new Uint8Array(ROOM_ID_BYTES);
29
+  window.crypto.getRandomValues(buffer);
30
+  return bytesToHexString(buffer);
32 31
 };
33 32
 
34 33
 export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
@@ -82,54 +81,6 @@ export type SocketUpdateData =
82 81
     _brand: "socketUpdateData";
83 82
   };
84 83
 
85
-export const encryptAESGEM = async (
86
-  data: Uint8Array,
87
-  key: string,
88
-): Promise<EncryptedData> => {
89
-  const importedKey = await getImportedKey(key, "encrypt");
90
-  const iv = createIV();
91
-  return {
92
-    data: await window.crypto.subtle.encrypt(
93
-      {
94
-        name: "AES-GCM",
95
-        iv,
96
-      },
97
-      importedKey,
98
-      data,
99
-    ),
100
-    iv,
101
-  };
102
-};
103
-
104
-export const decryptAESGEM = async (
105
-  data: ArrayBuffer,
106
-  key: string,
107
-  iv: Uint8Array,
108
-): Promise<SocketUpdateDataIncoming> => {
109
-  try {
110
-    const importedKey = await getImportedKey(key, "decrypt");
111
-    const decrypted = await window.crypto.subtle.decrypt(
112
-      {
113
-        name: "AES-GCM",
114
-        iv,
115
-      },
116
-      importedKey,
117
-      data,
118
-    );
119
-
120
-    const decodedData = new TextDecoder("utf-8").decode(
121
-      new Uint8Array(decrypted),
122
-    );
123
-    return JSON.parse(decodedData);
124
-  } catch (error: any) {
125
-    window.alert(t("alerts.decryptFailed"));
126
-    console.error(error);
127
-  }
128
-  return {
129
-    type: "INVALID_RESPONSE",
130
-  };
131
-};
132
-
133 84
 export const getCollaborationLinkData = (link: string) => {
134 85
   const hash = new URL(link).hash;
135 86
   const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
@@ -141,7 +92,7 @@ export const getCollaborationLinkData = (link: string) => {
141 92
 };
142 93
 
143 94
 export const generateCollaborationLinkData = async () => {
144
-  const roomId = await generateRandomID();
95
+  const roomId = await generateRoomId();
145 96
   const roomKey = await generateEncryptionKey();
146 97
 
147 98
   if (!roomKey) {
@@ -158,22 +109,6 @@ export const getCollaborationLink = (data: {
158 109
   return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
159 110
 };
160 111
 
161
-export const decryptImported = async (
162
-  iv: ArrayBuffer | Uint8Array,
163
-  encrypted: ArrayBuffer,
164
-  privateKey: string,
165
-): Promise<ArrayBuffer> => {
166
-  const key = await getImportedKey(privateKey, "decrypt");
167
-  return window.crypto.subtle.decrypt(
168
-    {
169
-      name: "AES-GCM",
170
-      iv,
171
-    },
172
-    key,
173
-    encrypted,
174
-  );
175
-};
176
-
177 112
 const importFromBackend = async (
178 113
   id: string,
179 114
   privateKey: string,
@@ -192,11 +127,11 @@ const importFromBackend = async (
192 127
       // Buffer should contain both the IV (fixed length) and encrypted data
193 128
       const iv = buffer.slice(0, IV_LENGTH_BYTES);
194 129
       const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
195
-      decrypted = await decryptImported(iv, encrypted, privateKey);
130
+      decrypted = await decryptData(new Uint8Array(iv), encrypted, privateKey);
196 131
     } catch (error: any) {
197 132
       // Fixed IV (old format, backward compatibility)
198 133
       const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
199
-      decrypted = await decryptImported(fixedIv, buffer, privateKey);
134
+      decrypted = await decryptData(fixedIv, buffer, privateKey);
200 135
     }
201 136
 
202 137
     // We need to convert the decrypted array buffer to a string
@@ -256,29 +191,12 @@ export const exportToBackend = async (
256 191
   const json = serializeAsJSON(elements, appState, files, "database");
257 192
   const encoded = new TextEncoder().encode(json);
258 193
 
259
-  const cryptoKey = await window.crypto.subtle.generateKey(
260
-    {
261
-      name: "AES-GCM",
262
-      length: 128,
263
-    },
264
-    true, // extractable
265
-    ["encrypt", "decrypt"],
266
-  );
194
+  const cryptoKey = await generateEncryptionKey("cryptoKey");
267 195
 
268
-  const iv = createIV();
269
-  // We use symmetric encryption. AES-GCM is the recommended algorithm and
270
-  // includes checks that the ciphertext has not been modified by an attacker.
271
-  const encrypted = await window.crypto.subtle.encrypt(
272
-    {
273
-      name: "AES-GCM",
274
-      iv,
275
-    },
276
-    cryptoKey,
277
-    encoded,
278
-  );
196
+  const { encryptedBuffer, iv } = await encryptData(cryptoKey, encoded);
279 197
 
280 198
   // Concatenate IV with encrypted data (IV does not have to be secret).
281
-  const payloadBlob = new Blob([iv.buffer, encrypted]);
199
+  const payloadBlob = new Blob([iv.buffer, encryptedBuffer]);
282 200
   const payload = await new Response(payloadBlob).arrayBuffer();
283 201
 
284 202
   // We use jwk encoding to be able to extract just the base64 encoded key.

+ 6
- 0
src/utils.ts Visa fil

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

Laddar…
Avbryt
Spara