From 6143d5195a4d280b29030cb887eb5c61a9917bd5 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Sun, 7 Nov 2021 14:33:21 +0100 Subject: [PATCH] refactor: deduplicate encryption helpers (#4146) --- src/constants.ts | 2 + src/data/blob.ts | 15 +-- src/data/encryption.ts | 29 ++++-- src/excalidraw-app/app_constants.ts | 2 + src/excalidraw-app/collab/CollabWrapper.tsx | 28 ++++- src/excalidraw-app/collab/Portal.tsx | 14 ++- src/excalidraw-app/data/firebase.ts | 26 +---- src/excalidraw-app/data/index.ts | 110 +++----------------- src/utils.ts | 6 ++ 9 files changed, 84 insertions(+), 148 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 83e2a65e..20e06121 100644 --- a/src/constants.ts +++ b/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; diff --git a/src/data/blob.ts b/src/data/blob.ts index bfc41a52..1fc7e9f4 100644 --- a/src/data/blob.ts +++ b/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 => { 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 => { diff --git a/src/data/encryption.ts b/src/data/encryption.ts index cc8eb589..21d3b852 100644 --- a/src/data/encryption.ts +++ b/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 => { 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 => { - const key = await getImportedKey(privateKey, "decrypt"); + const key = await getCryptoKey(privateKey, "decrypt"); return window.crypto.subtle.decrypt( { name: "AES-GCM", diff --git a/src/excalidraw-app/app_constants.ts b/src/excalidraw-app/app_constants.ts index 669ad085..532cdbcd 100644 --- a/src/excalidraw-app/app_constants.ts +++ b/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; diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx index f85b5b10..9b35e7b5 100644 --- a/src/excalidraw-app/collab/CollabWrapper.tsx +++ b/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 { 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 => { @@ -388,10 +409,11 @@ class CollabWrapper extends PureComponent { if (!this.portal.roomKey) { return; } - const decryptedData = await decryptAESGEM( + + const decryptedData = await this.decryptPayload( + iv, encryptedData, this.portal.roomKey, - iv, ); switch (decryptedData.type) { diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx index dd0a35a2..4922ac05 100644 --- a/src/excalidraw-app/collab/Portal.tsx +++ b/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, ); } } diff --git a/src/excalidraw-app/data/firebase.ts b/src/excalidraw-app/data/firebase.ts index 57fc64bd..dd7e207e 100644 --- a/src/excalidraw-app/data/firebase.ts +++ b/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 => { - 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), ); diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index 030c573f..4ebc06a0 100644 --- a/src/excalidraw-app/data/index.ts +++ b/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 => { - 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 => { - 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 => { - 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. diff --git a/src/utils.ts b/src/utils.ts index e86bdda7..f80927ad 100644 --- a/src/utils.ts +++ b/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(""); +};