Skip to content

Commit fec00fc

Browse files
Merge pull request #3 from botschat-app/fix/e2e-crypto-bundle
fix(plugin): bundle e2e-crypto instead of external dependency
2 parents cfa9cd7 + c41ea0e commit fec00fc

4 files changed

Lines changed: 327 additions & 5 deletions

File tree

packages/plugin/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@botschat/botschat",
33
"version": "0.1.8",
4-
"description": "BotsChat channel plugin for OpenClaw connects your OpenClaw agent to the BotsChat cloud platform",
4+
"description": "BotsChat channel plugin for OpenClaw \u2014 connects your OpenClaw agent to the BotsChat cloud platform",
55
"type": "module",
66
"main": "dist/index.js",
77
"types": "dist/index.d.ts",
@@ -30,8 +30,7 @@
3030
"openclaw": "*"
3131
},
3232
"dependencies": {
33-
"ws": "^8.18.0",
34-
"e2e-crypto": "*"
33+
"ws": "^8.18.0"
3534
},
3635
"devDependencies": {
3736
"@types/ws": "^8.5.0",

packages/plugin/src/channel.ts

Lines changed: 1 addition & 1 deletion
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, toBase64, fromBase64 } from "e2e-crypto";
12+
import { encryptText, decryptText, toBase64, fromBase64 } from "./e2e-crypto.js";
1313

1414
// ---------------------------------------------------------------------------
1515
// A2UI message-tool hints — injected via agentPrompt.messageToolHints so

packages/plugin/src/e2e-crypto.ts

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
/**
2+
* E2E Crypto — Isomorphic AES-256-CTR encryption with PBKDF2 key derivation.
3+
*
4+
* Works in both Web Crypto API (browser / Cloudflare Workers) and Node.js.
5+
*
6+
* Key design decisions:
7+
* - Salt = "botschat-e2e:" + userId (domain-prefixed, deterministic)
8+
* - PBKDF2-SHA256 with 310,000 iterations (OWASP 2023)
9+
* - AES-256-CTR with nonce derived from contextId via HKDF-SHA256
10+
* - Zero ciphertext overhead (no tag/MAC)
11+
* - Each contextId MUST be globally unique and used ONLY ONCE per key
12+
*/
13+
14+
// ---------------------------------------------------------------------------
15+
// Runtime detection
16+
// ---------------------------------------------------------------------------
17+
18+
const isNode =
19+
typeof globalThis.process !== "undefined" &&
20+
typeof globalThis.process.versions?.node === "string";
21+
22+
// ---------------------------------------------------------------------------
23+
// Constants
24+
// ---------------------------------------------------------------------------
25+
26+
const PBKDF2_ITERATIONS = 310_000;
27+
const KEY_LENGTH = 32; // 256 bits
28+
const NONCE_LENGTH = 16; // AES-CTR counter block
29+
const SALT_PREFIX = "botschat-e2e:";
30+
31+
// ---------------------------------------------------------------------------
32+
// Helpers — encode / decode
33+
// ---------------------------------------------------------------------------
34+
35+
function utf8Encode(str: string): Uint8Array {
36+
return new TextEncoder().encode(str);
37+
}
38+
39+
function utf8Decode(buf: Uint8Array): string {
40+
return new TextDecoder().decode(buf);
41+
}
42+
43+
// ---------------------------------------------------------------------------
44+
// Web Crypto (browser + Cloudflare Workers) implementation
45+
// ---------------------------------------------------------------------------
46+
47+
async function deriveKeyWeb(
48+
password: string,
49+
userId: string,
50+
): Promise<Uint8Array> {
51+
const enc = utf8Encode(password);
52+
const salt = utf8Encode(SALT_PREFIX + userId);
53+
const baseKey = await crypto.subtle.importKey("raw", enc.buffer as ArrayBuffer, "PBKDF2", false, [
54+
"deriveBits",
55+
]);
56+
const saltArr = new ArrayBuffer(salt.byteLength);
57+
new Uint8Array(saltArr).set(salt);
58+
const bits = await crypto.subtle.deriveBits(
59+
{ name: "PBKDF2", salt: saltArr, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
60+
baseKey,
61+
KEY_LENGTH * 8,
62+
);
63+
return new Uint8Array(bits);
64+
}
65+
66+
/**
67+
* HKDF-SHA256 expand-only (single-step, info-only).
68+
* We only need 16 bytes so a single HMAC round suffices.
69+
*/
70+
async function hkdfNonceWeb(
71+
key: Uint8Array,
72+
contextId: string,
73+
): Promise<Uint8Array> {
74+
const hmacKey = await crypto.subtle.importKey(
75+
"raw",
76+
key.buffer as ArrayBuffer,
77+
{ name: "HMAC", hash: "SHA-256" },
78+
false,
79+
["sign"],
80+
);
81+
const info = utf8Encode("nonce-" + contextId);
82+
// HKDF-Expand: T(1) = HMAC(PRK, info || 0x01)
83+
const input = new Uint8Array(info.length + 1);
84+
input.set(info);
85+
input[info.length] = 0x01;
86+
const full = await crypto.subtle.sign("HMAC", hmacKey, input.buffer as ArrayBuffer);
87+
return new Uint8Array(full).slice(0, NONCE_LENGTH);
88+
}
89+
90+
async function encryptWeb(
91+
key: Uint8Array,
92+
plaintext: Uint8Array,
93+
contextId: string,
94+
): Promise<Uint8Array> {
95+
const counter = await hkdfNonceWeb(key, contextId);
96+
const aesKey = await crypto.subtle.importKey(
97+
"raw",
98+
key.buffer as ArrayBuffer,
99+
{ name: "AES-CTR" },
100+
false,
101+
["encrypt"],
102+
);
103+
const ciphertext = await crypto.subtle.encrypt(
104+
{ name: "AES-CTR", counter: new Uint8Array(counter).buffer as ArrayBuffer, length: 128 },
105+
aesKey,
106+
plaintext.buffer as ArrayBuffer,
107+
);
108+
return new Uint8Array(ciphertext);
109+
}
110+
111+
async function decryptWeb(
112+
key: Uint8Array,
113+
ciphertext: Uint8Array,
114+
contextId: string,
115+
): Promise<Uint8Array> {
116+
const counter = await hkdfNonceWeb(key, contextId);
117+
const aesKey = await crypto.subtle.importKey(
118+
"raw",
119+
key.buffer as ArrayBuffer,
120+
{ name: "AES-CTR" },
121+
false,
122+
["decrypt"],
123+
);
124+
const plaintext = await crypto.subtle.decrypt(
125+
{ name: "AES-CTR", counter: new Uint8Array(counter).buffer as ArrayBuffer, length: 128 },
126+
aesKey,
127+
ciphertext.buffer as ArrayBuffer,
128+
);
129+
return new Uint8Array(plaintext);
130+
}
131+
132+
// ---------------------------------------------------------------------------
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.
136+
// ---------------------------------------------------------------------------
137+
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+
158+
async function deriveKeyNode(
159+
password: string,
160+
userId: string,
161+
): Promise<Uint8Array> {
162+
await ensureNodeModules();
163+
const pbkdf2Async = _nodeUtil!.promisify(_nodeCrypto!.pbkdf2);
164+
const salt = SALT_PREFIX + userId;
165+
const buf = await pbkdf2Async(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256");
166+
return new Uint8Array(buf);
167+
}
168+
169+
async function hkdfNonceNode(
170+
key: Uint8Array,
171+
contextId: string,
172+
): Promise<Uint8Array> {
173+
await ensureNodeModules();
174+
const info = utf8Encode("nonce-" + contextId);
175+
const input = new Uint8Array(info.length + 1);
176+
input.set(info);
177+
input[info.length] = 0x01;
178+
const hmac = _nodeCrypto!.createHmac("sha256", Buffer.from(key));
179+
hmac.update(Buffer.from(input));
180+
const full = hmac.digest();
181+
return new Uint8Array(full.buffer, full.byteOffset, NONCE_LENGTH);
182+
}
183+
184+
async function encryptNode(
185+
key: Uint8Array,
186+
plaintext: Uint8Array,
187+
contextId: string,
188+
): Promise<Uint8Array> {
189+
await ensureNodeModules();
190+
const iv = await hkdfNonceNode(key, contextId);
191+
const cipher = _nodeCrypto!.createCipheriv(
192+
"aes-256-ctr",
193+
Buffer.from(key),
194+
Buffer.from(iv),
195+
);
196+
const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]);
197+
return new Uint8Array(encrypted);
198+
}
199+
200+
async function decryptNode(
201+
key: Uint8Array,
202+
ciphertext: Uint8Array,
203+
contextId: string,
204+
): Promise<Uint8Array> {
205+
await ensureNodeModules();
206+
const iv = await hkdfNonceNode(key, contextId);
207+
const decipher = _nodeCrypto!.createDecipheriv(
208+
"aes-256-ctr",
209+
Buffer.from(key),
210+
Buffer.from(iv),
211+
);
212+
const decrypted = Buffer.concat([decipher.update(Buffer.from(ciphertext)), decipher.final()]);
213+
return new Uint8Array(decrypted);
214+
}
215+
216+
// ---------------------------------------------------------------------------
217+
// Public API — auto-selects implementation based on runtime
218+
// ---------------------------------------------------------------------------
219+
220+
/**
221+
* Derive a 256-bit master key from the user's E2E password and userId.
222+
* Uses PBKDF2-SHA256 with 310,000 iterations; salt = "botschat-e2e:" + userId.
223+
*/
224+
export async function deriveKey(
225+
password: string,
226+
userId: string,
227+
): Promise<Uint8Array> {
228+
return isNode ? deriveKeyNode(password, userId) : deriveKeyWeb(password, userId);
229+
}
230+
231+
/**
232+
* Encrypt plaintext string using AES-256-CTR with a nonce derived from contextId.
233+
* Returns raw ciphertext bytes (same length as UTF-8 encoded plaintext).
234+
*
235+
* ⚠️ Each contextId MUST be globally unique and used ONLY ONCE per key.
236+
*/
237+
export async function encryptText(
238+
key: Uint8Array,
239+
plaintext: string,
240+
contextId: string,
241+
): Promise<Uint8Array> {
242+
const data = utf8Encode(plaintext);
243+
return isNode
244+
? encryptNode(key, data, contextId)
245+
: encryptWeb(key, data, contextId);
246+
}
247+
248+
/**
249+
* Decrypt ciphertext bytes back to a plaintext string.
250+
* Returns the original UTF-8 string.
251+
*
252+
* Throws or returns garbled text if the key or contextId is wrong
253+
* (AES-CTR has no authentication — caller must handle errors gracefully).
254+
*/
255+
export async function decryptText(
256+
key: Uint8Array,
257+
ciphertext: Uint8Array,
258+
contextId: string,
259+
): Promise<string> {
260+
const data = isNode
261+
? await decryptNode(key, ciphertext, contextId)
262+
: await decryptWeb(key, ciphertext, contextId);
263+
return utf8Decode(data);
264+
}
265+
266+
/**
267+
* Encrypt raw bytes using AES-256-CTR with a nonce derived from contextId.
268+
* Returns raw ciphertext bytes (same length as input).
269+
*/
270+
export async function encryptBytes(
271+
key: Uint8Array,
272+
plaintext: Uint8Array,
273+
contextId: string,
274+
): Promise<Uint8Array> {
275+
return isNode
276+
? encryptNode(key, plaintext, contextId)
277+
: encryptWeb(key, plaintext, contextId);
278+
}
279+
280+
/**
281+
* Decrypt raw ciphertext bytes.
282+
* Returns the original plaintext bytes.
283+
*/
284+
export async function decryptBytes(
285+
key: Uint8Array,
286+
ciphertext: Uint8Array,
287+
contextId: string,
288+
): Promise<Uint8Array> {
289+
return isNode
290+
? await decryptNode(key, ciphertext, contextId)
291+
: await decryptWeb(key, ciphertext, contextId);
292+
}
293+
294+
// ---------------------------------------------------------------------------
295+
// Utility: base64 encode/decode for JSON transport
296+
// ---------------------------------------------------------------------------
297+
298+
/** Encode binary to URL-safe base64 (no padding). */
299+
export function toBase64(data: Uint8Array): string {
300+
// Works in both browser and Node
301+
if (typeof Buffer !== "undefined") {
302+
return Buffer.from(data).toString("base64");
303+
}
304+
let binary = "";
305+
for (let i = 0; i < data.length; i++) {
306+
binary += String.fromCharCode(data[i]);
307+
}
308+
return btoa(binary);
309+
}
310+
311+
/** Decode base64 string to binary. */
312+
export function fromBase64(b64: string): Uint8Array {
313+
if (typeof Buffer !== "undefined") {
314+
const buf = Buffer.from(b64, "base64");
315+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
316+
}
317+
const binary = atob(b64);
318+
const bytes = new Uint8Array(binary.length);
319+
for (let i = 0; i < binary.length; i++) {
320+
bytes[i] = binary.charCodeAt(i);
321+
}
322+
return bytes;
323+
}

packages/plugin/src/ws-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import WebSocket from "ws";
2-
import { deriveKey } from "e2e-crypto";
2+
import { deriveKey } from "./e2e-crypto.js";
33
import type { CloudInbound, CloudOutbound } from "./types.js";
44

55
export type BotsChatCloudClientOptions = {

0 commit comments

Comments
 (0)