Skip to content

Commit be5ad60

Browse files
fix: E2E encrypt agent media and fix image display
- Fix AES-CTR silent corruption: agent images were incorrectly fed through E2E decryption (which never throws), producing garbage. Now only decrypt media when `mediaEncrypted` flag is set. - Add E2E encryption for agent-sent images: plugin downloads external image, encrypts with AES-CTR, uploads via new /api/plugin-upload endpoint. - Add /api/plugin-upload route with pairing token auth for plugin uploads. - Use bitmask in `encrypted` column (bit0=text, bit1=media) for backward compatibility with old unencrypted agent media. - Fix WebSocket reconnection storm: skip startAccount when existing client is already connected, preventing health-monitor from killing healthy connections.
1 parent 3f15762 commit be5ad60

9 files changed

Lines changed: 143 additions & 15 deletions

File tree

packages/api/src/do/connection-do.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -328,16 +328,19 @@ export class ConnectionDO implements DurableObject {
328328

329329
// For agent.media, cache external images to R2 so they remain accessible
330330
// even after the original URL expires (e.g. DALL-E temporary URLs).
331+
// Skip caching if the plugin already uploaded E2E-encrypted media.
331332
let persistedMediaUrl = msg.mediaUrl as string | undefined;
332-
if (msg.type === "agent.media" && persistedMediaUrl) {
333+
if (msg.type === "agent.media" && persistedMediaUrl && !msg.mediaEncrypted) {
333334
const cachedUrl = await this.cacheExternalMedia(persistedMediaUrl);
334335
if (cachedUrl) {
335336
persistedMediaUrl = cachedUrl;
336-
// Update the message object so browsers get the cached URL
337337
msg.mediaUrl = cachedUrl;
338338
}
339339
}
340340

341+
// Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
342+
const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
343+
341344
await this.persistMessage({
342345
id: msg.messageId as string | undefined,
343346
sender: "agent",
@@ -346,7 +349,7 @@ export class ConnectionDO implements DurableObject {
346349
text: (msg.text ?? msg.caption ?? "") as string,
347350
mediaUrl: persistedMediaUrl,
348351
a2ui: msg.jsonl as string | undefined,
349-
encrypted: msg.encrypted ? 1 : 0,
352+
encrypted: encryptedBits,
350353
});
351354
}
352355

@@ -1114,15 +1117,21 @@ export class ConnectionDO implements DurableObject {
11141117
if (mediaUrl) {
11151118
mediaUrl = await this.refreshMediaUrl(mediaUrl, secret);
11161119
}
1120+
const encBits = (row.encrypted as number) ?? 0;
1121+
// Derive mediaEncrypted:
1122+
// - User messages with encrypted=1: media was always encrypted by browser
1123+
// - Any message with bit 1 set (encrypted >= 2): media was E2E encrypted
1124+
const mediaEncrypted = (row.sender === "user" && encBits >= 1) || (encBits & 2) !== 0;
11171125
return {
11181126
id: row.id,
11191127
sender: row.sender,
11201128
text: row.text ?? "",
1121-
timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
1129+
timestamp: ((row.created_at as number) ?? 0) * 1000,
11221130
mediaUrl,
11231131
a2ui: row.a2ui ?? undefined,
11241132
threadId: row.thread_id ?? undefined,
1125-
encrypted: row.encrypted ?? 0,
1133+
encrypted: encBits,
1134+
mediaEncrypted,
11261135
};
11271136
}),
11281137
);

packages/api/src/index.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Hono } from "hono";
22
import { cors } from "hono/cors";
33
import type { Env } from "./env.js";
4-
import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature } from "./utils/auth.js";
4+
import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature, signMediaUrl } from "./utils/auth.js";
5+
import { randomUUID } from "./utils/uuid.js";
56
import { auth } from "./routes/auth.js";
67
import { agents } from "./routes/agents.js";
78
import { channels } from "./routes/channels.js";
@@ -477,6 +478,53 @@ app.get("/api/messages/:userId", async (c) => {
477478
return stub.fetch(new Request(url.toString()));
478479
});
479480

481+
// ---- Plugin upload (pairing token auth) ----
482+
app.post("/api/plugin-upload", async (c) => {
483+
const token = c.req.header("X-Pairing-Token");
484+
if (!token) {
485+
return c.json({ error: "Missing X-Pairing-Token header" }, 401);
486+
}
487+
const row = await c.env.DB.prepare(
488+
"SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
489+
)
490+
.bind(token)
491+
.first<{ user_id: string }>();
492+
if (!row) {
493+
return c.json({ error: "Invalid pairing token" }, 401);
494+
}
495+
496+
const userId = row.user_id;
497+
const contentType = c.req.header("Content-Type") ?? "";
498+
if (!contentType.includes("multipart/form-data")) {
499+
return c.json({ error: "Expected multipart/form-data" }, 400);
500+
}
501+
502+
const formData = await c.req.formData();
503+
const file = formData.get("file") as File | null;
504+
if (!file) {
505+
return c.json({ error: "No file provided" }, 400);
506+
}
507+
508+
const MAX_SIZE = 20 * 1024 * 1024;
509+
if (file.size > MAX_SIZE) {
510+
return c.json({ error: "File too large (max 20 MB)" }, 413);
511+
}
512+
513+
const fileType = file.type || "application/octet-stream";
514+
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
515+
const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : "bin";
516+
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
517+
const key = `media/${userId}/${filename}`;
518+
519+
await c.env.MEDIA.put(key, file.stream(), {
520+
httpMetadata: { contentType: fileType },
521+
});
522+
523+
const secret = getJwtSecret(c.env);
524+
const url = await signMediaUrl(userId, filename, secret, 3600);
525+
return c.json({ url, key });
526+
});
527+
480528
// ---- Protected routes (require Bearer token) — AFTER ws routes ----
481529
app.route("/api", protectedApp);
482530

packages/plugin/src/channel.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getBotsChatRuntime } from "./runtime.js";
99
import type { BotsChatChannelConfig, CloudInbound, ResolvedBotsChatAccount } from "./types.js";
1010
import { BotsChatCloudClient } from "./ws-client.js";
1111
import crypto from "crypto";
12-
import { encryptText, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
12+
import { encryptText, encryptBytes, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
1313

1414
// ---------------------------------------------------------------------------
1515
// A2UI message-tool hints — injected via agentPrompt.messageToolHints so
@@ -51,6 +51,8 @@ function readAgentModel(_agentId: string): string | undefined {
5151
const cloudClients = new Map<string, BotsChatCloudClient>();
5252
/** Maps accountId → cloudUrl so handleCloudMessage can resolve relative URLs */
5353
const cloudUrls = new Map<string, string>();
54+
/** Maps accountId → pairingToken for plugin HTTP uploads */
55+
const pairingTokens = new Map<string, string>();
5456

5557
function getCloudClient(accountId: string): BotsChatCloudClient | undefined {
5658
return cloudClients.get(accountId);
@@ -176,17 +178,18 @@ export const botschatPlugin = {
176178
mediaUrl?: string;
177179
accountId?: string | null;
178180
}) => {
179-
const client = getCloudClient(ctx.accountId ?? "default");
181+
const accountId = ctx.accountId ?? "default";
182+
const client = getCloudClient(accountId);
180183
if (!client?.connected) {
181184
return { ok: false, error: new Error("Not connected to BotsChat cloud") };
182185
}
183186
const messageId = crypto.randomUUID();
184187
let text = ctx.text;
185188
let encrypted = false;
189+
let mediaEncrypted = false;
186190

187-
if (client.e2eKey && text) { // Only encrypt checksum if present
191+
if (client.e2eKey && text) {
188192
try {
189-
// Encrypt caption using messageId as contextId
190193
const ciphertext = await encryptText(client.e2eKey, text, messageId);
191194
text = toBase64(ciphertext);
192195
encrypted = true;
@@ -195,17 +198,63 @@ export const botschatPlugin = {
195198
}
196199
}
197200

201+
let finalMediaUrl = ctx.mediaUrl;
202+
203+
if (client.e2eKey && ctx.mediaUrl && !ctx.mediaUrl.startsWith("/api/media/")) {
204+
try {
205+
const baseUrl = cloudUrls.get(accountId);
206+
const token = pairingTokens.get(accountId);
207+
if (baseUrl && token) {
208+
const resp = await fetch(ctx.mediaUrl, { signal: AbortSignal.timeout(15_000) });
209+
if (resp.ok) {
210+
const rawBytes = new Uint8Array(await resp.arrayBuffer());
211+
const encBytes = await encryptBytes(client.e2eKey, rawBytes, `${messageId}:media`);
212+
213+
const contentType = resp.headers.get("Content-Type") ?? "application/octet-stream";
214+
const extMap: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
215+
const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
216+
217+
const formData = new FormData();
218+
const blob = new Blob([encBytes as any], { type: contentType });
219+
formData.append("file", blob, `encrypted.${ext}`);
220+
221+
const uploadUrl = `${baseUrl.replace(/\/$/, "")}/api/plugin-upload`;
222+
const uploadResp = await fetch(uploadUrl, {
223+
method: "POST",
224+
headers: { "X-Pairing-Token": token },
225+
body: formData as any,
226+
signal: AbortSignal.timeout(30_000),
227+
});
228+
229+
if (uploadResp.ok) {
230+
const result = await uploadResp.json() as { url: string };
231+
finalMediaUrl = result.url;
232+
mediaEncrypted = true;
233+
console.log(`[botschat][sendMedia] E2E encrypted media uploaded (${rawBytes.length}${encBytes.length} bytes)`);
234+
} else {
235+
console.error(`[botschat][sendMedia] Plugin upload failed: HTTP ${uploadResp.status}`);
236+
}
237+
} else {
238+
console.error(`[botschat][sendMedia] Failed to download media: HTTP ${resp.status}`);
239+
}
240+
}
241+
} catch (err) {
242+
console.error(`[botschat][sendMedia] E2E media encryption failed, sending unencrypted:`, err);
243+
}
244+
}
245+
198246
const notifyPreview = (encrypted && client.notifyPreview && ctx.text)
199247
? (ctx.text.length > 100 ? ctx.text.slice(0, 100) + "…" : ctx.text)
200248
: undefined;
201-
if (ctx.mediaUrl) {
249+
if (finalMediaUrl) {
202250
client.send({
203251
type: "agent.media",
204252
sessionKey: ctx.to,
205-
mediaUrl: ctx.mediaUrl,
253+
mediaUrl: finalMediaUrl,
206254
caption: text || undefined,
207255
messageId,
208256
encrypted,
257+
mediaEncrypted,
209258
...(notifyPreview ? { notifyPreview } : {}),
210259
});
211260
} else {
@@ -241,11 +290,16 @@ export const botschatPlugin = {
241290
}
242291

243292
const existingClient = cloudClients.get(accountId);
293+
if (existingClient?.connected) {
294+
log?.info(`[${accountId}] Already connected — skipping restart`);
295+
return existingClient;
296+
}
244297
if (existingClient) {
245-
log?.info(`[${accountId}] Disconnecting previous client before reconnect`);
298+
log?.info(`[${accountId}] Disconnecting stale client before reconnect`);
246299
existingClient.disconnect();
247300
cloudClients.delete(accountId);
248301
cloudUrls.delete(accountId);
302+
pairingTokens.delete(accountId);
249303
}
250304

251305
ctx.setStatus({
@@ -281,12 +335,14 @@ export const botschatPlugin = {
281335

282336
cloudClients.set(accountId, client);
283337
cloudUrls.set(accountId, account.cloudUrl);
338+
pairingTokens.set(accountId, account.pairingToken);
284339
client.connect();
285340

286341
ctx.abortSignal.addEventListener("abort", () => {
287342
client.disconnect();
288343
cloudClients.delete(accountId);
289344
cloudUrls.delete(accountId);
345+
pairingTokens.delete(accountId);
290346
});
291347

292348
return client;

packages/plugin/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export type CloudOutbound =
5858
replyToId?: string;
5959
threadId?: string;
6060
encrypted?: boolean;
61+
mediaEncrypted?: boolean;
6162
messageId?: string;
6263
notifyPreview?: string;
6364
}

packages/web/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,7 @@ export default function App() {
696696
text: msg.text as string,
697697
timestamp: Date.now(),
698698
threadId,
699+
encrypted: !!msg.encrypted,
699700
};
700701
if (threadId && sessionKey) {
701702
dispatch({ type: "ADD_THREAD_MESSAGE", message: chatMsg });
@@ -714,6 +715,8 @@ export default function App() {
714715
mediaUrl: msg.mediaUrl as string,
715716
timestamp: Date.now(),
716717
threadId,
718+
encrypted: !!msg.encrypted,
719+
mediaEncrypted: !!msg.mediaEncrypted,
717720
};
718721
if (threadId && sessionKey) {
719722
dispatch({ type: "ADD_THREAD_MESSAGE", message: mediaMsg });

packages/web/src/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,8 @@ export type MessageRecord = {
291291
mediaUrl?: string;
292292
a2ui?: string;
293293
threadId?: string;
294-
encrypted?: boolean;
294+
encrypted?: boolean | number;
295+
mediaEncrypted?: boolean;
295296
};
296297

297298
export const messagesApi = {

packages/web/src/components/ChatWindow.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,8 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
461461
text: trimmed,
462462
timestamp: Date.now(),
463463
mediaUrl,
464+
encrypted: E2eService.hasKey(),
465+
mediaEncrypted: !!mediaUrl && E2eService.hasKey(),
464466
};
465467

466468
dispatch({ type: "ADD_MESSAGE", message: msg });
@@ -1061,7 +1063,7 @@ function MessageRow({
10611063
text={msg.text}
10621064
mediaUrl={msg.mediaUrl}
10631065
messageId={msg.id}
1064-
encrypted={!!msg.mediaUrl && E2eService.hasKey()}
1066+
encrypted={!!msg.mediaEncrypted && !!msg.mediaUrl && E2eService.hasKey()}
10651067
a2ui={msg.a2ui}
10661068
isStreaming={msg.isStreaming}
10671069
onAction={onAction}

packages/web/src/components/ThreadPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
216216
<MessageContent
217217
text={parentMessage.text}
218218
mediaUrl={parentMessage.mediaUrl}
219+
messageId={parentMessage.id}
220+
encrypted={!!parentMessage.mediaEncrypted && !!parentMessage.mediaUrl && E2eService.hasKey()}
219221
a2ui={parentMessage.a2ui}
220222
onAction={handleA2UIAction}
221223
onResolveAction={(value, label) => handleResolveAction(parentMessage.id, value, label)}
@@ -274,6 +276,8 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
274276
<MessageContent
275277
text={msg.text}
276278
mediaUrl={msg.mediaUrl}
279+
messageId={msg.id}
280+
encrypted={!!msg.mediaEncrypted && !!msg.mediaUrl && E2eService.hasKey()}
277281
a2ui={msg.a2ui}
278282
isStreaming={msg.isStreaming}
279283
onAction={handleA2UIAction}

packages/web/src/store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export type ChatMessage = {
1515
/** Tracks which action blocks have been resolved, keyed by prompt hash */
1616
resolvedActions?: Record<string, { value: string; label: string }>;
1717
isEncryptedLocked?: boolean;
18+
/** Whether the message text was E2E encrypted (bitmask from API: bit0=text, bit1=media) */
19+
encrypted?: boolean | number;
20+
/** Whether the media binary was E2E encrypted (derived from sender + encrypted flag) */
21+
mediaEncrypted?: boolean;
1822
};
1923

2024
export type ActiveView = "messages" | "automations";

0 commit comments

Comments
 (0)