Просмотр исходного кода

Add encryption (#642)

* Add encryption

In order to avoid the server being able to read the content of the scene, this PR implements local encryption and decryption. This implements the algorithm described in #610.

Right now the server doesn't support uploading binary files. I mocked the server with comments. @lipis, could you add support on the server and update this PR? I added a bunch of TODO: that tell you where to comment/uncomment in order to get the server flow going.

To test locally right now:
- Import: Open http://localhost:3000/#json=1234,5oYVOnGpWYPPTz19-PMYYw and see a square
- Export: Click the export link and see the right url with the private key + the encrypted binary in the console

Fixes #610

* backend_v2

* v2
Christopher Chedeau 5 лет назад
Родитель
Сommit
2dd1796351
4 измененных файлов с 137 добавлено и 49 удалено
  1. 16 15
      src/components/StoredScenesList.tsx
  2. 17 5
      src/index.tsx
  3. 103 29
      src/scene/data.ts
  4. 1 0
      src/scene/types.ts

+ 16 - 15
src/components/StoredScenesList.tsx

@@ -5,7 +5,7 @@ import { t } from "../i18n";
 interface StoredScenesListProps {
   scenes: PreviousScene[];
   currentId?: string;
-  onChange: (selectedId: string) => {};
+  onChange: (selectedId: string, k?: string) => {};
 }
 
 export function StoredScenesList({
@@ -14,19 +14,20 @@ export function StoredScenesList({
   onChange,
 }: StoredScenesListProps) {
   return (
-    <React.Fragment>
-      <select
-        className="stored-ids-select"
-        onChange={({ currentTarget }) => onChange(currentTarget.value)}
-        value={currentId}
-        title={t("buttons.previouslyLoadedScenes")}
-      >
-        {scenes.map(scene => (
-          <option key={scene.id} value={scene.id}>
-            id={scene.id}
-          </option>
-        ))}
-      </select>
-    </React.Fragment>
+    <select
+      className="stored-ids-select"
+      onChange={({ currentTarget }) => {
+        const scene = scenes[(currentTarget.value as unknown) as number];
+        onChange(scene.id, scene.k);
+      }}
+      value={currentId}
+      title={t("buttons.previouslyLoadedScenes")}
+    >
+      {scenes.map((scene, i) => (
+        <option key={i} value={i}>
+          id={scene.id}
+        </option>
+      ))}
+    </select>
   );
 }

+ 17 - 5
src/index.tsx

@@ -302,12 +302,14 @@ export class App extends React.Component<any, AppState> {
     return true;
   }
 
-  private async loadScene(id: string | null) {
+  private async loadScene(id: string | null, k: string | undefined) {
     let data;
     let selectedId;
     if (id != null) {
-      data = await importFromBackend(id);
-      addToLoadedScenes(id);
+      // k is the private key used to decrypt the content from the server, take
+      // extra care not to leak it
+      data = await importFromBackend(id, k);
+      addToLoadedScenes(id, k);
       selectedId = id;
       window.history.replaceState({}, "Excalidraw", window.location.origin);
     } else {
@@ -342,7 +344,17 @@ export class App extends React.Component<any, AppState> {
     const searchParams = new URLSearchParams(window.location.search);
     const id = searchParams.get("id");
 
-    this.loadScene(id);
+    if (id) {
+      // Backwards compatibility with legacy url format
+      this.loadScene(id, undefined);
+    } else {
+      const match = window.location.hash.match(
+        /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
+      );
+      if (match) {
+        this.loadScene(match[1], match[2]);
+      }
+    }
   }
 
   public componentWillUnmount() {
@@ -1854,7 +1866,7 @@ export class App extends React.Component<any, AppState> {
       <StoredScenesList
         scenes={scenes}
         currentId={this.state.selectedId}
-        onChange={id => this.loadScene(id)}
+        onChange={(id, k) => this.loadScene(id, k)}
       />
     );
   }

+ 103 - 29
src/scene/data.ts

@@ -23,9 +23,11 @@ import {
 const LOCAL_STORAGE_KEY = "excalidraw";
 const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes";
 const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
-const BACKEND_POST = "https://json.excalidraw.com/api/v1/post/";
 const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
 
+const BACKEND_V2_POST = "https://json.excalidraw.com/api/v2/post/";
+const BACKEND_V2_GET = "https://json.excalidraw.com/api/v2/";
+
 // TODO: Defined globally, since file handles aren't yet serializable.
 // Once `FileSystemFileHandle` can be serialized, make this
 // part of `AppState`.
@@ -146,25 +148,54 @@ export async function exportToBackend(
   elements: readonly ExcalidrawElement[],
   appState: AppState,
 ) {
-  let response;
+  const json = serializeAsJSON(elements, appState);
+  const encoded = new TextEncoder().encode(json);
+
+  const key = await window.crypto.subtle.generateKey(
+    {
+      name: "AES-GCM",
+      length: 128,
+    },
+    true, // extractable
+    ["encrypt", "decrypt"],
+  );
+  // The iv is set to 0. We are never going to reuse the same key so we don't
+  // need to have an iv. (I hope that's correct...)
+  const iv = new Uint8Array(12);
+  // 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: iv,
+    },
+    key,
+    encoded,
+  );
+  // We use jwk encoding to be able to extract just the base64 encoded key.
+  // We will hardcode the rest of the attributes when importing back the key.
+  const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
+
   try {
-    response = await fetch(BACKEND_POST, {
+    const response = await fetch(BACKEND_V2_POST, {
       method: "POST",
-      headers: { "Content-Type": "application/json" },
-      body: serializeAsJSON(elements, appState),
+      body: encrypted,
     });
     const json = await response.json();
+    // TODO: comment following
+    // const json = {id: '1234'}
+    // console.log("new Uint8Array([" + new Uint8Array(encrypted).join(",") + "])");
+
     if (json.id) {
       const url = new URL(window.location.href);
-      url.searchParams.append("id", json.id);
+      // We need to store the key (and less importantly the id) as hash instead
+      // of queryParam in order to never send it to the server
+      url.hash = `json=${json.id},${exportedKey.k!}`;
+      const urlString = url.toString();
 
       try {
-        await copyTextToSystemClipboard(url.toString());
-        window.alert(
-          t("alerts.copiedToClipboard", {
-            url: url.toString(),
-          }),
-        );
+        await copyTextToSystemClipboard(urlString);
+        window.alert(t("alerts.copiedToClipboard", { url: urlString }));
       } catch (err) {
         // TODO: link will be displayed for user to copy manually in later PR
       }
@@ -172,31 +203,73 @@ export async function exportToBackend(
       window.alert(t("alerts.couldNotCreateShareableLink"));
     }
   } catch (e) {
+    console.error(e);
     window.alert(t("alerts.couldNotCreateShareableLink"));
   }
 }
 
-export async function importFromBackend(id: string | null) {
+export async function importFromBackend(
+  id: string | null,
+  k: string | undefined,
+) {
   let elements: readonly ExcalidrawElement[] = [];
   let appState: AppState = getDefaultAppState();
-  const data = await fetch(`${BACKEND_GET}${id}.json`)
-    .then(response => {
-      if (!response.ok) {
-        window.alert(t("alerts.importBackendFailed"));
-      }
-      return response;
-    })
-    .then(response => response.clone().json());
-  if (data != null) {
-    try {
-      elements = data.elements || elements;
-      appState = data.appState || appState;
-    } catch (error) {
+
+  try {
+    const response = await fetch(
+      k ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
+    );
+    if (!response.ok) {
       window.alert(t("alerts.importBackendFailed"));
-      console.error(error);
+      return restore(elements, appState, { scrollToContent: true });
+    }
+    let data;
+    if (k) {
+      const buffer = await response.arrayBuffer();
+      const key = await window.crypto.subtle.importKey(
+        "jwk",
+        {
+          alg: "A128GCM",
+          ext: true,
+          k: k,
+          key_ops: ["encrypt", "decrypt"],
+          kty: "oct",
+        },
+        {
+          name: "AES-GCM",
+          length: 128,
+        },
+        false, // extractable
+        ["decrypt"],
+      );
+      const iv = new Uint8Array(12);
+      const decrypted = await window.crypto.subtle.decrypt(
+        {
+          name: "AES-GCM",
+          iv: iv,
+        },
+        key,
+        buffer,
+      );
+      // We need to convert the decrypted array buffer to a string
+      const string = String.fromCharCode.apply(
+        null,
+        new Uint8Array(decrypted) as any,
+      );
+      data = JSON.parse(string);
+    } else {
+      // Legacy format
+      data = await response.json();
     }
+
+    elements = data.elements || elements;
+    appState = data.appState || appState;
+  } catch (error) {
+    window.alert(t("alerts.importBackendFailed"));
+    console.error(error);
+  } finally {
+    return restore(elements, appState, { scrollToContent: true });
   }
-  return restore(elements, appState, { scrollToContent: true });
 }
 
 export async function exportCanvas(
@@ -394,7 +467,7 @@ export function loadedScenes(): PreviousScene[] {
  * Append id to the list of Previous Scenes in Local Storage if not there yet
  * @param id string
  */
-export function addToLoadedScenes(id: string): void {
+export function addToLoadedScenes(id: string, k: string | undefined): void {
   const scenes = [...loadedScenes()];
   const newScene = scenes.every(scene => scene.id !== id);
 
@@ -402,6 +475,7 @@ export function addToLoadedScenes(id: string): void {
     scenes.push({
       timestamp: Date.now(),
       id,
+      k,
     });
   }
 

+ 1 - 0
src/scene/types.ts

@@ -18,6 +18,7 @@ export interface Scene {
 
 export interface PreviousScene {
   id: string;
+  k?: string;
   timestamp: number;
 }