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:
parent
c7d7d65e1b
commit
2dd1796351
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ export interface Scene {
|
||||
|
||||
export interface PreviousScene {
|
||||
id: string;
|
||||
k?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user