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

View File

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

View File

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

View File

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