mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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
4 changed files with 137 additions and 49 deletions
|
@ -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…
Add table
Add a link
Reference in a new issue