Skip to content

Commit 9899847

Browse files
feat(e2e): enhance end-to-end encryption logging and key management
- Added detailed logging for message forwarding and encryption status in the connection and plugin components. - Implemented eager loading of Node.js crypto modules to improve performance and avoid dynamic import issues. - Updated E2E service to cache derived keys and manage password storage more effectively. - Introduced UI enhancements for password visibility in onboarding and settings components. - Added a test script for end-to-end chat functionality to validate encryption and decryption processes.
1 parent 413e172 commit 9899847

10 files changed

Lines changed: 318 additions & 70 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,9 @@ export class ConnectionDO implements DurableObject {
290290
}
291291

292292
// Forward all messages to browser clients
293+
if (msg.type === "agent.text") {
294+
console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
295+
}
293296
this.broadcastToBrowsers(JSON.stringify(msg));
294297
}
295298

packages/e2e-crypto/e2e-crypto.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,16 +130,37 @@ async function decryptWeb(
130130
}
131131

132132
// ---------------------------------------------------------------------------
133-
// Node.js implementation
133+
// Node.js implementation — use static imports resolved at load time.
134+
// Dynamic `await import("node:crypto")` hangs in some extension loaders
135+
// (e.g. OpenClaw gateway), so we resolve the modules eagerly when isNode.
134136
// ---------------------------------------------------------------------------
135137

138+
// Node.js crypto modules — loaded eagerly.
139+
// We use a global cache keyed by "__e2e_crypto" to avoid re-importing
140+
// in environments where the module may be loaded multiple times.
141+
let _nodeCrypto: typeof import("node:crypto") | null = null;
142+
let _nodeUtil: typeof import("node:util") | null = null;
143+
144+
const _g = globalThis as Record<string, unknown>;
145+
if (isNode && _g.__e2e_nodeCrypto) {
146+
_nodeCrypto = _g.__e2e_nodeCrypto as typeof import("node:crypto");
147+
_nodeUtil = _g.__e2e_nodeUtil as typeof import("node:util");
148+
}
149+
150+
async function ensureNodeModules(): Promise<void> {
151+
if (_nodeCrypto && _nodeUtil) return;
152+
_nodeCrypto = await import("node:crypto");
153+
_nodeUtil = await import("node:util");
154+
_g.__e2e_nodeCrypto = _nodeCrypto;
155+
_g.__e2e_nodeUtil = _nodeUtil;
156+
}
157+
136158
async function deriveKeyNode(
137159
password: string,
138160
userId: string,
139161
): Promise<Uint8Array> {
140-
const { pbkdf2 } = await import("node:crypto");
141-
const { promisify } = await import("node:util");
142-
const pbkdf2Async = promisify(pbkdf2);
162+
await ensureNodeModules();
163+
const pbkdf2Async = _nodeUtil!.promisify(_nodeCrypto!.pbkdf2);
143164
const salt = SALT_PREFIX + userId;
144165
const buf = await pbkdf2Async(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256");
145166
return new Uint8Array(buf);
@@ -149,12 +170,12 @@ async function hkdfNonceNode(
149170
key: Uint8Array,
150171
contextId: string,
151172
): Promise<Uint8Array> {
152-
const { createHmac } = await import("node:crypto");
173+
await ensureNodeModules();
153174
const info = utf8Encode("nonce-" + contextId);
154175
const input = new Uint8Array(info.length + 1);
155176
input.set(info);
156177
input[info.length] = 0x01;
157-
const hmac = createHmac("sha256", Buffer.from(key));
178+
const hmac = _nodeCrypto!.createHmac("sha256", Buffer.from(key));
158179
hmac.update(Buffer.from(input));
159180
const full = hmac.digest();
160181
return new Uint8Array(full.buffer, full.byteOffset, NONCE_LENGTH);
@@ -165,9 +186,9 @@ async function encryptNode(
165186
plaintext: Uint8Array,
166187
contextId: string,
167188
): Promise<Uint8Array> {
168-
const { createCipheriv } = await import("node:crypto");
189+
await ensureNodeModules();
169190
const iv = await hkdfNonceNode(key, contextId);
170-
const cipher = createCipheriv(
191+
const cipher = _nodeCrypto!.createCipheriv(
171192
"aes-256-ctr",
172193
Buffer.from(key),
173194
Buffer.from(iv),
@@ -181,9 +202,9 @@ async function decryptNode(
181202
ciphertext: Uint8Array,
182203
contextId: string,
183204
): Promise<Uint8Array> {
184-
const { createDecipheriv } = await import("node:crypto");
205+
await ensureNodeModules();
185206
const iv = await hkdfNonceNode(key, contextId);
186-
const decipher = createDecipheriv(
207+
const decipher = _nodeCrypto!.createDecipheriv(
187208
"aes-256-ctr",
188209
Buffer.from(key),
189210
Buffer.from(iv),

packages/plugin/src/channel.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,17 @@ export const botschatPlugin = {
126126
let text = ctx.text;
127127
let encrypted = false;
128128

129+
console.log(`[botschat][sendText] e2eKey=${!!client.e2eKey}, textLen=${text.length}`);
130+
129131
if (client.e2eKey) {
130132
try {
131133
// Encrypt text using messageId as contextId
132134
const ciphertext = await encryptText(client.e2eKey, text, messageId);
133135
text = toBase64(ciphertext);
134136
encrypted = true;
137+
console.log(`[botschat][sendText] encrypted OK, ctLen=${text.length}`);
135138
} catch (err) {
136-
// Log error but proceed? Or fail? The user expects encryption.
139+
console.error(`[botschat][sendText] Encryption FAILED:`, err);
137140
return { ok: false, error: new Error(`Encryption failed: ${err}`) };
138141
}
139142
}
@@ -147,6 +150,7 @@ export const botschatPlugin = {
147150
messageId,
148151
encrypted,
149152
});
153+
console.log(`[botschat][sendText] sent agent.text, encrypted=${encrypted}`);
150154
return { ok: true };
151155
},
152156

@@ -439,10 +443,10 @@ async function handleCloudMessage(
439443
// pass through the command-auth pipeline instead of being silently
440444
// dropped (the default is false / deny).
441445
const msgCtx: Record<string, unknown> = {
442-
Body: msg.text,
443-
RawBody: msg.text,
444-
CommandBody: msg.text,
445-
BodyForCommands: msg.text,
446+
Body: text,
447+
RawBody: text,
448+
CommandBody: text,
449+
BodyForCommands: text,
446450
From: `botschat:${msg.userId}`,
447451
To: msg.sessionKey,
448452
SessionKey: msg.sessionKey,
@@ -477,22 +481,48 @@ async function handleCloudMessage(
477481

478482
// Create a reply dispatcher that sends responses back through the cloud WSS
479483
const client = getCloudClient(ctx.accountId);
484+
console.log(`[botschat] client for accountId=${ctx.accountId}: connected=${client?.connected}`);
480485
const deliver = async (payload: { text?: string; mediaUrl?: string }) => {
481-
if (!client?.connected) return;
486+
console.log(`[botschat][deliver] called, connected=${client?.connected}, hasKey=${!!client?.e2eKey}, textLen=${(payload.text || "").length}`);
487+
if (!client?.connected) { console.log("[botschat][deliver] SKIP - not connected"); return; }
488+
const messageId = crypto.randomUUID();
489+
let text = payload.text ?? "";
490+
let caption = payload.text ?? "";
491+
let encrypted = false;
492+
493+
if (client.e2eKey && text) {
494+
try {
495+
const ct = await encryptText(client.e2eKey, text, messageId);
496+
text = toBase64(ct);
497+
caption = text;
498+
encrypted = true;
499+
console.log(`[botschat][deliver] encrypted OK: msgId=${messageId}, ctLen=${text.length}, encrypted=${encrypted}`);
500+
} catch (err) {
501+
console.error("[botschat][deliver] E2E encrypt failed:", err);
502+
}
503+
} else {
504+
console.log(`[botschat][deliver] no encryption: hasKey=${!!client.e2eKey}, textLen=${text.length}`);
505+
}
506+
507+
console.log(`[botschat][deliver] sending: type=${payload.mediaUrl ? "agent.media" : "agent.text"}, encrypted=${encrypted}, messageId=${messageId}`);
482508
if (payload.mediaUrl) {
483509
client.send({
484510
type: "agent.media",
485511
sessionKey: msg.sessionKey,
486512
mediaUrl: payload.mediaUrl,
487-
caption: payload.text,
513+
caption: encrypted ? caption : payload.text,
488514
threadId,
515+
messageId,
516+
encrypted,
489517
});
490518
} else if (payload.text) {
491519
client.send({
492520
type: "agent.text",
493521
sessionKey: msg.sessionKey,
494-
text: payload.text,
522+
text,
495523
threadId,
524+
messageId,
525+
encrypted,
496526
});
497527
// Detect model-change confirmations and emit model.changed
498528
// Handles both formats:

packages/plugin/src/ws-client.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,17 +135,21 @@ export class BotsChatCloudClient {
135135
switch (msg.type) {
136136
case "auth.ok":
137137
this.log("info", `Authenticated with BotsChat cloud (userId=${msg.userId}, hasE2ePwd=${!!this.opts.e2ePassword})`);
138+
// Mark connected FIRST so that subsequent messages (task.scan.request,
139+
// models.request) arriving while deriveKey is running can be processed.
140+
this.backoffMs = MIN_BACKOFF_MS;
141+
this.setConnected(true);
142+
this.startPing();
143+
// Derive E2E key AFTER marking connected (PBKDF2 is slow ~1-2s).
138144
if (msg.userId && this.opts.e2ePassword) {
139145
this.log("info", `Deriving E2E key for userId: ${msg.userId}`);
140146
try {
141147
this.e2eKey = await deriveKey(this.opts.e2ePassword, msg.userId);
148+
this.log("info", "E2E key derived successfully");
142149
} catch (err) {
143150
this.log("error", `Failed to derive E2E key: ${err}`);
144151
}
145152
}
146-
this.backoffMs = MIN_BACKOFF_MS;
147-
this.setConnected(true);
148-
this.startPing();
149153
break;
150154
case "auth.fail":
151155
this.log("error", `Authentication failed: ${msg.reason}`);

packages/web/src/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export default function App() {
5252
// Track whether the initial channels fetch has completed (prevents onboarding flash)
5353
const [channelsLoadedOnce, setChannelsLoadedOnce] = useState(false);
5454

55+
// Track E2E key readiness — when key becomes available, re-decrypt messages
56+
const [e2eReady, setE2eReady] = useState(E2eService.hasKey());
57+
useEffect(() => {
58+
return E2eService.subscribe(() => setE2eReady(E2eService.hasKey()));
59+
}, []);
60+
5561
// Responsive layout hooks (must be called unconditionally)
5662
const isMobile = useIsMobile();
5763
const mainLayout = useDefaultLayout({ id: "botschat-main" });
@@ -395,7 +401,7 @@ export default function App() {
395401
console.error("Failed to load message history:", err);
396402
});
397403
return () => { stale = true; };
398-
}, [state.user, state.selectedSessionKey]);
404+
}, [state.user, state.selectedSessionKey, e2eReady]);
399405

400406
// Keep a ref to state for use in WS handler (avoids stale closures)
401407
const stateRef = useRef(state);

packages/web/src/components/E2ESettings.tsx

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export function E2ESettings() {
99
const [remember, setRemember] = useState(false);
1010
const [busy, setBusy] = useState(false);
1111
const [error, setError] = useState<string | null>(null);
12+
const [showPassword, setShowPassword] = useState(false);
1213

1314
// Subscribe to E2eService changes
1415
useEffect(() => {
@@ -63,7 +64,7 @@ export function E2ESettings() {
6364
)}
6465
</span>
6566
{hasKey && (
66-
<button onClick={handleLock} className="text-caption font-bold text-red-500 hover:underline">
67+
<button onClick={handleLock} className="text-caption font-bold hover:underline" style={{ color: "var(--accent-red, #e53e3e)" }}>
6768
Lock / Clear Key
6869
</button>
6970
)}
@@ -73,14 +74,37 @@ export function E2ESettings() {
7374
<div className="space-y-4">
7475
<div>
7576
<label className="block text-caption font-bold mb-1" style={{ color: "var(--text-secondary)" }}>E2E Password</label>
76-
<input
77-
type="password"
78-
value={password}
79-
onChange={e => setPassword(e.target.value)}
80-
className="w-full px-3 py-2 rounded border"
81-
style={{ background: "var(--bg-input)", borderColor: "var(--border)", color: "var(--text-primary)" }}
82-
placeholder="Enter your encryption password"
83-
/>
77+
<div className="relative">
78+
<input
79+
type={showPassword ? "text" : "password"}
80+
value={password}
81+
onChange={e => setPassword(e.target.value)}
82+
className="w-full px-3 py-2 pr-10 rounded border"
83+
style={{ background: "var(--bg-input)", borderColor: "var(--border)", color: "var(--text-primary)" }}
84+
placeholder="Enter your encryption password"
85+
/>
86+
<button
87+
type="button"
88+
onClick={() => setShowPassword(!showPassword)}
89+
className="absolute right-2 top-1/2 -translate-y-1/2 p-1"
90+
style={{ color: "var(--text-muted)" }}
91+
tabIndex={-1}
92+
>
93+
{showPassword ? (
94+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
95+
<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"/>
96+
<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"/>
97+
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>
98+
<line x1="1" y1="1" x2="23" y2="23"/>
99+
</svg>
100+
) : (
101+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
102+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
103+
<circle cx="12" cy="12" r="3"/>
104+
</svg>
105+
)}
106+
</button>
107+
</div>
84108
</div>
85109

86110
<div className="flex items-center gap-2">
@@ -100,8 +124,8 @@ export function E2ESettings() {
100124
<button
101125
onClick={handleUnlock}
102126
disabled={!password || busy}
103-
className="px-4 py-2 rounded font-bold text-white w-full"
104-
style={{ background: "var(--primary)", opacity: (!password || busy) ? 0.5 : 1 }}
127+
className="px-4 py-2 rounded font-bold w-full"
128+
style={{ background: "var(--bg-active, #6366f1)", color: "#fff", opacity: (!password || busy) ? 0.5 : 1 }}
105129
>
106130
{busy ? "Deriving Key..." : "Unlock / Set Password"}
107131
</button>

0 commit comments

Comments
 (0)