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.
Files changed (45) hide show
  1. package/README.md +62 -22
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +4 -2
  4. package/packages/api/src/do/connection-do.ts +37 -9
  5. package/packages/api/src/index.ts +29 -7
  6. package/packages/api/src/routes/auth.ts +4 -1
  7. package/packages/api/src/routes/setup.ts +2 -0
  8. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  9. package/packages/plugin/dist/src/accounts.js +1 -0
  10. package/packages/plugin/dist/src/accounts.js.map +1 -1
  11. package/packages/plugin/dist/src/channel.d.ts +1 -0
  12. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  13. package/packages/plugin/dist/src/channel.js +180 -13
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/dist/src/types.d.ts +16 -0
  16. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  17. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  18. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/ws-client.js +18 -3
  20. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  21. package/packages/plugin/package.json +3 -2
  22. package/packages/web/dist/architecture.png +0 -0
  23. package/packages/web/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  24. package/packages/web/dist/assets/{index-BST9bfvT.css → index-B1sFqYiM.css} +1 -1
  25. package/packages/web/dist/assets/index-C-FpELeN.js +1497 -0
  26. package/packages/web/dist/index.html +2 -2
  27. package/packages/web/package.json +1 -0
  28. package/packages/web/src/App.tsx +53 -9
  29. package/packages/web/src/analytics.ts +57 -0
  30. package/packages/web/src/api.ts +4 -0
  31. package/packages/web/src/components/ConnectionSettings.tsx +3 -1
  32. package/packages/web/src/components/E2ESettings.tsx +146 -0
  33. package/packages/web/src/components/IconRail.tsx +1 -12
  34. package/packages/web/src/components/LoginPage.tsx +19 -3
  35. package/packages/web/src/components/OnboardingPage.tsx +199 -5
  36. package/packages/web/src/e2e.ts +146 -0
  37. package/packages/web/src/main.tsx +3 -0
  38. package/packages/web/src/store.ts +4 -3
  39. package/packages/web/src/ws.ts +79 -4
  40. package/scripts/dev.sh +5 -5
  41. package/scripts/test-e2e-chat.ts +97 -0
  42. package/scripts/test-e2e-live.ts +194 -0
  43. package/scripts/verify-e2e-db.ts +48 -0
  44. package/scripts/verify-e2e.ts +56 -0
  45. 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
- 1
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 2 */}
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
- 2
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 global defaultModel from OpenClaw config.
306
- // It never touches sessionModel that's per-session and managed separately.
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: action.defaultModel ?? state.defaultModel,
311
+ defaultModel: state.defaultModel ?? action.defaultModel ?? null,
311
312
  };
312
313
  case "SET_SESSION_MODEL":
313
314
  return { ...state, sessionModel: action.model };
@@ -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 parse incoming message", evt.data);
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 REMOTE="mini.local"
61
+ local REMOTE_USER="mini.local"
62
62
  local REMOTE_DIR="~/Projects/botsChat/packages/plugin"
63
63
 
64
- info "Syncing plugin to $REMOTE…"
64
+ info "Syncing plugin to mini.local…"
65
65
  rsync -avz --exclude node_modules --exclude .git --exclude dist --exclude .wrangler \
66
- packages/plugin/ "$REMOTE:$REMOTE_DIR/"
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 "$REMOTE" 'export PATH="/opt/homebrew/bin:$PATH"
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 "$REMOTE" 'tail -5 /tmp/openclaw-gateway.log | grep -i "authenticated\|error\|Task scan"'
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); });