Skip to content

Commit c115d92

Browse files
matej21claude
andcommitted
feat: end-to-end encrypt canvas shares (backward-compatible)
New shares are encrypted with a fresh AES-GCM-256 key generated by the daemon. The key travels in the URL fragment (`#k=...`), so the worker only ever sees ciphertext — canvas JS, JSX source, session id, label, reviewer name, annotation snippet/note/context, and generalNote. The daemon keeps a local copy of the key on `ShareEntry` so the feedback poller can decrypt incoming entries before persisting/broadcasting. Backward compat: the worker accepts both encrypted and plaintext payloads (the `encryption` marker is optional in validation), and the browser detects mode from the URL fragment — existing unencrypted shares continue to work unchanged. Opt-out via `CANVAS_SHARE_ENCRYPTION=off` for users who need the old behavior. Covered by an in-process e2e test that runs the real worker fetch handler against in-memory R2/KV stubs and asserts no plaintext leaks into stored blobs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 46ac54c commit c115d92

14 files changed

Lines changed: 980 additions & 39 deletions

File tree

daemon/client/App.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
2020
import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
2121
import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
2222
import type { Annotation } from "#canvas/runtime";
23-
import { MODE, metaUrl, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
23+
import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
2424

2525
export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
2626

@@ -309,8 +309,7 @@ function App() {
309309
// Fetch initial meta
310310
useEffect(() => {
311311
if (!sessionId) return;
312-
fetch(metaUrl())
313-
.then((r) => r.json())
312+
fetchMeta()
314313
.then((data: any) => {
315314
if (data.currentRevision) {
316315
setCurrentRevision(data.currentRevision);

daemon/client/PlanRenderer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { extractContext } from "./annotationContext";
66
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
77
import { generateAnnotationId } from "./utils";
88
import { useTextAnnotation } from "./useTextAnnotation";
9-
import { canvasJsUrl } from "./clientApi";
9+
import { loadCanvasModule } from "./clientApi";
1010

1111
/** All navigable blocks (keyboard arrows) */
1212
const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
@@ -42,7 +42,7 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
4242
setError(null);
4343
setFocusedBlockIndex(null);
4444
void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
45-
import(/* @vite-ignore */ canvasJsUrl(filename, revision))
45+
loadCanvasModule(filename, revision)
4646
.then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
4747
.catch((e) => { setError(e.message); setLoading(false); });
4848
}, [sessionId, revision, filename]);

daemon/client/ShareDialog.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export interface ShareEntry {
88
ownerToken?: string;
99
expiresAt?: string;
1010
lastFeedbackAt?: string;
11+
/** Present iff the share is end-to-end encrypted; the URL itself
12+
* carries the key in the `#k=...` fragment. */
13+
encryptionKey?: string;
1114
}
1215

1316
interface ShareDialogProps {
@@ -160,6 +163,15 @@ export function ShareDialog({
160163
Expires {new Date(shareForRev.expiresAt).toLocaleDateString()}.
161164
</p>
162165
)}
166+
{shareForRev.encryptionKey && (
167+
<p className="text-[11px] text-accent-green font-body flex items-center gap-1.5">
168+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
169+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
170+
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
171+
</svg>
172+
End-to-end encrypted — the key is part of the link and never leaves your browser.
173+
</p>
174+
)}
163175
<p className="text-[11px] text-text-tertiary font-body">
164176
Anyone with this link can view and leave feedback. Reviewer
165177
feedback will appear in your annotation sidebar within seconds.

daemon/client/clientApi.ts

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,27 @@
88
* `window.__CANVAS_SHARE__`, which the CF Worker injects into the HTML
99
* shell it serves. Local daemon never sets this, so the app defaults to
1010
* local mode.
11+
*
12+
* End-to-end encryption
13+
* ---------------------
14+
* When the share URL carries a `#k=<base64url>` fragment, every secret
15+
* payload going over the wire is encrypted with that key:
16+
* - canvas JS bodies (decrypted before being imported as ES modules),
17+
* - origin.sessionId / origin.label in the meta response,
18+
* - feedback fields submitted by the reviewer.
19+
* The key is never sent to the worker — browsers strip URL fragments
20+
* before issuing requests. Legacy shares with no fragment continue to
21+
* work in cleartext.
1122
*/
1223

24+
import {
25+
importShareKey,
26+
encryptString,
27+
decryptString,
28+
ENCRYPTION_META,
29+
type EncryptionMeta,
30+
} from "./shareCrypto";
31+
1332
declare global {
1433
interface Window {
1534
__CANVAS_SHARE__?: {
@@ -22,6 +41,8 @@ declare global {
2241
export interface SharedModeInfo {
2342
isShared: true;
2443
shareId: string;
44+
/** Lazy-imported AES-GCM key parsed from the URL fragment, if any. */
45+
encryptionKey: Promise<CryptoKey> | null;
2546
}
2647

2748
export interface LocalModeInfo {
@@ -31,11 +52,28 @@ export interface LocalModeInfo {
3152

3253
export type ModeInfo = SharedModeInfo | LocalModeInfo;
3354

55+
/** Strip the `#k=...` fragment and parse out the key (base64url). */
56+
function readFragmentKey(): string | null {
57+
if (typeof window === "undefined") return null;
58+
const hash = window.location.hash.replace(/^#/, "");
59+
if (!hash) return null;
60+
for (const part of hash.split("&")) {
61+
const [k, v] = part.split("=");
62+
if (k === "k" && v) return decodeURIComponent(v);
63+
}
64+
return null;
65+
}
66+
3467
/** One-time detection. In shared mode the daemon-provided sessionId from
3568
* the URL path is ignored in favor of the worker-injected shareId. */
3669
export function detectMode(): ModeInfo {
3770
if (typeof window !== "undefined" && window.__CANVAS_SHARE__?.shareId) {
38-
return { isShared: true, shareId: window.__CANVAS_SHARE__.shareId };
71+
const encoded = readFragmentKey();
72+
return {
73+
isShared: true,
74+
shareId: window.__CANVAS_SHARE__.shareId,
75+
encryptionKey: encoded ? importShareKey(encoded) : null,
76+
};
3977
}
4078
// Local: session id is the path suffix /s/:sessionId
4179
const sessionId = typeof window !== "undefined"
@@ -46,6 +84,11 @@ export function detectMode(): ModeInfo {
4684

4785
export const MODE: ModeInfo = detectMode();
4886

87+
/** True iff we're in a shared canvas AND have a fragment key. */
88+
export function isEncryptedShare(): boolean {
89+
return MODE.isShared && MODE.encryptionKey !== null;
90+
}
91+
4992
/** Build a canonical id-ish string used as React keys / identifiers. */
5093
export function getIdentifier(): string {
5194
return MODE.isShared ? MODE.shareId : MODE.sessionId;
@@ -75,6 +118,73 @@ export function uploadUrl(): string {
75118
return `/api/session/${MODE.sessionId}/upload`;
76119
}
77120

121+
// --- Meta fetch (with optional decryption) ---------------------------------
122+
123+
interface RawMetaResponse {
124+
encryption?: EncryptionMeta;
125+
origin?: { sessionId?: string; label?: string; revision?: number; createdAt?: string };
126+
[key: string]: unknown;
127+
}
128+
129+
/** Fetch + parse meta, decrypting origin.sessionId/label for encrypted
130+
* shares. The shape returned is the same as the worker/daemon emits, so
131+
* callers don't have to branch. */
132+
export async function fetchMeta(): Promise<any> {
133+
const res = await fetch(metaUrl());
134+
if (!res.ok) throw new Error(`meta ${res.status}`);
135+
const data = (await res.json()) as RawMetaResponse;
136+
137+
if (data.encryption && MODE.isShared && MODE.encryptionKey) {
138+
const key = await MODE.encryptionKey;
139+
if (data.origin) {
140+
const o: any = { ...data.origin };
141+
if (o.sessionId) o.sessionId = await decryptString(key, o.sessionId);
142+
if (o.label) o.label = await decryptString(key, o.label);
143+
data.origin = o;
144+
}
145+
// The worker synthesizes a one-element revisions array from the same
146+
// ShareRecord.origin, so its `label` is also ciphertext — decrypt it
147+
// in lockstep with origin.label above.
148+
const revs = (data as any).revisions;
149+
if (Array.isArray(revs)) {
150+
for (const r of revs) {
151+
if (r.label) {
152+
try { r.label = await decryptString(key, r.label); } catch {}
153+
}
154+
}
155+
}
156+
}
157+
return data;
158+
}
159+
160+
// --- Canvas module loading -------------------------------------------------
161+
162+
/** Dynamically import the compiled canvas JS for a revision. In encrypted
163+
* shared mode the body is ciphertext, so we fetch it, decrypt to plain JS
164+
* source, then materialize as a Blob URL the browser can `import()`.
165+
* Import maps still apply to Blob-URL module imports, so the resulting
166+
* module's `#canvas/runtime` / `#canvas/components` imports resolve
167+
* exactly as they would for an in-place script. */
168+
export async function loadCanvasModule(filename: string, revision?: number): Promise<any> {
169+
const url = canvasJsUrl(filename, revision);
170+
if (!isEncryptedShare()) {
171+
return import(/* @vite-ignore */ url);
172+
}
173+
const res = await fetch(url);
174+
if (!res.ok) throw new Error(`canvas ${res.status}`);
175+
const ciphertext = await res.text();
176+
const key = await (MODE as SharedModeInfo).encryptionKey!;
177+
const js = await decryptString(key, ciphertext);
178+
const blob = new Blob([js], { type: "application/javascript" });
179+
const blobUrl = URL.createObjectURL(blob);
180+
try {
181+
return await import(/* @vite-ignore */ blobUrl);
182+
} finally {
183+
// Revoke after a tick so the import has fully resolved its source.
184+
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
185+
}
186+
}
187+
78188
// --- Feedback submission ----------------------------------------------------
79189

80190
/**
@@ -115,17 +225,60 @@ export interface SharedFeedbackPayload {
115225
generalNote?: string;
116226
}
117227

228+
/** Encrypt the secret fields of a feedback payload in-place semantics. */
229+
async function encryptFeedback(
230+
payload: SharedFeedbackPayload,
231+
key: CryptoKey,
232+
): Promise<SharedFeedbackPayload & { encryption: EncryptionMeta }> {
233+
const annotations = await Promise.all(
234+
payload.annotations.map(async (a) => {
235+
const out: Record<string, unknown> = {
236+
id: a.id,
237+
createdAt: a.createdAt,
238+
snippet: await encryptString(key, String(a.snippet ?? "")),
239+
note: await encryptString(key, String(a.note ?? "")),
240+
};
241+
if (a.filePath) out.filePath = await encryptString(key, String(a.filePath));
242+
if (a.canvasFile) out.canvasFile = await encryptString(key, String(a.canvasFile));
243+
if (a.context !== undefined && a.context !== null) {
244+
out.context = await encryptString(key, JSON.stringify(a.context));
245+
}
246+
// Image attachments are content-addressed worker URLs — already public
247+
// by virtue of being served by the worker, so leaving them plaintext
248+
// is fine and avoids breaking client-side rendering of the URL.
249+
if (a.attachments) out.attachments = a.attachments;
250+
return out;
251+
}),
252+
);
253+
return {
254+
author: {
255+
id: payload.author.id,
256+
name: await encryptString(key, payload.author.name),
257+
},
258+
revision: payload.revision,
259+
annotations,
260+
...(payload.generalNote ? { generalNote: await encryptString(key, payload.generalNote) } : {}),
261+
encryption: ENCRYPTION_META,
262+
};
263+
}
264+
118265
/**
119266
* Submit feedback in shared mode. Throws on HTTP error. Caller is
120267
* responsible for collecting + prompting for the reviewer's name before
121-
* invoking this.
268+
* invoking this. Transparently encrypts when the share URL carried a
269+
* fragment key.
122270
*/
123271
export async function submitSharedFeedback(payload: SharedFeedbackPayload): Promise<void> {
124272
if (!MODE.isShared) throw new Error("submitSharedFeedback called outside shared mode");
273+
let body: unknown = payload;
274+
if (MODE.encryptionKey) {
275+
const key = await MODE.encryptionKey;
276+
body = await encryptFeedback(payload, key);
277+
}
125278
const res = await fetch(`/s/${MODE.shareId}/feedback`, {
126279
method: "POST",
127280
headers: { "Content-Type": "application/json" },
128-
body: JSON.stringify(payload),
281+
body: JSON.stringify(body),
129282
});
130283
if (!res.ok) {
131284
const text = await res.text().catch(() => "");

0 commit comments

Comments
 (0)