agent-canvas 0.1.34 → 0.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/daemon/client/App.tsx +2 -3
- package/daemon/client/PlanRenderer.tsx +2 -2
- package/daemon/client/ShareDialog.tsx +12 -0
- package/daemon/client/clientApi.ts +156 -3
- package/daemon/client/components/renderMarkdownChildren.tsx +54 -9
- package/daemon/client/shareCrypto.ts +123 -0
- package/daemon/dist/client.js +68 -68
- package/daemon/dist/components.js +55 -55
- package/daemon/src/remote-feedback.ts +104 -4
- package/daemon/src/session.ts +5 -0
- package/daemon/src/share-e2e.test.ts +277 -0
- package/daemon/src/share.test.ts +76 -1
- package/daemon/src/share.ts +85 -5
- package/daemon/tsconfig.json +4 -1
- package/package.json +1 -1
- package/workers/canvas-share/public/assets/client.js +68 -68
- package/workers/canvas-share/public/assets/components.js +55 -55
- package/workers/canvas-share/src/index.test.ts +44 -0
- package/workers/canvas-share/src/index.ts +9 -1
- package/workers/canvas-share/src/types.ts +24 -2
- package/workers/canvas-share/src/validation.ts +61 -18
package/daemon/client/App.tsx
CHANGED
|
@@ -20,7 +20,7 @@ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
|
20
20
|
import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
|
|
21
21
|
import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
|
|
22
22
|
import type { Annotation } from "#canvas/runtime";
|
|
23
|
-
import { MODE,
|
|
23
|
+
import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
|
|
24
24
|
|
|
25
25
|
export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
|
|
26
26
|
|
|
@@ -309,8 +309,7 @@ function App() {
|
|
|
309
309
|
// Fetch initial meta
|
|
310
310
|
useEffect(() => {
|
|
311
311
|
if (!sessionId) return;
|
|
312
|
-
|
|
313
|
-
.then((r) => r.json())
|
|
312
|
+
fetchMeta()
|
|
314
313
|
.then((data: any) => {
|
|
315
314
|
if (data.currentRevision) {
|
|
316
315
|
setCurrentRevision(data.currentRevision);
|
|
@@ -6,7 +6,7 @@ import { extractContext } from "./annotationContext";
|
|
|
6
6
|
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
7
7
|
import { generateAnnotationId } from "./utils";
|
|
8
8
|
import { useTextAnnotation } from "./useTextAnnotation";
|
|
9
|
-
import {
|
|
9
|
+
import { loadCanvasModule } from "./clientApi";
|
|
10
10
|
|
|
11
11
|
/** All navigable blocks (keyboard arrows) */
|
|
12
12
|
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) {
|
|
|
42
42
|
setError(null);
|
|
43
43
|
setFocusedBlockIndex(null);
|
|
44
44
|
void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
|
|
45
|
-
|
|
45
|
+
loadCanvasModule(filename, revision)
|
|
46
46
|
.then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
|
|
47
47
|
.catch((e) => { setError(e.message); setLoading(false); });
|
|
48
48
|
}, [sessionId, revision, filename]);
|
|
@@ -8,6 +8,9 @@ export interface ShareEntry {
|
|
|
8
8
|
ownerToken?: string;
|
|
9
9
|
expiresAt?: string;
|
|
10
10
|
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;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
interface ShareDialogProps {
|
|
@@ -160,6 +163,15 @@ export function ShareDialog({
|
|
|
160
163
|
Expires {new Date(shareForRev.expiresAt).toLocaleDateString()}.
|
|
161
164
|
</p>
|
|
162
165
|
)}
|
|
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
|
+
)}
|
|
163
175
|
<p className="text-[11px] text-text-tertiary font-body">
|
|
164
176
|
Anyone with this link can view and leave feedback. Reviewer
|
|
165
177
|
feedback will appear in your annotation sidebar within seconds.
|
|
@@ -8,8 +8,27 @@
|
|
|
8
8
|
* `window.__CANVAS_SHARE__`, which the CF Worker injects into the HTML
|
|
9
9
|
* shell it serves. Local daemon never sets this, so the app defaults to
|
|
10
10
|
* 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.
|
|
11
22
|
*/
|
|
12
23
|
|
|
24
|
+
import {
|
|
25
|
+
importShareKey,
|
|
26
|
+
encryptString,
|
|
27
|
+
decryptString,
|
|
28
|
+
ENCRYPTION_META,
|
|
29
|
+
type EncryptionMeta,
|
|
30
|
+
} from "./shareCrypto";
|
|
31
|
+
|
|
13
32
|
declare global {
|
|
14
33
|
interface Window {
|
|
15
34
|
__CANVAS_SHARE__?: {
|
|
@@ -22,6 +41,8 @@ declare global {
|
|
|
22
41
|
export interface SharedModeInfo {
|
|
23
42
|
isShared: true;
|
|
24
43
|
shareId: string;
|
|
44
|
+
/** Lazy-imported AES-GCM key parsed from the URL fragment, if any. */
|
|
45
|
+
encryptionKey: Promise<CryptoKey> | null;
|
|
25
46
|
}
|
|
26
47
|
|
|
27
48
|
export interface LocalModeInfo {
|
|
@@ -31,11 +52,28 @@ export interface LocalModeInfo {
|
|
|
31
52
|
|
|
32
53
|
export type ModeInfo = SharedModeInfo | LocalModeInfo;
|
|
33
54
|
|
|
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
|
+
|
|
34
67
|
/** One-time detection. In shared mode the daemon-provided sessionId from
|
|
35
68
|
* the URL path is ignored in favor of the worker-injected shareId. */
|
|
36
69
|
export function detectMode(): ModeInfo {
|
|
37
70
|
if (typeof window !== "undefined" && window.__CANVAS_SHARE__?.shareId) {
|
|
38
|
-
|
|
71
|
+
const encoded = readFragmentKey();
|
|
72
|
+
return {
|
|
73
|
+
isShared: true,
|
|
74
|
+
shareId: window.__CANVAS_SHARE__.shareId,
|
|
75
|
+
encryptionKey: encoded ? importShareKey(encoded) : null,
|
|
76
|
+
};
|
|
39
77
|
}
|
|
40
78
|
// Local: session id is the path suffix /s/:sessionId
|
|
41
79
|
const sessionId = typeof window !== "undefined"
|
|
@@ -46,6 +84,11 @@ export function detectMode(): ModeInfo {
|
|
|
46
84
|
|
|
47
85
|
export const MODE: ModeInfo = detectMode();
|
|
48
86
|
|
|
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
|
+
|
|
49
92
|
/** Build a canonical id-ish string used as React keys / identifiers. */
|
|
50
93
|
export function getIdentifier(): string {
|
|
51
94
|
return MODE.isShared ? MODE.shareId : MODE.sessionId;
|
|
@@ -75,6 +118,73 @@ export function uploadUrl(): string {
|
|
|
75
118
|
return `/api/session/${MODE.sessionId}/upload`;
|
|
76
119
|
}
|
|
77
120
|
|
|
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
|
+
|
|
78
188
|
// --- Feedback submission ----------------------------------------------------
|
|
79
189
|
|
|
80
190
|
/**
|
|
@@ -115,17 +225,60 @@ export interface SharedFeedbackPayload {
|
|
|
115
225
|
generalNote?: string;
|
|
116
226
|
}
|
|
117
227
|
|
|
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
|
+
|
|
118
265
|
/**
|
|
119
266
|
* Submit feedback in shared mode. Throws on HTTP error. Caller is
|
|
120
267
|
* 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.
|
|
122
270
|
*/
|
|
123
271
|
export async function submitSharedFeedback(payload: SharedFeedbackPayload): Promise<void> {
|
|
124
272
|
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
|
+
}
|
|
125
278
|
const res = await fetch(`/s/${MODE.shareId}/feedback`, {
|
|
126
279
|
method: "POST",
|
|
127
280
|
headers: { "Content-Type": "application/json" },
|
|
128
|
-
body: JSON.stringify(
|
|
281
|
+
body: JSON.stringify(body),
|
|
129
282
|
});
|
|
130
283
|
if (!res.ok) {
|
|
131
284
|
const text = await res.text().catch(() => "");
|
|
@@ -3,6 +3,27 @@ import { marked } from "marked";
|
|
|
3
3
|
|
|
4
4
|
marked.setOptions({ gfm: true, breaks: false });
|
|
5
5
|
|
|
6
|
+
/** Host tags that flow inline and must not break a prose run into separate blocks. */
|
|
7
|
+
const INLINE_TAGS = new Set([
|
|
8
|
+
"a", "abbr", "b", "br", "code", "del", "em", "i", "kbd", "mark",
|
|
9
|
+
"q", "s", "small", "span", "strong", "sub", "sup", "time", "u",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
/** Custom components that render inline (e.g. <Priority>). */
|
|
13
|
+
const INLINE_COMPONENTS = new Set(["Priority"]);
|
|
14
|
+
|
|
15
|
+
function isInlineNode(node: React.ReactNode): boolean {
|
|
16
|
+
if (typeof node === "string" || typeof node === "number") return true;
|
|
17
|
+
if (!React.isValidElement(node)) return false;
|
|
18
|
+
const type = node.type;
|
|
19
|
+
if (typeof type === "string") return INLINE_TAGS.has(type);
|
|
20
|
+
if (typeof type === "function") {
|
|
21
|
+
const name = (type as { displayName?: string; name?: string }).displayName ?? (type as { name?: string }).name;
|
|
22
|
+
return name ? INLINE_COMPONENTS.has(name) : false;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
6
27
|
function renderTextAsMarkdown(text: string, key: string): React.ReactNode {
|
|
7
28
|
const trimmed = text.trim();
|
|
8
29
|
if (!trimmed) return null;
|
|
@@ -18,30 +39,54 @@ function renderTextAsMarkdown(text: string, key: string): React.ReactNode {
|
|
|
18
39
|
);
|
|
19
40
|
}
|
|
20
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Render a run of inline content (text + inline JSX like <code>/<strong>) as a single
|
|
44
|
+
* flowing paragraph. Text segments are parsed as inline markdown; JSX passes through.
|
|
45
|
+
* No `data-md` attribute so the markdown exporter treats it as an inline container.
|
|
46
|
+
*/
|
|
47
|
+
function renderInlineRun(run: React.ReactNode[], key: string): React.ReactNode {
|
|
48
|
+
return (
|
|
49
|
+
<div key={key} className="prose-canvas">
|
|
50
|
+
{run.map((node, i) => {
|
|
51
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
52
|
+
const html = marked.parseInline(String(node), { async: false }) as string;
|
|
53
|
+
return <span key={i} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
54
|
+
}
|
|
55
|
+
return node;
|
|
56
|
+
})}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
21
61
|
/**
|
|
22
62
|
* Auto-render markdown for string children, passing JSX children through.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
63
|
+
* Block-level JSX (Callout, Diff, etc.) breaks the prose into separate markdown blocks,
|
|
64
|
+
* while inline JSX (code, strong, links) stays within the surrounding paragraph so text
|
|
65
|
+
* keeps flowing instead of stacking one fragment per line.
|
|
25
66
|
*/
|
|
26
67
|
export function renderMarkdownChildren(children: React.ReactNode): React.ReactNode {
|
|
27
68
|
const kids = React.Children.toArray(children);
|
|
28
69
|
if (kids.length === 0) return null;
|
|
29
70
|
|
|
30
71
|
const result: React.ReactNode[] = [];
|
|
31
|
-
let
|
|
32
|
-
let
|
|
72
|
+
let segment: React.ReactNode[] = [];
|
|
73
|
+
let segIdx = 0;
|
|
33
74
|
|
|
34
75
|
const flush = () => {
|
|
35
|
-
if (
|
|
36
|
-
|
|
76
|
+
if (segment.length === 0) return;
|
|
77
|
+
const hasInlineJsx = segment.some((n) => typeof n !== "string" && typeof n !== "number");
|
|
78
|
+
if (hasInlineJsx) {
|
|
79
|
+
result.push(renderInlineRun(segment, `md-${segIdx++}`));
|
|
80
|
+
} else {
|
|
81
|
+
const node = renderTextAsMarkdown(segment.join(""), `md-${segIdx++}`);
|
|
37
82
|
if (node) result.push(node);
|
|
38
|
-
buffer = "";
|
|
39
83
|
}
|
|
84
|
+
segment = [];
|
|
40
85
|
};
|
|
41
86
|
|
|
42
87
|
for (const child of kids) {
|
|
43
|
-
if (
|
|
44
|
-
|
|
88
|
+
if (isInlineNode(child)) {
|
|
89
|
+
segment.push(child);
|
|
45
90
|
} else {
|
|
46
91
|
flush();
|
|
47
92
|
result.push(child);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-GCM-256 helpers for end-to-end encrypted canvas shares.
|
|
3
|
+
*
|
|
4
|
+
* The same module is loaded in the browser (decrypts payloads) and in the
|
|
5
|
+
* daemon's Bun runtime (encrypts payloads + decrypts polled feedback). It
|
|
6
|
+
* only uses `crypto.subtle`, `TextEncoder`/`TextDecoder`, and
|
|
7
|
+
* `btoa`/`atob` — all available in both environments.
|
|
8
|
+
*
|
|
9
|
+
* Wire format
|
|
10
|
+
* -----------
|
|
11
|
+
* - Key: 32 random bytes, transported in the URL fragment as base64url
|
|
12
|
+
* (e.g. `#k=<key>`). Fragments are never sent to the worker, so the
|
|
13
|
+
* worker cannot decrypt the payloads it stores.
|
|
14
|
+
* - Each encrypted string is `base64(IV || ciphertext-with-tag)` where the
|
|
15
|
+
* IV is 12 fresh random bytes. This is the standard AES-GCM packing.
|
|
16
|
+
* - The presence of an `encryption: { alg: "AES-GCM", v: 1 }` marker on a
|
|
17
|
+
* SharePayload / FeedbackPostBody / ShareRecord tells the consumer that
|
|
18
|
+
* the marked fields contain ciphertext rather than plaintext.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const CRYPTO_ALG = "AES-GCM" as const;
|
|
22
|
+
export const CRYPTO_VERSION = 1 as const;
|
|
23
|
+
|
|
24
|
+
export interface EncryptionMeta {
|
|
25
|
+
alg: typeof CRYPTO_ALG;
|
|
26
|
+
v: typeof CRYPTO_VERSION;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ENCRYPTION_META: EncryptionMeta = { alg: CRYPTO_ALG, v: CRYPTO_VERSION };
|
|
30
|
+
|
|
31
|
+
/** Generate a fresh 256-bit AES-GCM key. */
|
|
32
|
+
export async function generateShareKey(): Promise<CryptoKey> {
|
|
33
|
+
return crypto.subtle.generateKey(
|
|
34
|
+
{ name: CRYPTO_ALG, length: 256 },
|
|
35
|
+
true,
|
|
36
|
+
["encrypt", "decrypt"],
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Export a CryptoKey to a base64url string suitable for URL fragments. */
|
|
41
|
+
export async function exportShareKey(key: CryptoKey): Promise<string> {
|
|
42
|
+
const raw = await crypto.subtle.exportKey("raw", key);
|
|
43
|
+
return b64urlFromBytes(new Uint8Array(raw));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Import a base64url-encoded key for AES-GCM. */
|
|
47
|
+
export async function importShareKey(encoded: string): Promise<CryptoKey> {
|
|
48
|
+
const raw = bytesFromB64url(encoded);
|
|
49
|
+
return crypto.subtle.importKey(
|
|
50
|
+
"raw",
|
|
51
|
+
raw as BufferSource,
|
|
52
|
+
{ name: CRYPTO_ALG, length: 256 },
|
|
53
|
+
false,
|
|
54
|
+
["encrypt", "decrypt"],
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Encrypt a UTF-8 string. Returns base64(IV || ciphertext+tag). */
|
|
59
|
+
export async function encryptString(key: CryptoKey, plaintext: string): Promise<string> {
|
|
60
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
61
|
+
const data = new TextEncoder().encode(plaintext);
|
|
62
|
+
const ct = await crypto.subtle.encrypt(
|
|
63
|
+
{ name: CRYPTO_ALG, iv: iv as BufferSource },
|
|
64
|
+
key,
|
|
65
|
+
data as BufferSource,
|
|
66
|
+
);
|
|
67
|
+
const combined = new Uint8Array(iv.byteLength + ct.byteLength);
|
|
68
|
+
combined.set(iv, 0);
|
|
69
|
+
combined.set(new Uint8Array(ct), iv.byteLength);
|
|
70
|
+
return b64FromBytes(combined);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Decrypt a base64-encoded ciphertext back to a UTF-8 string. */
|
|
74
|
+
export async function decryptString(key: CryptoKey, ciphertext: string): Promise<string> {
|
|
75
|
+
const combined = bytesFromB64(ciphertext);
|
|
76
|
+
if (combined.byteLength < 12 + 16) throw new Error("Ciphertext too short");
|
|
77
|
+
const iv = combined.subarray(0, 12);
|
|
78
|
+
const ct = combined.subarray(12);
|
|
79
|
+
const pt = await crypto.subtle.decrypt(
|
|
80
|
+
{ name: CRYPTO_ALG, iv: iv as BufferSource },
|
|
81
|
+
key,
|
|
82
|
+
ct as BufferSource,
|
|
83
|
+
);
|
|
84
|
+
return new TextDecoder().decode(pt);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Convenience: encrypt a JSON-serializable value as a single ciphertext blob. */
|
|
88
|
+
export async function encryptJson(key: CryptoKey, value: unknown): Promise<string> {
|
|
89
|
+
return encryptString(key, JSON.stringify(value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Convenience: decrypt a ciphertext blob into a JSON value. */
|
|
93
|
+
export async function decryptJson<T = unknown>(key: CryptoKey, ciphertext: string): Promise<T> {
|
|
94
|
+
return JSON.parse(await decryptString(key, ciphertext)) as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- base64 helpers ---------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function b64FromBytes(bytes: Uint8Array): string {
|
|
100
|
+
let bin = "";
|
|
101
|
+
// Chunk to avoid stack overflow on large inputs.
|
|
102
|
+
const CHUNK = 0x8000;
|
|
103
|
+
for (let i = 0; i < bytes.byteLength; i += CHUNK) {
|
|
104
|
+
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)));
|
|
105
|
+
}
|
|
106
|
+
return btoa(bin);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function bytesFromB64(s: string): Uint8Array {
|
|
110
|
+
const bin = atob(s);
|
|
111
|
+
const out = new Uint8Array(bin.length);
|
|
112
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function b64urlFromBytes(bytes: Uint8Array): string {
|
|
117
|
+
return b64FromBytes(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function bytesFromB64url(s: string): Uint8Array {
|
|
121
|
+
const padded = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
|
|
122
|
+
return bytesFromB64(padded);
|
|
123
|
+
}
|