feat(amsg): /cloudflare 链路载荷加密改用 Web Crypto,免 nodejs_compat 打包#19
Conversation
单用户 / Cloudflare 入口不再依赖 node crypto:encryption.js 的 5 个导出改为 基于 globalThis.crypto.subtle(全部变 async,调用方已补 await),randomUUID 改走 runtime-neutral helper。线格式与旧实现逐字节兼容(含 base64url 字母表、 96-128 位截断 authTag 的宽容度),由 encryption-compat.test.mjs 的跨实现 互通测试钉住。示例 wrangler.toml 去掉 nodejs_compat。多租户入口行为不变。 Claude-Session: https://claude.ai/code/session_01JmwcPeEKQoJaMrsiCBB5Dt
There was a problem hiding this comment.
Code Review
This pull request migrates the encryption utilities from Node's crypto module to Web Crypto (globalThis.crypto.subtle), removing the need for the nodejs_compat flag on Cloudflare Workers. As a result, key derivation and encryption/decryption functions are now asynchronous, and all calling handlers and tests have been updated to use await. Additionally, a new compatibility test suite has been added to ensure byte-level compatibility with the old implementation. Feedback on these changes highlights two performance optimization opportunities: using chunked encoding in bytesToBase64 to avoid subarray instead of slice when extracting the ciphertext and authentication tag in aesGcmSeal.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function bytesToBase64(buf) { | ||
| const bytes = toUint8(buf); | ||
| let bin = ''; | ||
| for (let i = 0; i < bytes.length; i++) { | ||
| bin += String.fromCharCode(bytes[i]); | ||
| } | ||
| // btoa is available in all Web Crypto runtimes (browsers, Workers, Node 16+). | ||
| const b64 = (typeof btoa === 'function') | ||
| return (typeof btoa === 'function') | ||
| ? btoa(bin) | ||
| : Buffer.from(bin, 'binary').toString('base64'); | ||
| return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); | ||
| } |
There was a problem hiding this comment.
Performance Bottleneck & Dead Code Elimination
-
Performance Bottleneck: The current implementation performs byte-by-byte string concatenation (
bin += String.fromCharCode(bytes[i])), which is highly inefficient ($O(N^2)$ memory allocations) for larger payloads (such as encrypted task payloads or message histories). Using chunkedString.fromCharCode.applyis significantly faster and avoids call stack overflow. -
Dead Code Elimination: Since the project requires Node ≥ 19 and Cloudflare Workers (both of which have global
btoabuilt-in), the fallback to Node'sBufferis dead code and can be safely removed. This also prevents bundlers from attempting to polyfillBufferin neutral/browser environments.
export function bytesToBase64(buf) {
const bytes = toUint8(buf);
const CHUNK_SIZE = 0x1000;
let bin = '';
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK_SIZE));
}
return btoa(bin);
}| return { | ||
| ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES), | ||
| authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES) | ||
| }; |
There was a problem hiding this comment.
Performance Optimization: Zero-Copy Subarray
Using sealed.slice copies the underlying ArrayBuffer twice to create new Uint8Array instances. Since the returned ciphertext and authTag are immediately encoded to base64/hex and then discarded, there is no risk of accidental mutation.
Using sealed.subarray creates a view over the existing buffer without copying the bytes, making it a zero-copy, highly efficient alternative.
| return { | |
| ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES), | |
| authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES) | |
| }; | |
| return { | |
| ciphertext: sealed.subarray(0, sealed.length - TAG_LENGTH_BYTES), | |
| authTag: sealed.subarray(sealed.length - TAG_LENGTH_BYTES) | |
| }; |
改了什么
@rei-standard/amsg-server/cloudflare此前打包必须开nodejs_compat(载荷 AES 加密还在用 nodecrypto),现在整条单用户 / Cloudflare 链路不再引用任何 Node 内建模块:--platform=neutral打包Could not resolve "crypto"node:引用 0compatibility_flags = ["nodejs_compat"]lib/encryption.js的 5 个导出(deriveUserEncryptionKey/encryptPayload/decryptPayload/encryptForStorage/decryptFromStorage)改为基于globalThis.crypto.subtle,全部从同步变 async,仓内所有调用点(含测试)已补await。randomUUID改从 runtime-neutral 的webcrypto-utils.js获取;该文件新增 hex / 标准 base64 编解码 helper。tenant/三个多租户专用文件按计划保留 node crypto(不在 cloudflare import 图里)。线格式兼容(命门)
与旧实现逐字节兼容:payload 12 字节 IV + base64、storage 16 字节 IV + hex
iv:authTag:data、authTag 分离存放、key 派生sha256(masterKey+userId)前 64 hex。宽容度也对齐旧的Buffer.from/ node decipher 行为:base64url 字母表(-/_)照收,96–128 位截断 authTag 照解。回归测试(先在旧实现上跑绿钉住格式,port 后必须仍绿)
新增
test/encryption-compat.test.mjs(16 条),用独立的 node:crypto 参考实现做跨实现互通:createHash('sha256')逐字符相等;IV/tag 长度与编码布局断言。全仓 5 包 500 测试全绿;neutral bundle 在 Node 里端到端冒烟通过(init → get-user-key(派生 key 与源码实现一致)→ 加密 schedule-message 201)。
发版
changeset
amsg-server-webcrypto-encryption.md(@rei-standard/amsg-serverminor)。合并后next预发布线上一档(→2.6.0-next.2)。注意:对直接 import 这 5 个加密函数的外部消费者是 sync→async 的行为变更,迁移注意已写进 changeset。https://claude.ai/code/session_01JmwcPeEKQoJaMrsiCBB5Dt