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