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+
1332declare global {
1433 interface Window {
1534 __CANVAS_SHARE__ ?: {
@@ -22,6 +41,8 @@ declare global {
2241export 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
2748export interface LocalModeInfo {
@@ -31,11 +52,28 @@ export interface LocalModeInfo {
3152
3253export 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. */
3669export 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
4785export 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. */
5093export 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 */
123271export 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