|
| 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 | +} |
0 commit comments