botschat 0.1.6 → 0.1.8
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/README.md +62 -22
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +4 -2
- package/packages/api/src/do/connection-do.ts +37 -9
- package/packages/api/src/index.ts +29 -7
- package/packages/api/src/routes/auth.ts +4 -1
- package/packages/api/src/routes/setup.ts +2 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
- package/packages/plugin/dist/src/accounts.js +1 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +1 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +180 -13
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +16 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +18 -3
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +3 -2
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/packages/web/dist/assets/{index-BST9bfvT.css → index-B1sFqYiM.css} +1 -1
- package/packages/web/dist/assets/index-C-FpELeN.js +1497 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +53 -9
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +4 -0
- package/packages/web/src/components/ConnectionSettings.tsx +3 -1
- package/packages/web/src/components/E2ESettings.tsx +146 -0
- package/packages/web/src/components/IconRail.tsx +1 -12
- package/packages/web/src/components/LoginPage.tsx +19 -3
- package/packages/web/src/components/OnboardingPage.tsx +199 -5
- package/packages/web/src/e2e.ts +146 -0
- package/packages/web/src/main.tsx +3 -0
- package/packages/web/src/store.ts +4 -3
- package/packages/web/src/ws.ts +79 -4
- package/scripts/dev.sh +5 -5
- package/scripts/test-e2e-chat.ts +97 -0
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- package/packages/web/dist/assets/index-Da18EnTa.js +0 -851
|
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from "react";
|
|
|
2
2
|
import { pairingApi, setupApi, type PairingToken } from "../api";
|
|
3
3
|
import { useAppState } from "../store";
|
|
4
4
|
import { dlog } from "../debug-log";
|
|
5
|
+
import { E2eService } from "../e2e";
|
|
5
6
|
|
|
6
7
|
/** Clipboard copy button with feedback */
|
|
7
8
|
function CopyButton({ text }: { text: string }) {
|
|
@@ -78,6 +79,15 @@ export function OnboardingPage({ onSkip }: { onSkip: () => void }) {
|
|
|
78
79
|
const [pairingToken, setPairingToken] = useState<string | null>(null);
|
|
79
80
|
const [loadingToken, setLoadingToken] = useState(true);
|
|
80
81
|
|
|
82
|
+
// E2E password step
|
|
83
|
+
const [e2ePassword, setE2ePassword] = useState("");
|
|
84
|
+
const [e2eConfirm, setE2eConfirm] = useState("");
|
|
85
|
+
const [e2eRemember, setE2eRemember] = useState(true);
|
|
86
|
+
const [e2eReady, setE2eReady] = useState(false); // true after password is set
|
|
87
|
+
const [e2eError, setE2eError] = useState("");
|
|
88
|
+
const [e2eLoading, setE2eLoading] = useState(false);
|
|
89
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
90
|
+
|
|
81
91
|
// Cloud URL — resolved by backend (smart priority), editable by user
|
|
82
92
|
const [cloudUrl, setCloudUrl] = useState<string>(
|
|
83
93
|
typeof window !== "undefined" ? window.location.origin : "https://console.botschat.app",
|
|
@@ -138,10 +148,32 @@ export function OnboardingPage({ onSkip }: { onSkip: () => void }) {
|
|
|
138
148
|
return () => { cancelled = true; };
|
|
139
149
|
}, []);
|
|
140
150
|
|
|
151
|
+
// E2E password validation
|
|
152
|
+
const e2ePasswordValid = e2ePassword.length >= 6 && e2ePassword === e2eConfirm;
|
|
153
|
+
|
|
154
|
+
const handleE2eSubmit = async () => {
|
|
155
|
+
if (!e2ePasswordValid) return;
|
|
156
|
+
if (!state.user?.id) {
|
|
157
|
+
setE2eError("User not loaded yet. Please wait.");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
setE2eLoading(true);
|
|
161
|
+
setE2eError("");
|
|
162
|
+
try {
|
|
163
|
+
await E2eService.setPassword(e2ePassword, state.user.id, e2eRemember);
|
|
164
|
+
setE2eReady(true);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
setE2eError("Failed to derive encryption key. Please try again.");
|
|
167
|
+
} finally {
|
|
168
|
+
setE2eLoading(false);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
141
172
|
const setupCommand = pairingToken
|
|
142
173
|
? `openclaw plugins install @botschat/botschat && \\
|
|
143
174
|
openclaw config set channels.botschat.cloudUrl ${cloudUrl} && \\
|
|
144
175
|
openclaw config set channels.botschat.pairingToken ${pairingToken} && \\
|
|
176
|
+
openclaw config set channels.botschat.e2ePassword "${e2ePassword}" && \\
|
|
145
177
|
openclaw config set channels.botschat.enabled true && \\
|
|
146
178
|
openclaw gateway restart`
|
|
147
179
|
: "Loading...";
|
|
@@ -220,14 +252,176 @@ openclaw gateway restart`
|
|
|
220
252
|
</span>
|
|
221
253
|
</div>
|
|
222
254
|
|
|
223
|
-
{/* Step 1 */}
|
|
255
|
+
{/* Step 1: E2E Password (mandatory) */}
|
|
224
256
|
<div className="mb-6">
|
|
257
|
+
<div className="flex items-center gap-2 mb-2">
|
|
258
|
+
<span
|
|
259
|
+
className="inline-flex items-center justify-center w-6 h-6 rounded-full text-tiny font-bold text-white"
|
|
260
|
+
style={{ background: e2eReady ? "var(--accent-green)" : "var(--bg-active)" }}
|
|
261
|
+
>
|
|
262
|
+
{e2eReady ? (
|
|
263
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
264
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
265
|
+
</svg>
|
|
266
|
+
) : "1"}
|
|
267
|
+
</span>
|
|
268
|
+
<h3 className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
|
|
269
|
+
Set your E2E encryption password
|
|
270
|
+
</h3>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{!e2eReady ? (
|
|
274
|
+
<div className="ml-8">
|
|
275
|
+
<p className="text-caption mb-3" style={{ color: "var(--text-secondary)" }}>
|
|
276
|
+
Your messages, prompts, and task results will be <strong>encrypted on this device</strong> before
|
|
277
|
+
they leave — the server only stores ciphertext it cannot read.
|
|
278
|
+
</p>
|
|
279
|
+
|
|
280
|
+
{/* Architecture diagram */}
|
|
281
|
+
<div className="mb-3 rounded-md overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
|
282
|
+
<img
|
|
283
|
+
src="/architecture.png"
|
|
284
|
+
alt="BotsChat E2E Encryption Architecture"
|
|
285
|
+
className="w-full"
|
|
286
|
+
style={{ display: "block" }}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
<p className="text-caption mb-4" style={{ color: "var(--text-muted)" }}>
|
|
290
|
+
Encryption keys are derived locally and never sent to the server.{" "}
|
|
291
|
+
<a
|
|
292
|
+
href="https://botschat.app/#features"
|
|
293
|
+
target="_blank"
|
|
294
|
+
rel="noopener noreferrer"
|
|
295
|
+
className="underline"
|
|
296
|
+
style={{ color: "var(--text-link)" }}
|
|
297
|
+
>
|
|
298
|
+
Learn more
|
|
299
|
+
</a>
|
|
300
|
+
</p>
|
|
301
|
+
|
|
302
|
+
{/* Password inputs */}
|
|
303
|
+
<div className="space-y-2.5">
|
|
304
|
+
<div className="relative">
|
|
305
|
+
<input
|
|
306
|
+
type={showPassword ? "text" : "password"}
|
|
307
|
+
value={e2ePassword}
|
|
308
|
+
onChange={(e) => setE2ePassword(e.target.value)}
|
|
309
|
+
placeholder="E2E encryption password (min 6 chars)"
|
|
310
|
+
className="w-full px-3 py-2 pr-10 rounded-sm text-caption"
|
|
311
|
+
style={{
|
|
312
|
+
background: "var(--code-bg)",
|
|
313
|
+
border: "1px solid var(--border)",
|
|
314
|
+
color: "var(--text-primary)",
|
|
315
|
+
outline: "none",
|
|
316
|
+
}}
|
|
317
|
+
/>
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
321
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 p-1"
|
|
322
|
+
style={{ color: "var(--text-muted)" }}
|
|
323
|
+
tabIndex={-1}
|
|
324
|
+
>
|
|
325
|
+
{showPassword ? (
|
|
326
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
327
|
+
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
|
328
|
+
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
|
329
|
+
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>
|
|
330
|
+
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
331
|
+
</svg>
|
|
332
|
+
) : (
|
|
333
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
334
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
335
|
+
<circle cx="12" cy="12" r="3"/>
|
|
336
|
+
</svg>
|
|
337
|
+
)}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
<div className="relative">
|
|
341
|
+
<input
|
|
342
|
+
type={showPassword ? "text" : "password"}
|
|
343
|
+
value={e2eConfirm}
|
|
344
|
+
onChange={(e) => setE2eConfirm(e.target.value)}
|
|
345
|
+
placeholder="Confirm password"
|
|
346
|
+
className="w-full px-3 py-2 pr-10 rounded-sm text-caption"
|
|
347
|
+
style={{
|
|
348
|
+
background: "var(--code-bg)",
|
|
349
|
+
border: `1px solid ${e2eConfirm && e2ePassword !== e2eConfirm ? "var(--accent-red, #e53e3e)" : "var(--border)"}`,
|
|
350
|
+
color: "var(--text-primary)",
|
|
351
|
+
outline: "none",
|
|
352
|
+
}}
|
|
353
|
+
/>
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
357
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 p-1"
|
|
358
|
+
style={{ color: "var(--text-muted)" }}
|
|
359
|
+
tabIndex={-1}
|
|
360
|
+
>
|
|
361
|
+
{showPassword ? (
|
|
362
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
363
|
+
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
|
364
|
+
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
|
365
|
+
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>
|
|
366
|
+
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
367
|
+
</svg>
|
|
368
|
+
) : (
|
|
369
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
370
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
371
|
+
<circle cx="12" cy="12" r="3"/>
|
|
372
|
+
</svg>
|
|
373
|
+
)}
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
{e2eConfirm && e2ePassword !== e2eConfirm && (
|
|
377
|
+
<p className="text-caption" style={{ color: "var(--accent-red, #e53e3e)" }}>
|
|
378
|
+
Passwords do not match.
|
|
379
|
+
</p>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{/* Remember checkbox */}
|
|
383
|
+
<label className="flex items-center gap-2 text-caption" style={{ color: "var(--text-secondary)" }}>
|
|
384
|
+
<input
|
|
385
|
+
type="checkbox"
|
|
386
|
+
checked={e2eRemember}
|
|
387
|
+
onChange={(e) => setE2eRemember(e.target.checked)}
|
|
388
|
+
/>
|
|
389
|
+
Remember on this device
|
|
390
|
+
</label>
|
|
391
|
+
|
|
392
|
+
{e2eError && (
|
|
393
|
+
<p className="text-caption" style={{ color: "var(--accent-red, #e53e3e)" }}>{e2eError}</p>
|
|
394
|
+
)}
|
|
395
|
+
|
|
396
|
+
<button
|
|
397
|
+
onClick={handleE2eSubmit}
|
|
398
|
+
disabled={!e2ePasswordValid || e2eLoading}
|
|
399
|
+
className="w-full py-2 font-bold text-caption text-white rounded-sm transition-colors"
|
|
400
|
+
style={{
|
|
401
|
+
background: e2ePasswordValid && !e2eLoading ? "var(--bg-active)" : "var(--bg-hover)",
|
|
402
|
+
cursor: e2ePasswordValid && !e2eLoading ? "pointer" : "not-allowed",
|
|
403
|
+
opacity: e2ePasswordValid && !e2eLoading ? 1 : 0.5,
|
|
404
|
+
}}
|
|
405
|
+
>
|
|
406
|
+
{e2eLoading ? "Deriving key..." : "Set E2E Password & Continue"}
|
|
407
|
+
</button>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
) : (
|
|
411
|
+
<p className="text-caption ml-8" style={{ color: "var(--accent-green)" }}>
|
|
412
|
+
E2E encryption is active. Your encryption key has been derived.
|
|
413
|
+
</p>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
{/* Step 2: Install command (only shown after E2E password set) */}
|
|
418
|
+
<div className="mb-6" style={{ opacity: e2eReady ? 1 : 0.4, pointerEvents: e2eReady ? "auto" : "none" }}>
|
|
225
419
|
<div className="flex items-center gap-2 mb-2">
|
|
226
420
|
<span
|
|
227
421
|
className="inline-flex items-center justify-center w-6 h-6 rounded-full text-tiny font-bold text-white"
|
|
228
422
|
style={{ background: "var(--bg-active)" }}
|
|
229
423
|
>
|
|
230
|
-
|
|
424
|
+
2
|
|
231
425
|
</span>
|
|
232
426
|
<h3 className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
|
|
233
427
|
Run this command on your OpenClaw machine
|
|
@@ -306,14 +500,14 @@ openclaw gateway restart`
|
|
|
306
500
|
</div>
|
|
307
501
|
</div>
|
|
308
502
|
|
|
309
|
-
{/* Step
|
|
310
|
-
<div className="mb-6">
|
|
503
|
+
{/* Step 3: Verify */}
|
|
504
|
+
<div className="mb-6" style={{ opacity: e2eReady ? 1 : 0.4, pointerEvents: e2eReady ? "auto" : "none" }}>
|
|
311
505
|
<div className="flex items-center gap-2 mb-2">
|
|
312
506
|
<span
|
|
313
507
|
className="inline-flex items-center justify-center w-6 h-6 rounded-full text-tiny font-bold text-white"
|
|
314
508
|
style={{ background: "var(--bg-active)" }}
|
|
315
509
|
>
|
|
316
|
-
|
|
510
|
+
3
|
|
317
511
|
</span>
|
|
318
512
|
<h3 className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
|
|
319
513
|
Verify connection
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { deriveKey, encryptText, decryptText, toBase64, fromBase64 } from "e2e-crypto";
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = "botschat_e2e_pwd_cache";
|
|
4
|
+
const KEY_CACHE_KEY = "botschat_e2e_key_cache"; // base64-encoded derived key
|
|
5
|
+
|
|
6
|
+
let currentKey: Uint8Array | null = null;
|
|
7
|
+
let currentPassword: string | null = null;
|
|
8
|
+
const listeners: Set<() => void> = new Set();
|
|
9
|
+
|
|
10
|
+
// Try to restore cached key immediately (synchronous, no PBKDF2)
|
|
11
|
+
try {
|
|
12
|
+
const cachedKey = localStorage.getItem(KEY_CACHE_KEY);
|
|
13
|
+
if (cachedKey) {
|
|
14
|
+
currentKey = fromBase64(cachedKey);
|
|
15
|
+
currentPassword = localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
}
|
|
17
|
+
} catch { /* ignore */ }
|
|
18
|
+
|
|
19
|
+
export const E2eService = {
|
|
20
|
+
/**
|
|
21
|
+
* Subscribe to key state changes. Returns unsubscribe function.
|
|
22
|
+
*/
|
|
23
|
+
subscribe(cb: () => void): () => void {
|
|
24
|
+
listeners.add(cb);
|
|
25
|
+
return () => listeners.delete(cb);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Notify all listeners.
|
|
30
|
+
*/
|
|
31
|
+
notify() {
|
|
32
|
+
listeners.forEach((cb) => cb());
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set the E2E password and derive the key.
|
|
37
|
+
* Optionally persist the password and derived key to localStorage.
|
|
38
|
+
*/
|
|
39
|
+
async setPassword(password: string, userId: string, remember: boolean): Promise<void> {
|
|
40
|
+
if (!password) {
|
|
41
|
+
currentKey = null;
|
|
42
|
+
currentPassword = null;
|
|
43
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
44
|
+
localStorage.removeItem(KEY_CACHE_KEY);
|
|
45
|
+
this.notify();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
currentKey = await deriveKey(password, userId);
|
|
51
|
+
currentPassword = password;
|
|
52
|
+
if (remember) {
|
|
53
|
+
localStorage.setItem(STORAGE_KEY, password);
|
|
54
|
+
localStorage.setItem(KEY_CACHE_KEY, toBase64(currentKey));
|
|
55
|
+
} else {
|
|
56
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
57
|
+
localStorage.removeItem(KEY_CACHE_KEY);
|
|
58
|
+
}
|
|
59
|
+
this.notify();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error("Failed to derive E2E key:", err);
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Clear the key and password from memory and storage.
|
|
68
|
+
*/
|
|
69
|
+
clear(): void {
|
|
70
|
+
currentKey = null;
|
|
71
|
+
currentPassword = null;
|
|
72
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
73
|
+
localStorage.removeItem(KEY_CACHE_KEY);
|
|
74
|
+
this.notify();
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if we have a key loaded.
|
|
79
|
+
*/
|
|
80
|
+
hasKey(): boolean {
|
|
81
|
+
return !!currentKey;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if we have a saved password in storage.
|
|
86
|
+
*/
|
|
87
|
+
hasSavedPassword(): boolean {
|
|
88
|
+
return !!localStorage.getItem(STORAGE_KEY);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Try to load the key from cache or derive from saved password.
|
|
93
|
+
* Cache path is synchronous (already done at module load).
|
|
94
|
+
* Derive path is async (PBKDF2).
|
|
95
|
+
*/
|
|
96
|
+
async loadSavedPassword(userId: string): Promise<boolean> {
|
|
97
|
+
// Already loaded from cache at module init
|
|
98
|
+
if (currentKey) return true;
|
|
99
|
+
const pwd = localStorage.getItem(STORAGE_KEY);
|
|
100
|
+
if (!pwd) return false;
|
|
101
|
+
try {
|
|
102
|
+
await this.setPassword(pwd, userId, true);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Encrypt text using the current key.
|
|
111
|
+
* Generates a random messageId (UUID) as contextId/nonce source.
|
|
112
|
+
* Returns { ciphertext: base64, messageId: string }
|
|
113
|
+
*/
|
|
114
|
+
async encrypt(text: string): Promise<{ ciphertext: string; messageId: string }> {
|
|
115
|
+
if (!currentKey) throw new Error("E2E key not set");
|
|
116
|
+
const messageId = crypto.randomUUID();
|
|
117
|
+
const encrypted = await encryptText(currentKey, text, messageId);
|
|
118
|
+
return { ciphertext: toBase64(encrypted), messageId };
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Decrypt text (base64) using the current key and messageId (contextId).
|
|
123
|
+
*/
|
|
124
|
+
async decrypt(ciphertextBase64: string, messageId: string): Promise<string> {
|
|
125
|
+
if (!currentKey) throw new Error("E2E key not set");
|
|
126
|
+
const ciphertext = fromBase64(ciphertextBase64);
|
|
127
|
+
return decryptText(currentKey, ciphertext, messageId);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get the current E2E password (in memory). Returns null if not set.
|
|
132
|
+
*/
|
|
133
|
+
getPassword(): string | null {
|
|
134
|
+
return currentPassword;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Decrypt bytes (base64) -> Uint8Array.
|
|
139
|
+
*/
|
|
140
|
+
async decryptBytes(ciphertextBase64: string, messageId: string): Promise<Uint8Array> {
|
|
141
|
+
if (!currentKey) throw new Error("E2E key not set");
|
|
142
|
+
const ciphertext = fromBase64(ciphertextBase64);
|
|
143
|
+
const plainStr = await decryptText(currentKey, ciphertext, messageId);
|
|
144
|
+
return new TextEncoder().encode(plainStr);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
@@ -2,6 +2,9 @@ import React from "react";
|
|
|
2
2
|
import ReactDOM from "react-dom/client";
|
|
3
3
|
import App from "./App";
|
|
4
4
|
import "./index.css";
|
|
5
|
+
import { initAnalytics } from "./analytics";
|
|
6
|
+
|
|
7
|
+
initAnalytics();
|
|
5
8
|
|
|
6
9
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
7
10
|
<React.StrictMode>
|
|
@@ -14,6 +14,7 @@ export type ChatMessage = {
|
|
|
14
14
|
isStreaming?: boolean; // true while streaming is in progress
|
|
15
15
|
/** Tracks which action blocks have been resolved, keyed by prompt hash */
|
|
16
16
|
resolvedActions?: Record<string, { value: string; label: string }>;
|
|
17
|
+
isEncryptedLocked?: boolean;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export type ActiveView = "messages" | "automations";
|
|
@@ -302,12 +303,12 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
305
|
case "SET_OPENCLAW_CONNECTED":
|
|
305
|
-
// connection.status carries the
|
|
306
|
-
//
|
|
306
|
+
// connection.status carries the gateway defaultModel (OpenClaw primary).
|
|
307
|
+
// Prefer user's saved default (from API/settings); only use gateway default when user has not set one.
|
|
307
308
|
return {
|
|
308
309
|
...state,
|
|
309
310
|
openclawConnected: action.connected,
|
|
310
|
-
defaultModel:
|
|
311
|
+
defaultModel: state.defaultModel ?? action.defaultModel ?? null,
|
|
311
312
|
};
|
|
312
313
|
case "SET_SESSION_MODEL":
|
|
313
314
|
return { ...state, sessionModel: action.model };
|
package/packages/web/src/ws.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** WebSocket client for connecting to the BotsChat ConnectionDO. */
|
|
2
2
|
|
|
3
3
|
import { dlog } from "./debug-log";
|
|
4
|
+
import { E2eService } from "./e2e";
|
|
4
5
|
|
|
5
6
|
export type WSMessage = {
|
|
6
7
|
type: string;
|
|
@@ -44,19 +45,78 @@ export class BotsChatWSClient {
|
|
|
44
45
|
this.ws!.send(JSON.stringify({ type: "auth", token: this.opts.token }));
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
this.ws.onmessage = (evt) => {
|
|
48
|
+
this.ws.onmessage = async (evt) => {
|
|
48
49
|
try {
|
|
49
50
|
const msg = JSON.parse(evt.data) as WSMessage;
|
|
51
|
+
|
|
52
|
+
// Handle E2E Decryption
|
|
53
|
+
console.log(`[E2E-WS] msg.type=${msg.type} encrypted=${msg.encrypted} hasKey=${E2eService.hasKey()} messageId=${msg.messageId}`);
|
|
54
|
+
if (msg.encrypted && E2eService.hasKey()) {
|
|
55
|
+
try {
|
|
56
|
+
if (msg.type === "agent.text" || msg.type === "agent.media") {
|
|
57
|
+
// Decrypt text/caption
|
|
58
|
+
const text = msg.text as string | undefined;
|
|
59
|
+
const caption = msg.caption as string | undefined;
|
|
60
|
+
const messageId = msg.messageId as string;
|
|
61
|
+
|
|
62
|
+
if (text && messageId) {
|
|
63
|
+
msg.text = await E2eService.decrypt(text, messageId);
|
|
64
|
+
msg.encrypted = false;
|
|
65
|
+
}
|
|
66
|
+
if (caption && messageId) {
|
|
67
|
+
msg.caption = await E2eService.decrypt(caption, messageId);
|
|
68
|
+
msg.encrypted = false;
|
|
69
|
+
}
|
|
70
|
+
} else if (msg.type === "job.update") {
|
|
71
|
+
const summary = msg.summary as string;
|
|
72
|
+
// Job ID is contextId
|
|
73
|
+
const jobId = msg.jobId as string;
|
|
74
|
+
if (summary && jobId) {
|
|
75
|
+
msg.summary = await E2eService.decrypt(summary, jobId);
|
|
76
|
+
msg.encrypted = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
dlog.warn("E2E", "Decryption failed", err);
|
|
81
|
+
msg.decryptionError = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Handle Task Scan Results (array items)
|
|
86
|
+
if (msg.type === "task.scan.result" && Array.isArray(msg.tasks) && E2eService.hasKey()) {
|
|
87
|
+
for (const t of msg.tasks) {
|
|
88
|
+
if (t.encrypted && t.iv) {
|
|
89
|
+
try {
|
|
90
|
+
if (t.schedule) t.schedule = await E2eService.decrypt(t.schedule, t.iv);
|
|
91
|
+
if (t.instructions) t.instructions = await E2eService.decrypt(t.instructions, t.iv);
|
|
92
|
+
t.encrypted = false;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
dlog.warn("E2E", `Task decryption failed for ${t.cronJobId}`, err);
|
|
95
|
+
t.decryptionError = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
50
101
|
if (msg.type === "auth.ok") {
|
|
51
102
|
dlog.info("WS", "Auth OK — connected");
|
|
103
|
+
|
|
104
|
+
// Try to load E2E password
|
|
105
|
+
const userId = msg.userId as string;
|
|
106
|
+
console.log(`[E2E-WS] auth.ok userId=${userId}, hasSavedPwd=${E2eService.hasSavedPassword()}`);
|
|
107
|
+
if (userId && E2eService.hasSavedPassword()) {
|
|
108
|
+
const loaded = await E2eService.loadSavedPassword(userId);
|
|
109
|
+
console.log(`[E2E-WS] loadSavedPassword result=${loaded}, hasKey=${E2eService.hasKey()}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
52
112
|
this.backoffMs = 1000;
|
|
53
113
|
this._connected = true;
|
|
54
114
|
this.opts.onStatusChange(true);
|
|
55
115
|
} else {
|
|
56
116
|
this.opts.onMessage(msg);
|
|
57
117
|
}
|
|
58
|
-
} catch {
|
|
59
|
-
dlog.warn("WS", "Failed to
|
|
118
|
+
} catch (err) {
|
|
119
|
+
dlog.warn("WS", "Failed to process incoming message", err);
|
|
60
120
|
}
|
|
61
121
|
};
|
|
62
122
|
|
|
@@ -79,8 +139,23 @@ export class BotsChatWSClient {
|
|
|
79
139
|
};
|
|
80
140
|
}
|
|
81
141
|
|
|
82
|
-
send(msg: WSMessage): void {
|
|
142
|
+
async send(msg: WSMessage): Promise<void> {
|
|
83
143
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
144
|
+
// E2E Encryption for user messages
|
|
145
|
+
if (msg.type === "user.message" && E2eService.hasKey() && typeof msg.text === "string") {
|
|
146
|
+
try {
|
|
147
|
+
const { ciphertext, messageId } = await E2eService.encrypt(msg.text);
|
|
148
|
+
msg.text = ciphertext;
|
|
149
|
+
msg.messageId = messageId;
|
|
150
|
+
msg.encrypted = true;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
dlog.error("E2E", "Encryption failed", err);
|
|
153
|
+
// Fail? or send as plaintext?
|
|
154
|
+
// Security first: if key exists but encrypt fails, abort.
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
84
159
|
this.ws.send(JSON.stringify(msg));
|
|
85
160
|
} else {
|
|
86
161
|
dlog.warn("WS", `Cannot send — socket not open (readyState=${this.ws?.readyState})`, msg);
|
package/scripts/dev.sh
CHANGED
|
@@ -58,16 +58,16 @@ do_start() {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
do_sync_plugin() {
|
|
61
|
-
local
|
|
61
|
+
local REMOTE_USER="mini.local"
|
|
62
62
|
local REMOTE_DIR="~/Projects/botsChat/packages/plugin"
|
|
63
63
|
|
|
64
|
-
info "Syncing plugin to
|
|
64
|
+
info "Syncing plugin to mini.local…"
|
|
65
65
|
rsync -avz --exclude node_modules --exclude .git --exclude dist --exclude .wrangler \
|
|
66
|
-
packages/plugin/ "$
|
|
66
|
+
packages/plugin/ "$REMOTE_USER:$REMOTE_DIR/"
|
|
67
67
|
ok "Plugin files synced"
|
|
68
68
|
|
|
69
69
|
info "Building plugin, deploying to extensions, restarting gateway on mini.local…"
|
|
70
|
-
ssh "$
|
|
70
|
+
ssh "$REMOTE_USER" 'export PATH="/opt/homebrew/bin:$PATH"
|
|
71
71
|
cd ~/Projects/botsChat/packages/plugin
|
|
72
72
|
npm run build
|
|
73
73
|
EXT_DIR=~/.openclaw/extensions/botschat
|
|
@@ -83,7 +83,7 @@ echo "Gateway restarted (PID=$!)"'
|
|
|
83
83
|
|
|
84
84
|
sleep 4
|
|
85
85
|
info "Checking connection…"
|
|
86
|
-
ssh "$
|
|
86
|
+
ssh "$REMOTE_USER" 'tail -5 /tmp/openclaw-gateway.log | grep -i "authenticated\|error\|Task scan"'
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
do_logs() {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { deriveKey, encryptText, decryptText, toBase64, fromBase64 } from "../packages/e2e-crypto/e2e-crypto.js";
|
|
3
|
+
|
|
4
|
+
const E2E_PWD = "botschat123";
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
// Login
|
|
8
|
+
const res = await fetch("http://localhost:8787/api/auth/login", {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "Content-Type": "application/json" },
|
|
11
|
+
body: JSON.stringify({ email: "tong@mini.local", password: "botschat123" }),
|
|
12
|
+
});
|
|
13
|
+
if (!res.ok) { console.log("Login failed:", res.status); process.exit(1); }
|
|
14
|
+
const login = await res.json() as { id: string; token: string };
|
|
15
|
+
const { token, id: userId } = login;
|
|
16
|
+
console.log("1. Logged in:", userId);
|
|
17
|
+
|
|
18
|
+
// Channels — create if none
|
|
19
|
+
const chRes = await fetch("http://localhost:8787/api/channels", { headers: { Authorization: `Bearer ${token}` } });
|
|
20
|
+
let { channels } = await chRes.json() as { channels: Array<{ id: string }> };
|
|
21
|
+
if (!channels.length) {
|
|
22
|
+
const cr = await fetch("http://localhost:8787/api/channels", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ name: "Test Channel", description: "E2E test" }),
|
|
26
|
+
});
|
|
27
|
+
const ch = await cr.json() as { id: string };
|
|
28
|
+
channels = [ch];
|
|
29
|
+
console.log(" Created channel:", ch.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Sessions — create a fresh one
|
|
33
|
+
const sesRes = await fetch(`http://localhost:8787/api/channels/${channels[0].id}/sessions`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify({ name: "E2E Chat Test" }),
|
|
37
|
+
});
|
|
38
|
+
const session = await sesRes.json() as { sessionKey: string };
|
|
39
|
+
console.log("2. Session:", session.sessionKey);
|
|
40
|
+
|
|
41
|
+
// Derive key
|
|
42
|
+
const key = await deriveKey(E2E_PWD, userId);
|
|
43
|
+
console.log("3. E2E key derived");
|
|
44
|
+
|
|
45
|
+
// WS connect
|
|
46
|
+
const ws = new WebSocket(`ws://localhost:8787/api/ws/${userId}/chat-test`);
|
|
47
|
+
|
|
48
|
+
await new Promise<void>((resolve, reject) => {
|
|
49
|
+
const timeout = setTimeout(() => { reject(new Error("Timeout 45s")); ws.close(); }, 45000);
|
|
50
|
+
|
|
51
|
+
ws.on("open", () => ws.send(JSON.stringify({ type: "auth", token })));
|
|
52
|
+
|
|
53
|
+
ws.on("message", async (data) => {
|
|
54
|
+
const msg = JSON.parse(data.toString()) as Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
if (msg.type === "auth.ok") {
|
|
57
|
+
console.log("4. WS auth OK");
|
|
58
|
+
const messageId = `chat-test-${Date.now()}`;
|
|
59
|
+
const ct = await encryptText(key, "hello from API test", messageId);
|
|
60
|
+
ws.send(JSON.stringify({
|
|
61
|
+
type: "user.message",
|
|
62
|
+
sessionKey: session.sessionKey,
|
|
63
|
+
text: toBase64(ct),
|
|
64
|
+
messageId,
|
|
65
|
+
encrypted: true,
|
|
66
|
+
}));
|
|
67
|
+
console.log("5. Sent encrypted 'hello from API test'");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (msg.type === "agent.text") {
|
|
71
|
+
console.log(`6. Got agent.text (encrypted=${msg.encrypted}, messageId=${msg.messageId})`);
|
|
72
|
+
if (msg.encrypted && msg.messageId) {
|
|
73
|
+
try {
|
|
74
|
+
const plain = await decryptText(key, fromBase64(msg.text as string), msg.messageId as string);
|
|
75
|
+
console.log(" DECRYPTED:", plain.slice(0, 200));
|
|
76
|
+
} catch (e: any) {
|
|
77
|
+
console.log(" Decrypt FAILED:", e.message);
|
|
78
|
+
console.log(" Raw:", (msg.text as string || "").slice(0, 80));
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
console.log(" PLAINTEXT:", ((msg.text as string) || "").slice(0, 200));
|
|
82
|
+
}
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
resolve();
|
|
85
|
+
ws.close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (msg.type === "error") console.log("ERROR:", msg.message);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
ws.on("error", (e) => console.log("WS error:", e.message));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
console.log("\nTEST COMPLETE");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch((e) => { console.error("FAIL:", e.message); process.exit(1); });
|