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
This commit is contained in:
Christopher Chedeau 2020-02-05 07:35:51 -08:00 committed by GitHub
parent c7d7d65e1b
commit 2dd1796351
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 49 deletions

View File

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

View File

@ -302,12 +302,14 @@ export class App extends React.Component<any, AppState> {
return true; return true;
} }
private async loadScene(id: string | null) { private async loadScene(id: string | null, k: string | undefined) {
let data; let data;
let selectedId; let selectedId;
if (id != null) { if (id != null) {
data = await importFromBackend(id); // k is the private key used to decrypt the content from the server, take
addToLoadedScenes(id); // extra care not to leak it
data = await importFromBackend(id, k);
addToLoadedScenes(id, k);
selectedId = id; selectedId = id;
window.history.replaceState({}, "Excalidraw", window.location.origin); window.history.replaceState({}, "Excalidraw", window.location.origin);
} else { } else {
@ -342,7 +344,17 @@ export class App extends React.Component<any, AppState> {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id"); 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() { public componentWillUnmount() {
@ -1854,7 +1866,7 @@ export class App extends React.Component<any, AppState> {
<StoredScenesList <StoredScenesList
scenes={scenes} scenes={scenes}
currentId={this.state.selectedId} currentId={this.state.selectedId}
onChange={id => this.loadScene(id)} onChange={(id, k) => this.loadScene(id, k)}
/> />
); );
} }

View File

@ -23,9 +23,11 @@ import {
const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY = "excalidraw";
const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes"; const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes";
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; 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_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. // TODO: Defined globally, since file handles aren't yet serializable.
// Once `FileSystemFileHandle` can be serialized, make this // Once `FileSystemFileHandle` can be serialized, make this
// part of `AppState`. // part of `AppState`.
@ -146,25 +148,54 @@ export async function exportToBackend(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, 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 { try {
response = await fetch(BACKEND_POST, { const response = await fetch(BACKEND_V2_POST, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, body: encrypted,
body: serializeAsJSON(elements, appState),
}); });
const json = await response.json(); const json = await response.json();
// TODO: comment following
// const json = {id: '1234'}
// console.log("new Uint8Array([" + new Uint8Array(encrypted).join(",") + "])");
if (json.id) { if (json.id) {
const url = new URL(window.location.href); 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 { try {
await copyTextToSystemClipboard(url.toString()); await copyTextToSystemClipboard(urlString);
window.alert( window.alert(t("alerts.copiedToClipboard", { url: urlString }));
t("alerts.copiedToClipboard", {
url: url.toString(),
}),
);
} catch (err) { } catch (err) {
// TODO: link will be displayed for user to copy manually in later PR // 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")); window.alert(t("alerts.couldNotCreateShareableLink"));
} }
} catch (e) { } catch (e) {
console.error(e);
window.alert(t("alerts.couldNotCreateShareableLink")); 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 elements: readonly ExcalidrawElement[] = [];
let appState: AppState = getDefaultAppState(); let appState: AppState = getDefaultAppState();
const data = await fetch(`${BACKEND_GET}${id}.json`)
.then(response => { try {
if (!response.ok) { const response = await fetch(
window.alert(t("alerts.importBackendFailed")); k ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
} );
return response; if (!response.ok) {
})
.then(response => response.clone().json());
if (data != null) {
try {
elements = data.elements || elements;
appState = data.appState || appState;
} catch (error) {
window.alert(t("alerts.importBackendFailed")); 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( 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 * Append id to the list of Previous Scenes in Local Storage if not there yet
* @param id string * @param id string
*/ */
export function addToLoadedScenes(id: string): void { export function addToLoadedScenes(id: string, k: string | undefined): void {
const scenes = [...loadedScenes()]; const scenes = [...loadedScenes()];
const newScene = scenes.every(scene => scene.id !== id); const newScene = scenes.every(scene => scene.id !== id);
@ -402,6 +475,7 @@ export function addToLoadedScenes(id: string): void {
scenes.push({ scenes.push({
timestamp: Date.now(), timestamp: Date.now(),
id, id,
k,
}); });
} }

View File

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