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 {
|
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.value)}
|
onChange={({ currentTarget }) => {
|
||||||
|
const scene = scenes[(currentTarget.value as unknown) as number];
|
||||||
|
onChange(scene.id, scene.k);
|
||||||
|
}}
|
||||||
value={currentId}
|
value={currentId}
|
||||||
title={t("buttons.previouslyLoadedScenes")}
|
title={t("buttons.previouslyLoadedScenes")}
|
||||||
>
|
>
|
||||||
{scenes.map(scene => (
|
{scenes.map((scene, i) => (
|
||||||
<option key={scene.id} value={scene.id}>
|
<option key={i} value={i}>
|
||||||
id={scene.id}
|
id={scene.id}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
try {
|
const encoded = new TextEncoder().encode(json);
|
||||||
response = await fetch(BACKEND_POST, {
|
|
||||||
method: "POST",
|
const key = await window.crypto.subtle.generateKey(
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: serializeAsJSON(elements, appState),
|
name: "AES-GCM",
|
||||||
});
|
length: 128,
|
||||||
const json = await response.json();
|
},
|
||||||
if (json.id) {
|
true, // extractable
|
||||||
const url = new URL(window.location.href);
|
["encrypt", "decrypt"],
|
||||||
url.searchParams.append("id", json.id);
|
);
|
||||||
|
// 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 {
|
||||||
await copyTextToSystemClipboard(url.toString());
|
const response = await fetch(BACKEND_V2_POST, {
|
||||||
window.alert(
|
method: "POST",
|
||||||
t("alerts.copiedToClipboard", {
|
body: encrypted,
|
||||||
url: url.toString(),
|
});
|
||||||
}),
|
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);
|
||||||
|
// 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(urlString);
|
||||||
|
window.alert(t("alerts.copiedToClipboard", { url: urlString }));
|
||||||
} 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 {
|
||||||
|
const response = await fetch(
|
||||||
|
k ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
window.alert(t("alerts.importBackendFailed"));
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
|
return restore(elements, appState, { scrollToContent: true });
|
||||||
}
|
}
|
||||||
return response;
|
let data;
|
||||||
})
|
if (k) {
|
||||||
.then(response => response.clone().json());
|
const buffer = await response.arrayBuffer();
|
||||||
if (data != null) {
|
const key = await window.crypto.subtle.importKey(
|
||||||
try {
|
"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;
|
elements = data.elements || elements;
|
||||||
appState = data.appState || appState;
|
appState = data.appState || appState;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(t("alerts.importBackendFailed"));
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
console.error(error);
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ export interface Scene {
|
|||||||
|
|
||||||
export interface PreviousScene {
|
export interface PreviousScene {
|
||||||
id: string;
|
id: string;
|
||||||
|
k?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user