diff --git a/src/components/StoredScenesList.tsx b/src/components/StoredScenesList.tsx
index 031af2de..184fb563 100644
--- a/src/components/StoredScenesList.tsx
+++ b/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 (
-
-
-
+
);
}
diff --git a/src/index.tsx b/src/index.tsx
index beb74441..348fbbf6 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -302,12 +302,14 @@ export class App extends React.Component {
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 {
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 {
this.loadScene(id)}
+ onChange={(id, k) => this.loadScene(id, k)}
/>
);
}
diff --git a/src/scene/data.ts b/src/scene/data.ts
index e182da43..55909e26 100644
--- a/src/scene/data.ts
+++ b/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,
});
}
diff --git a/src/scene/types.ts b/src/scene/types.ts
index f4398372..b9923abc 100644
--- a/src/scene/types.ts
+++ b/src/scene/types.ts
@@ -18,6 +18,7 @@ export interface Scene {
export interface PreviousScene {
id: string;
+ k?: string;
timestamp: number;
}