refactor: deduplicate encryption helpers (#4146)

This commit is contained in:
David Luzar 2021-11-07 14:33:21 +01:00 committed by GitHub
parent f59e608f18
commit 6143d5195a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 84 additions and 148 deletions

View File

@ -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;

View File

@ -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> => {

View File

@ -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",

View File

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

View File

@ -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) {

View File

@ -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,
);
}
}

View File

@ -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),
);

View File

@ -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.

View File

@ -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("");
};